Skip to content

Close the anonymous write path into client_error_log - #164

Merged
YurMil merged 1 commit into
mainfrom
security/client-error-log-edge-function
Aug 16, 2026
Merged

Close the anonymous write path into client_error_log#164
YurMil merged 1 commit into
mainfrom
security/client-error-log-edge-function

Conversation

@YurMil

@YurMil YurMil commented Aug 16, 2026

Copy link
Copy Markdown
Owner

The problem

client_error_log granted INSERT to anon so the browser could post its own crashes (migration). What made that look bounded — a ten-per-page-load cap and dedupe by message — lives in src/lib/errorReporting.ts, on the reporting side of the network. The anon key ships inside the JS bundle, so anything could POST straight to PostgREST and write unbounded rows of nearly 8 KB each; the client-side caps would never see the traffic.

The change

Reports go through a new report-client-error Edge Function, which runs under the service role and calls record_client_error(). anon and authenticated lose INSERT entirely.

The quota lives in the database, not the function. The slot is claimed by the same statement that increments it, so two concurrent reports cannot both read a stale count, and the limit survives a function redeploy.

Two limits, not one. 30 reports per address per hour, and 2000 rows per hour across the whole table. The second is not symmetry: the address header can be forged, which spreads a flood across quota buckets, and the global ceiling does not depend on that value.

The quota table is separate from the log on purpose. client_error_log stores no identity and that stays true — the quota holds only a salted SHA-256 of the address, nothing keys the two tables together, and rows are pruned once their window is spent.

Also: the user agent now comes from the request headers rather than the request body, and the function answers identically whether a report was stored, throttled or malformed, so the limit cannot be calibrated against.

Verification

Both migrations applied to a throwaway Postgres 17:

  • migration is idempotent (re-applied cleanly);
  • anon retains neither table-level nor column-level INSERT, and no INSERT policy remains;
  • EXECUTE on record_client_error is granted to service_role only;
  • 35 reports from one address store exactly 30 and drop 5; a second address is unaffected;
  • an expired window resets the counter to 1 and the next report is accepted;
  • with 2000 rows in the last hour, a report from a fresh address is refused;
  • fields truncate to the column limits; an empty source falls back to /; empty optional fields store NULL;
  • a malformed hash and a blank message are both rejected.

pnpm typecheck, eslint on the changed file, and a six-locale pnpm build all pass.

Deploy order

Matters here, or reports are silently dropped in the gap:

  1. supabase functions deploy report-client-error
  2. deploy the site
  3. apply the migration

Optional: set an ERROR_LOG_IP_SALT secret. Without it the salt falls back to the service role key, so forgetting it is not a weakness.

Not included

client_error_log still has no retention policy — the flood is bounded now, but the table grows without limit under normal traffic. Automatic deletion of stored data is a decision worth making on its own rather than attaching it to a security fix.

🤖 Generated with Claude Code

client_error_log granted INSERT to anon so the browser could post its own
crashes. The caps that made that look bounded — ten reports per page load,
dedupe by message — live in src/lib/errorReporting.ts, on the reporting side
of the network. The anon key ships in the JS bundle, so anything could POST
straight to PostgREST and write unbounded rows of nearly 8 KB, and those caps
would never see the traffic.

Reports now go through the report-client-error Edge Function, which runs under
the service role and calls record_client_error(). anon and authenticated lose
INSERT entirely.

The quota lives in the database, not the function: the slot is claimed by the
same statement that increments it, so concurrent reports cannot race past it,
and it survives a redeploy. Two limits — 30 per address per hour, and 2000
rows per hour across the table. The second is not symmetry. The address header
can be forged, which spreads a flood across buckets; the global ceiling does
not depend on that value and bounds the worst case regardless.

The quota table is separate from the log on purpose. client_error_log stores
no identity and that stays true: the quota holds only a salted SHA-256 of the
address, there is no shared key between the tables, and rows are pruned once
their window is spent.

The user agent now comes from the request headers rather than the body, and
the function answers identically whether a report was stored, throttled or
malformed — a caller who could tell those apart could calibrate against the
limit.

Verified against a throwaway Postgres 17: the migration is idempotent, anon
retains neither table nor column INSERT privileges, 35 reports from one
address store exactly 30, a second address is unaffected, an expired window
resets the counter, the 2001st row in an hour is refused even to a fresh
address, and fields truncate to the column limits. typecheck, lint and a
six-locale build pass.

Deploy order matters: function first, then the site, then the migration.
Applying the migration ahead of the others silently drops reports in between.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
cadautoscript-com Ready Ready Preview Aug 16, 2026 10:19am

@supabase

supabase Bot commented Aug 16, 2026

Copy link
Copy Markdown

Updates to Preview Branch (security/client-error-log-edge-function) ↗︎

Deployments Status Updated
Database Sun, 16 Aug 2026 10:13:14 UTC
Services Sun, 16 Aug 2026 10:13:14 UTC
APIs Sun, 16 Aug 2026 10:13:14 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

Tasks Status Updated
Configurations Sun, 16 Aug 2026 10:13:21 UTC
Migrations Sun, 16 Aug 2026 10:13:25 UTC
Seeding ⏸️ Sun, 16 Aug 2026 10:12:58 UTC
Edge Functions ⏸️ Sun, 16 Aug 2026 10:12:58 UTC

❌ Branch Error • Sun, 16 Aug 2026 10:13:26 UTC

ERROR: function public.is_admin() does not exist (SQLSTATE 42883)
At statement: 5
create policy "Admins can read audit log"
  on public.admin_audit_log
  for select
  to authenticated
  using (public.is_admin())

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ece1f104a7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread supabase/migrations/20260816000000_harden_client_error_log.sql
Comment thread supabase/migrations/20260816000000_harden_client_error_log.sql
@YurMil
YurMil merged commit dcb1b77 into main Aug 16, 2026
7 of 8 checks passed
YurMil added a commit that referenced this pull request Aug 17, 2026
Three things are pending that a checkout cannot do on its own, and one of them
is time-sensitive: PR #164 is merged and deployed, so the production bundle
already calls an Edge Function that does not exist yet. Error reports are being
dropped, and the anon INSERT grant the PR exists to remove is still in place
until the migration is applied.

Also records the second half of the preview-check story. PR #166 fixes the
is_admin() ordering; the check then fails on public.profiles, which is
referenced by five migrations and defined by none. Closing that needs the real
table definition dumped from production — reconstructing it from the columns
the migrations happen to touch would turn the check green while describing a
schema that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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