Skip to content

Security hardening: RCE upgrade, role-aware RLS, OAuth connector binding - #21

Merged
aliihsaad merged 8 commits into
masterfrom
security/safe-hardening-pass
Sep 12, 2026
Merged

aliihsaad merged 8 commits into
masterfrom
security/safe-hardening-pass

Conversation

@aliihsaad

Copy link
Copy Markdown
Owner

Full-project security audit plus the fixes, verified against the live database at each step.

Highest impact

Next.js 16.2.12 -> 16.3.5, sharp -> 0.35.4. Two unauthenticated RCE advisories, one of them in the Image Optimization API, which next.config.ts actively enables via remotePatterns. sharp was additionally pinned to the vulnerable 0.35.3 by this repo's own overrides block. npm audit --omit=dev went from 1 critical + 1 high to 0.

Role-aware RLS across 18 tables. is_member_of() resolved membership but not role, and 18 tables carried GRANT ALL TO authenticated, so a viewer could bypass the app entirely and write through PostgREST — including Instagram tokens, Telegram bot tokens, and AI provider keys. Adds has_workspace_role() and splits the blanket FOR ALL policies into member SELECT plus role-constrained writes, mirroring the permissions the routes already enforce.

OAuth connector could leak Developer API keys. /register persisted nothing, so /authorize validated redirect_uri only as "starts with https://". Since the authorization code encrypts the operator's raw API key, an attacker could have a workspace owner approve a consent page on the genuine origin and receive a code redeemable for full API access. Codes were also replayable for their full 10 minute TTL. Now: clients are persisted, redirect_uri must exactly match a registered URI, and codes are single-use.

Also included

  • Advisor function hardening: REST execute revoked on 4 trigger-only SECURITY DEFINER functions, search_path pinned on 2 more.
  • rate_limit_buckets: RLS enabled and grants revoked.
  • Auth check added to two use server exports that trusted a caller-supplied workspaceId.
  • Constant-time comparison for the cron secret and Meta webhook verify token.
  • Bug fix: automation-worker-send-email was dispatched by the graph executor but missing from both deploy manifests, so fresh deploys never shipped it.
  • pg_cron run-history pruning plus a runbook for the 0.5 GB database warning (96% of which was pg_cron/pg_net logs, not application data).

Verification

tsc 0 errors, 570/570 tests (up from 565), lint 0 errors, production build passing. Every database change was applied and then confirmed by live query — the final check classified all 56 write-capable policies across the 18 tables and found zero still role-blind.

Two findings retracted after live verification

Worth recording, since the original audit was wrong about both:

  • rate_limit_buckets was reported as exploitable on the assumption that stock Supabase default privileges granted it to anon/authenticated. Live relacl was empty (owner-only). Rate limits were never bypassable. The migration is kept as hygiene.
  • pg_net was reported as a blind SSRF vector. Its functions live in the net schema, which PostgREST does not route to, and it is load-bearing for the every-minute scheduler-tick heartbeat. "Fixing" it would have broken automation for a vulnerability that did not exist.

Deployment notes

All migrations that code depends on are already applied to production, so this is safe to merge and deploy as-is. One migration is still pending and has no code dependency:

npx supabase db push --linked   # applies 20260912170000_prune_cron_run_history

Behaviour change to be aware of: a viewer or editor hitting PostgREST directly with a non-admin token will now be refused writes. Nothing changes through the app, where every write path already required owner/admin. An existing MCP connector would need to re-register to authorize again; existing access and refresh tokens keep working.

🤖 Generated with Claude Code

…sons

Three low-risk security fixes from the full-project audit. None changes
application behaviour; verified with tsc, 565/565 tests, lint and a
production build.

- rate_limit_buckets: enable RLS and revoke anon/authenticated grants.
  The table backing every application rate limit was created without RLS,
  so stock Supabase default privileges left it directly writable over
  PostgREST by any signed-in user, making all limiters bypassable. Its
  only accessor is the consume_rate_limit security definer function,
  which bypasses RLS, so no code path changes.

- getWorkspaceSettings / getWorkspaceSettingsForDisplay: require a session
  and workspace:read on the caller-supplied workspaceId. Every export in a
  'use server' file is a POST-reachable endpoint; these two trusted their
  argument. RLS made it non-exploitable today, but the check keeps the
  action safe if the reader is ever moved to the admin client.

- Cron secret and Meta webhook verify token: compare with
  timingSafeStringEqual instead of ===. The webhook POST path already used
  timingSafeEqual; this brings the two remaining comparisons in line.

Migration is committed but NOT yet applied to the Supabase project.
…xes)

Next.js 16.2.12 was affected by two unauthenticated remote code execution
advisories, and the app actively enables the feature one of them targets:

