Full-stack SecOps pass in response to the Vercel April 2026 security
bulletin.
Vercel disclosed unauthorised access to internal systems and advised
customers to review environment variables, enable the Sensitive env-var
feature, and rotate secrets. This changelog entry records the code-side
work; the vendor-side rotation checklist lives in
ROTATION-RUNBOOK.md and needs to be worked
through in the Vercel dashboard.
- New:
SECURITY.md— vulnerability disclosure policy, reporting channels, scope, and safe-harbour language. - New:
ROTATION-RUNBOOK.md— per-secret step-by-step forCIVACCOUNT_DATA_TOKEN,STRIPE_SECRET_KEY,UPSTASH_REDIS_REST_TOKEN, Supabase JWT secret, and the Vercel-incident one-time checklist.
HTTP security headers (next.config.ts)
- CSP hardened: added
object-src 'none',manifest-src,worker-src, explicitframe-srcfor Stripe Checkout,upgrade-insecure-requests, Supabase realtime WebSocket + Vitals toconnect-src. Form-action allow-list now includeshttps://checkout.stripe.com. - New headers:
Cross-Origin-Opener-Policy: same-origin,X-Permitted-Cross-Domain-Policies: none, hugely expandedPermissions-Policydenying every unused capability (accelerometer, autoplay, fullscreen, gyroscope, HID, idle-detection, interest-cohort, keyboard-map, magnetometer, midi, otp-credentials, picture-in-picture, screen-wake-lock, serial, storage-access, sync-xhr, usb, web-share, window-management, xr-spatial-tracking — the full set). - Embed pages: removed invalid
X-Frame-Options: ALLOWALL(silently ignored by browsers); framing policy is now expressed viaframe-ancestors *in CSP only. - OG share endpoints: added
Cross-Origin-Resource-Policy: cross-originso third-party embeds load without triggering COEP rejections. poweredByHeader: false— removes theX-Powered-By: Next.jsframework fingerprint.
- New:
src/lib/security.ts—checkOrigin()andclamp()helpers. POST /api/checkout: validates Origin/Referer against an explicit allow-list (prod + canonical + localhost + Vercel preview regex) before touching Stripe. Stripe success/cancel URLs are now pinned toPRODUCTION_ORIGINin prod — an attacker-crafted Origin header can no longer steer the post-payment redirect. Error logging redacted so Stripe request bodies aren't echoed to stdout.POST /api/feature-request: same Origin guard before rate-limit budget is spent.
GET /api/share/statandGET /api/share/[slug]/[type]: added IP rate-limits (30 / 60 rpm), length clamps on every query param (label, value, council, type, context, slug, type-path), format allow-list, and early rejection of oversized slugs. Defends against cheap Satori-render DoS via the public OG surface.
- New:
src/lib/safe-json-ld.ts—serializeJsonLd()escapes<,>,&, U+2028 and U+2029 so a</script>substring anywhere in JSON-LD data cannot close the script tag early. - Wired into 44 page/layout files (every
<script type="application/ld+json">site-wide) via a one-off migration.
src/lib/rate-limit.ts: IP extraction now prefersx-vercel-forwarded-for(unspoofable on Vercel) overx-forwarded-for, withx-real-ipfallback and IPv6 normalisation.
scripts/fetch-private-data.mjs: replacedexecSyncstring concat withspawnSyncargv (no shell expansion). Token is shape-validated (ghp_/github_pat_) before use — a malformed token is treated as "no token" without being echoed. Token is redacted from any git stderr/stdout before logging. SetsGIT_TERMINAL_PROMPT=0+GIT_ASKPASS=/bin/trueso a revoked token can never hang a Vercel build on an interactive credential prompt.
Supabase hardening (supabase/migrations/003_security_april_2026.sql)
- Email leak fixed: the previous
"Public can see basic user info"RLS policy withUSING (true)was a GDPR leak — anonymous clients couldSELECT emaildirectly frompublic.users. New policy model: RLS restricts by row (self only), column-levelGRANTrestricts by column (no email for anon).public.user_profilesview runs withsecurity_invoker = trueand is the only anon-readable surface. search_pathpinned on everySECURITY DEFINERfunction (update_proposal_score,update_comment_count,flag_comment,check_vote_rate_limit,check_proposal_rate_limit,check_comment_rate_limit) to defeat public-schema function hijacks.- Display-name constraint now rejects zero-width joiners, RTL overrides, and BOMs to defeat homograph username attacks.
- Realtime publication:
public.usersremoved (if present) so email changes aren't broadcast on the realtime websocket.
.githooks/pre-commit: broader pattern set — Stripe (sk_live_,sk_test_,rk_live_,rk_test_,whsec_,pk_live_), GitHub (ghp_,github_pat_,gh[souru]_), AWS (AKIA,ASIA), Slack (xoxb-,xoxp-,xoxa-,xoxo-), SendGrid, Google (AIza), every common PEM private-key header. Allow-list for docs/runbooks/example files. Tested against a synthetic secret + an allow-listed doc example.- New: Gitleaks CI job (
.github/workflows/ci.yml) + allow-list at.github/gitleaks.toml. Runs on every push and PR, so nothing that sneaks past a local hook survives a PR.
.github/workflows/ci.yml:permissions: contents: readat the workflow root (minimum GITHUB_TOKEN surface), everyuses:pinned to a full commit SHA, newsecret-scanandnpm-auditjobs.- New:
.github/dependabot.yml— weekly npm + GitHub-Actions grouped PRs, security advisories always open their own PR immediately.
npm audit fixapplied. Closed 8 of 9 advisories including:- Next.js upgraded from 16.1.2 → 16.2.4 (fixes HTTP request smuggling in rewrites, Server Actions null-origin CSRF bypass, unbounded PPR resume buffer DoS, Server Component DoS, unbounded image-cache growth — all High severity).
- picomatch, minimatch, qs, tar, flatted, brace-expansion, ajv — transitive vulns closed.
- Remaining:
xlsx(SheetJS) prototype-pollution + ReDoS. No upstream fix;xlsxis only used from private enrichment scripts underscripts/data-scripts/(git-ignored) that never run on Vercel or in CI. Accepted risk, scoped to the maintainer's local machine.
npx tsc --noEmit: clean.npm run lint: clean (pre-existing warnings only).npm run buildwith real data: 122 routes generated, no errors.scripts/fetch-private-data.mjs: tested no-token path, malformed-token path (redacted prefix + length logged, no leak).- Pre-commit hook: tested blocks synthetic
sk_live_…and allows an allow-listed example inSECURITY.md. .env.localconfirmed git-ignored, zero secret hits in history.
See ROTATION-RUNBOOK.md for full steps. High
priority:
- Rotate
CIVACCOUNT_DATA_TOKEN,STRIPE_SECRET_KEY,UPSTASH_REDIS_REST_TOKEN. - Mark all three as Sensitive in Vercel env-var settings.
- Audit Vercel team membership, deploy hooks, integrations, recent deployments.
- Run the new Supabase migration (003) in the Supabase SQL Editor.
Ships the technical infrastructure for distribution, data-change signalling, and coverage expansion — four new public routes designed to turn first-mover advantage into durable reach.
- /developers — public API docs + one-line iframe embed snippets. Copy-paste code blocks for any of 317 councils, full or single-card variants. Positions CivAccount as infrastructure estate agents, local papers and mortgage tools can integrate free forever.
- /changelog — live data change log, server-rendered. Every year-over-year council tax move linked to its council page. Gives AI crawlers + journalists a reason to recrawl regularly.
- /foi — archive for Freedom of Information responses. Ships with a 6-item backlog (HSF recipients, pension contributions, SEND transport per provider, councillor journey claims, agency spend, thin-supplier-list fill).
- /parish +
/parish/[slug]— scaffold for England's ~10,000 parish/town councils. Empty data array, pilot county to follow.
llms.txtexpanded with new sections for developers + stable API endpoints.- Footer nav includes Developers / Change log / FOI archive.
- Sitemap covers all new routes.
- Parity reached 100.0% overall by adding grant_payments for Hinckley & Bosworth, Elmbridge, Redcar & Cleveland and North East Lincolnshire (all sourced from each council's own open data).
- 4 councils remain blocked at the network layer (Amber Valley firewall, Lancaster UK-geo-fence, South Hams Cloudflare, Stockton WAF) — documented in the FOI queue.
- Honest gap notices shipped for every silent-hide path (Bill history, Leadership, Pay & allowances, Service outcomes) with
{Council}placeholder substitution for natural copy. - Validator warnings 165 → 127 via salary_bands sort/dedupe, population refresh to ONS Mid-2024, post-2025-election councillor counts corrected.
The biggest release yet. CivAccount goes from transparency tool to civic participation platform. Residents can now propose how their council should spend money, vote on ideas, and discuss with neighbours. Plus, every English council now has detailed leadership, salary, and spending data.
- Create proposals for any budget category — suggest how your council should spend money
- Vote on proposals from other residents (upvote/downvote with real-time scores)
- Nested comment threads with reply support (up to 3 levels deep)
- Community moderation — flag inappropriate content, auto-hidden at 3+ flags
- Draft auto-save — come back to finish your proposal within 24 hours
- Embeddable proposal widgets — share proposals on external websites via iframe
- Dynamic social media previews when sharing proposal links
- Display name system — choose how you appear to other residents
- Cabinet members and leadership for all 317 councils (100% coverage)
- Chief executive salary for 316 of 317 councils (99.7%)
- Councillor basic allowance for 306 councils (96.5%)
- Top suppliers for 301 councils (95%)
- Waste and recycling destinations for all disposal authorities (100%)
- Performance data (roads, waste, housing) for every council
- Council comparison tool — compare any two councils side by side
- Budget, transparency, and councillor URLs for 93-97% of councils
57 commits across a single intensive session brought data parity from 87.8% to 90.6% across all 317 English councils. The largest single enrichment effort in the project's history, covering financial data, governance structures, and transparency URLs.
| Field | Before | After | Change |
|---|---|---|---|
| Chief Executive Salary | 17 (5%) | 316 (99.7%) | +299 |
| Basic Allowance | 11 (3%) | 306 (96.5%) | +295 |
| Leader Allowance | 12 (4%) | 184 (58%) | +172 |
| Cabinet Members | 114 (36%) | 299 (94%) | +185 |
| budget_url | 220 (69%) | 302 (95%) | +82 |
| councillors_url | 260 (82%) | 294 (93%) | +34 |
| transparency_url | 293 (92%) | 306 (97%) | +13 |
| Grant Payments | 79 (25%) | 85 (27%) | +6 |
| Overall Kent Parity | 87.8% | 90.6% | +2.8% |
| Type | Councils | Basic | CE Salary | Cabinet | budget_url |
|---|---|---|---|---|---|
| County | 21 | 100% | 100% | 100% | 100% |
| Districts | 164 | 100% | 100% | 89% | 95% |
| Unitary | 63 | 100% | 100% | 100% | 98% |
| Metropolitan | 36 | 100% | 100% | 100% | 86% |
| London | 33 | 100% | 100% | 100% | 97% |
- CE salary coverage: 5% → 99.7% — The breakthrough was discovering the TaxPayers' Alliance Town Hall Rich List 2025 Excel dataset, which provided 30+ CE salaries from a single download. Combined with 50+ targeted research agents scraping .gov.uk Pay Policy Statements.
- Basic allowance: 3% → 96.5% — Required per-council research across Members' Allowances Schemes. Used a combination of web search agents, PDF downloads, Wayback Machine cached pages, and the browse tool with headless Chromium.
- 11 councils annotated as unavailable — Remaining gaps (East Staffordshire, Fylde, Sevenoaks, etc.) have inline code comments explaining the specific technical blocker (Cloudflare, ModernGov 403s, SSL cert issues, JS-rendered sites).
- 185 cabinet lists added in a single session across all council types
- Covers Leader/Mayor, Deputy Leader, and 4-5 key portfolio holders per council
- Handles diverse governance models: traditional cabinets, elected mayors, committee systems, co-leader arrangements, executive boards
- Notable: West Northamptonshire identified as Reform UK-controlled; several councils documented as having switched from cabinet to committee systems (Bristol, Stroud, Swale, etc.)
- budget_url: 69% → 95% (+82 URLs)
- councillors_url: 82% → 93% (+34 URLs)
- transparency_url: 92% → 97% (+13 URLs)
- 8 councils added with verified itemised grant data (Bath & NE Somerset, Blackpool, Brighton & Hove, Cheshire West & Chester, Cornwall, Durham, Manchester, Barnsley)
- Grant data sourced from council transparency pages, community fund announcements, and VCSE grant CSVs
- Parallel agent architecture — Up to 6 research agents running simultaneously, each covering 10-20 councils
- TPA Town Hall Rich List — Single Excel dataset solved 30 CE salary gaps in one shot
- PDF download + local read — Downloaded PDFs from council domains and used Claude's PDF reader to extract data locally
- Wayback Machine — Used cached versions of council pages blocked by Cloudflare
- Browse tool — Headless Chromium for councils blocking plain HTTP requests (limited success due to Cloudflare Enterprise)
- Deduplication scripts — Automated detection and removal of duplicate property entries caused by batch insertions
| Field | Missing | Effort Required |
|---|---|---|
| councillor_allowances_detail | 245 (77%) | Per-councillor payment PDFs — highest effort |
| total_allowances_cost | 244 (76%) | Statement of Accounts PDFs |
| grant_payments | 232 (73%) | Transparency data downloads (CSV/XLSX) |
| salary_bands | 215 (67%) | Statement of Accounts officer remuneration notes |
| cabinet | 18 (5%) | 18 remaining districts (final agent in progress) |
| councillors_url | 23 (7%) | Quick URL lookups |
| top_suppliers | 16 (5%) | Transparency spending data |
- Fixed Worthing basic allowance (was £5,175 = Wyre's figure, corrected to £5,845)
- Removed ~40 duplicate property entries (budget_url, cabinet, grant_payments) caused by batch insertion scripts
- All changes verified with
npx tsc --noEmitbefore each push
- TaxPayers' Alliance Town Hall Rich List 2025 — CE salaries (2023-24 Statement of Accounts data)
- Council .gov.uk websites — Pay Policy Statements, Members' Allowances Schemes, Constitutions
- ModernGov democracy portals — Cabinet membership, allowances data (blocked by Cloudflare for ~30% of councils)
- Wayback Machine — Cached versions of blocked council pages
- data.gov.uk — Some grant payment datasets
- 360Giving — Birmingham grant data reference