Close the anonymous write path into client_error_log - #164
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Updates to Preview Branch (security/client-error-log-edge-function) ↗︎
Tasks are run on every commit but only new migration files are pushed.
❌ Branch Error • Sun, 16 Aug 2026 10:13:26 UTC View logs for this Workflow Run ↗︎. |
There was a problem hiding this comment.
💡 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".
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>
The problem
client_error_loggrantedINSERTtoanonso the browser could post its own crashes (migration). What made that look bounded — a ten-per-page-load cap and dedupe by message — lives insrc/lib/errorReporting.ts, on the reporting side of the network. The anon key ships inside the JS bundle, so anything couldPOSTstraight 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-errorEdge Function, which runs under the service role and callsrecord_client_error().anonandauthenticatedloseINSERTentirely.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_logstores 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:
anonretains neither table-level nor column-levelINSERT, and noINSERTpolicy remains;EXECUTEonrecord_client_erroris granted toservice_roleonly;sourcefalls back to/; empty optional fields storeNULL;pnpm typecheck, eslint on the changed file, and a six-localepnpm buildall pass.Deploy order
Matters here, or reports are silently dropped in the gap:
supabase functions deploy report-client-errorOptional: set an
ERROR_LOG_IP_SALTsecret. Without it the salt falls back to the service role key, so forgetting it is not a weakness.Not included
client_error_logstill 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