- GHSA-2xp9-vwfh-vxw4: unauthenticated RCE in the Image Optimization API
  via AVIF. next.config.ts configures remotePatterns for cdninstagram.com
  and fbcdn.net, so this code path is live.
- GHSA-p293-qw3h-jr36: unauthenticated RCE on Windows-hosted servers.

sharp was additionally held at the vulnerable 0.35.3 by this project's own
overrides block (GHSA-rgj7-g3m4-5g8c, libheif). Raised to 0.35.4.

eslint-config-next moved to 16.3.5 in lockstep, matching the repo's
convention of pinning both exactly.

npm audit --omit=dev now reports 0 vulnerabilities, down from 1 critical
and 1 high. The 4 remaining advisories are dev-only (eslint, vitest,
js-yaml) and do not ship to production.

Verified: tsc 0 errors, 565/565 tests, production build OK, lint 0 errors.
Lint gained 2 warnings from a new rule in the upgraded config
(no-location-assign-relative-destination) flagging pre-existing
window.location.href use in two Instagram connect components; left
unchanged as they are warnings on working navigation behaviour.
process-automations/graph-executor.ts maps the action_send_email node type
to the automation-worker-send-email edge function, but that function was
missing from both scripts/deploy-managed-supabase.mjs and
scripts/setup-check.mjs while every other automation worker was listed in
both. A fresh managed deployment therefore never shipped it, so send-email
automation nodes would fail at runtime against a newly provisioned project.

The function already exists and already carries the assertInternalInvoke
guard, so this only adds it to the deploy and preflight manifests.

Verified all eight workers dispatched by graph-executor are now present in
both manifests. 565/565 tests pass.
…h_path

Live Supabase advisors still reported three findings that
20260702110000_advisor_security_and_fk_index_remediation.sql had started but
not completed, because later migrations added functions that missed the same
treatment:

- anon/authenticated_security_definer_function_executable (5 each): four
  trigger-only SECURITY DEFINER functions were callable over PostgREST by anon
  and authenticated. Each has exactly one CREATE TRIGGER definition and zero
  application rpc() call sites, so nothing needs REST execute. Revoked, the
  same way 20260702110000 handled create_default_settings and
  create_default_workspace_settings.

- is_member_of(uuid) is the fifth function in those advisor findings and is
  deliberately left executable: it backs 16 RLS policy expressions in the
  baseline schema and revoking it would break row-level security. That
  exception is already documented in 20260702110000.

- function_search_path_mutable (2): claim_webhook_inbox_events and
  claim_automation_actions had no pinned search_path. Both are SECURITY
  INVOKER with fully schema-qualified bodies and execute granted only to
  service_role and the worker roles, so this is deterministic-resolution
  hygiene, not a privilege fix.

Signatures were read from the live database rather than inferred. 565/565
tests pass. Not yet applied.
is_member_of(uuid) resolves membership only, so the baseline "Member access"
policies on these two tables — both FOR ALL with no FOR clause — granted every
member, viewer included, full read and write. Combined with the table-level
GRANT ALL TO authenticated that both tables carry (confirmed live:
authenticated=arwdDxtm/postgres), a viewer could bypass the application
entirely, call PostgREST with their own JWT, and overwrite provider
credentials: telegram_bot_token, the AI provider keys, and the Instagram
access_token/refresh_token. decryptSecretIfNeeded() returns any value lacking
an enc: prefix verbatim, so an attacker-written plaintext token would then be
used as-is by the runtime.

Adds has_workspace_role(uuid, text[]) — is_member_of plus a role constraint,
SECURITY DEFINER with pinned search_path — and splits each FOR ALL policy into
a member SELECT plus owner/admin INSERT/UPDATE/DELETE.

This does not change application behaviour. Every session-client write path to
these tables already enforces the same restriction, verified route by route:
workspace_settings via requireWorkspacePermission(..., 'settings:write') and
social_accounts via 'integrations:write', both mapping to ["owner","admin"] in
WORKSPACE_PERMISSION_ROLE_MAP. Reads are untouched and remain open to every
member. Service-role callers bypass RLS. The two worker-role SELECT policies on
social_accounts are left in place.

Scoped deliberately to the two credential-bearing tables. The same role-blind
pattern exists on other baseline tables and needs the same per-table review of
write paths before it can be changed safely.

565/565 tests pass. Not yet applied.
The Developer API OAuth connector had two flaws in the same subsystem.

redirect_uri was unbound. /register returned a client_id but persisted
nothing, so /authorize had no registered set to compare against and validated
only that the URI started with "https://". Because the authorization code
encrypts the operator's raw Developer API key, and /api/developer/mcp unwraps
it back into a Bearer header, an attacker could send a workspace owner an
authorize link pointing at their own host. The consent page renders on the
genuine SwiftFlow origin asking for exactly what the product legitimately
asks for, so approving it hands the attacker a code that exchanges into full
workspace API access. PKCE does not help: it binds the code to whoever made
the request, which in that flow is the attacker.

Authorization codes were replayable. verifyDeveloperOAuthCode only decrypted
and checked exp, with no server-side record, so a code stayed valid for its
full 10 minute TTL even after the legitimate client redeemed it.

Changes:
- New developer_oauth_clients and developer_oauth_used_codes tables, both
  service_role only.
- /register now persists client_id with its redirect_uris and rejects a
  registration that has no usable HTTPS URI.
- /authorize resolves client_id and requires redirect_uri to exactly match a
  registered URI, on both GET and POST. Failures render an error page and
  never redirect, so an unregistered URI cannot be used to bounce the user.
- Codes carry a jti; /token claims it after all other checks pass, so a
  replay is refused while a failed PKCE attempt does not burn a valid code.
  jti is optional on the payload type so codes minted before this deploy
  still verify; they expire within the 10 minute TTL.

Matching is exact string comparison with no normalization, prefix matching or
wildcards, since those are what make redirect_uri validation bypassable.

Verified nothing is actively connected before changing the flow: 2 API keys
exist, both used, but last use was 2026-07-23. Existing access and refresh
tokens are unaffected either way, as they verify by decryption rather than
client lookup.

570 tests pass, up from 565: adds regression coverage for unregistered
redirect_uri, unknown client_id, registration without HTTPS redirect, code
replay, and non-consumption on PKCE failure.
Completes the work started in 20260912140000. Eleven more baseline tables had
policies that resolved workspace membership but not role, so any member —
viewer included — could bypass the application through PostgREST and write
content, automations, inbox rows, brand profiles, and third-party service
credentials.

Write roles are taken from the permission each table's own routes already
enforce via requireWorkspacePermission, per WORKSPACE_PERMISSION_ROLE_MAP:

  content:write    (owner/admin/editor) - posts, comments, conversations,
                                          messages
  automation:write (owner/admin)        - automations, automation_logs,
                                          automation_scheduled_executions,
                                          processed_comments
  settings:write   (owner/admin)        - external_services,
                                          workspace_brand_profiles
  analytics:sync   (owner/admin)        - analytics_snapshots

Reads are unchanged: every member keeps SELECT everywhere. Service-role
callers bypass RLS, and the existing "Service role has full access" policies
are left alone — each is gated by an auth.jwt() role check in its USING
clause, so it never widens access for a normal user. Worker-role policies are
untouched.

automation_logs and automation_scheduled_executions carry no workspace_id, so
both are scoped through their parent automation, matching the shape of the
policies they replace. This was caught by checking the live schema rather than
assuming the column existed.

Not changed: workspaces, workspace_members, workspace_invites and
publishing_automations already constrain role in their own policies, and
chat_sessions is scoped per user rather than by workspace role.

570/570 tests pass. Not yet applied.
The Supabase 0.5 GB warning is not application data. At 492 MB total, the
public schema — all SwiftFlow data — was 15 MB (3%). The rest was byproducts
of scheduler-tick-cron firing every minute:

- cron.job_run_details: 289,479 real rows back to 2026-02-23 (195 MB).
  pg_cron has no built-in retention, so this grows unbounded at ~1440
  rows/day.
- net._http_response: only 360 live rows over ~6 hours (pg_net expires its
  own rows) but 271 MB of heap with 0 dead tuples — bloat that will never
  shrink on its own.

This migration schedules purge-cron-run-history nightly to trim
cron.job_run_details to 7 days, which stops the growth. It is idempotent: the
job is unscheduled first so a re-run or db reset cannot duplicate it.

Reclaiming the space already on disk is deliberately NOT in the migration —
TRUNCATE is destructive and does not belong in schema history. It is
documented in docs/operations/database-size-reclaim.md with the reasoning for
why each truncate is safe. VACUUM FULL is not an option: both tables are owned
by supabase_admin rather than postgres. TRUNCATE is, and returns the space
immediately.

Worth recording: the existing retention-cleanup edge function and
workspace_retention_policies target public-schema tables totalling ~15 MB, so
enabling them would not have addressed this warning.
@vercel

vercel Bot commented Sep 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
social-media-manager-ai Ready Ready Preview Sep 12, 2026 3:03pm UTC

@coderabbitai

coderabbitai Bot commented Sep 12, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 0837b49e-db63-47d9-ab79-ad94ce86aca8


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@aliihsaad
aliihsaad merged commit 049bf74 into master Sep 12, 2026
3 of 4 checks passed

This branch was successfully deployed

1 active deployment
Preview — 70a56672 Deployed Sep 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant