π Security & DevSecOps
π Security & DevSecOps
π Summary
Most of the application's own server code is written with real care for security. Nearly every API route checks who is signing in and what they may touch before it uses the all-powerful database key. Share and upload links use long random tokens that expire and can be withdrawn, and emails, logs and upload paths show deliberate hardening. The weakness sits underneath that code, in the database's own permission rules. As written in the repository, those rules let any signed-in user change their own record, including the field that makes them an administrator. If the live database matches the repository, every other access control can be bypassed. Within a job, a project manager can make themselves the approver, which breaks separation of duties on sign-off. There is also a confirmed route by which a campaign editor can plant script that runs in an administrator's browser, an open redirect in the sign-in callback, and test-only API endpoints that are reachable in production builds. The web framework is pinned to a version with published critical advisories (covered in module 08). The CI pipeline has no security scanning, and the privacy and data-retention controls the team has itself written down as required are not yet built.
π― Rating
π΄ βββββ 2 / 5 β Red. The application code is better than average, but one Critical, trivially reachable privilege escalation in the database policies, two High findings (stored XSS, self-granted approval rights) and an unpatched framework with critical advisories outweigh it. Most fixes are small (S effort), so this rating can move quickly once they are made.
π Findings
F1. Authorisation model, as built
[Observed] There are two layers of authorisation:
- Route layer.
src/lib/api-auth.tsprovidesgetUser,canAccessCampaign,canEditCampaign,canEditOpenCampaign,canEditArea,canApprove,canReviewCampaignanddisciplineOf. They queryusers.roleandcampaign_membersthrough the user's own client, so RLS applies, and they fail closed: a null row givesfalse(api-auth.ts:46-61,120-141). Writes then go through the service-role client, which bypasses RLS (src/lib/supabase/service.ts). 67 of 83 route files use the service client. - Database layer. Row-level security (RLS) in
supabase/baseline.sql: 44enable row level securitystatements, 95create policystatements. This layer covers direct PostgREST and Storage access from the browser, which works with the public anon key plus the user's JWT.
Every global "is admin" decision in the app reads users.role. In the route layer that happens in isStaff() and in 25 inline role === 'admin' checks. In RLS it happens through (select role from users where id = auth.uid()). The integrity of users.role therefore holds up the whole model. F2 shows it is not protected.
F2. CRITICAL: any signed-in user can make themselves an administrator (database policy)
[Observed] supabase/baseline.sql:1044-1046:
- Policy
"Users can update own profile"onusersisfor update using (auth.uid() = id). - It has no
with checkclause and no column restriction. The repository contains norevoke/grantstatements (grep -rn "revoke\|grant " supabase/finds only a comment in 092), and no trigger protectsrole(the only triggers on/near users arehandle_new_userand timestamp triggers,baseline.sql:935-985). - Migration 028 removed the only admin policy on
usersand noted that "Admin user-management writes happen via the service role client" (supabase/migrations/028_fix_users_rls_recursion.sql). It left the self-update policy unrestricted.
[Inferred] Supabase's default table privileges give the authenticated role UPDATE on public tables. Any account holder could therefore send a direct REST update to their own users row, setting role to admin (or discipline to traffic). They would need only the public anon key, which is shipped to every browser, and their own session. From then on every route treats them as staff. They could invite users (/api/admin/invite), delete users, read every campaign through the service client, and generate a password-recovery link for the protected owner account (F5). That would make the takeover permanent.
[Unavailable] Whether production matches the baseline. A dashboard change could have restricted this, and it should be checked against the live database (pg_policies, information_schema.column_privileges). The project's own test supabase/tests/040_rls.sql does not cover updates to users.
Related: handle_new_user() (baseline.sql:947) takes the new user's role from raw_user_meta_data->>'role'. That metadata is client-controlled whenever someone signs up through Supabase's public sign-up endpoint. [Unavailable] Whether public sign-ups are disabled in the Supabase project is a dashboard setting, and nothing in the repo records it (grep -rn signUp finds no hits). If they are enabled, anyone on the internet can create an admin account. If disabled, this is latent.
F3. Route authorisation audit (83 route files)
Method: for every src/app/api/**/route.ts, I recorded whether it calls getUser and which authorisation helper or role/token check runs before the first service-client use. Then I read each route that the pattern scan did not classify. Child-resource routes were checked for IDOR, meaning whether they confirm that the child id belongs to the campaign in the path or derive the campaign from the child row.
| Auth pattern | Routes | Examples |
|---|---|---|
Session + campaign-scoped helper (canEdit*, canAccess*, canApprove, canReview*, cardContext) | 39 | campaigns/[id]/ad-units/[unitId] (PATCH), import-copy, share-link, review/files/[fileId], track, nudge |
Session + global admin role (users.role === 'admin') | 24 | all admin/* except ad-formats/[id]/usage; ad-units/[id] DELETE; campaigns/[id] (admin, plus a narrow editor path for brief_text); jobs; duplicate; sections*; tasks*; users |
| Session + global discipline (traffic / project manager / admin) | 2 | briefs/waiting, briefs/waiting/[versionId]/take |
| Session only, self-scoped or RLS-scoped | 7 | users/me, users/me/avatar, notifications*, for-you, feedback (POST), admin/ad-formats/[id]/usage |
Session or supplier upload token (resolveReviewCaller) | 2 | campaigns/[id]/review/upload-url, campaigns/[id]/review/files |
| Bearer token only (public) | 4 | upload/[token]/url, upload/[token]/register, preview/[token]/custody, invite/[token]/accept (token + any session) |
| Cron secret | 2 | cron/overdue, cron/purge-review |
Dev-only (NODE_ENV === 'development', else 404) | 2 | auth/dev-login, dev/cleanup-qa (unauthenticated, service-role deletes) |
| Public by design | 1 | auth/forgot-password |
| Total | 83 |
[Observed] Good IDOR discipline is the norm. Child ids are pinned to the path's campaign, for example .eq('id', unitId).eq('campaign_id', campaignId) in frames/card-writes.ts:35, and .eq('id', g.body.id).eq('campaign_id', g.id) in media-strategy, products, questions and wrapup. Comment and file routes derive the campaign from the object row before checking (review/files/[fileId]/comments/route.ts:21-23, unit-comments/[commentId]/route.ts:13-29). The supplier register route only accepts an adUnitId found among the token's own campaign units, and storage paths are validated with isReviewObject against the campaign id.
Routes with inadequate checks or IDOR exposure:
| Route | Issue | Impact | Severity |
|---|---|---|---|
campaigns/[id] PATCH (route.ts:33-43) | Lets a campaign editor write brief_text with no sanitisation, unlike campaigns/[id]/brief, which calls sanitizeBriefHtml (brief/route.ts:48). See F4. | Stored XSS | π High |
campaigns/[id]/members/[memberId] PATCH { discipline } (route.ts:27-35) | Gated only by canEditArea(β¦, 'people') (:28). The project manager and traffic disciplines hold people (src/lib/discipline.ts:58), and so does any member with no discipline (mayEdit returns true for null, discipline.ts:72-73). The update sets any member's discipline, including the caller's own, to any value, brand_manager included. There is no self-check and no "only an approver or admin may grant the approving discipline" guard. canApprove then accepts that person (api-auth.ts, mayApprove). | A PM or traffic member (or an undisciplined editor) can make themselves the job's approver and sign off copy, artwork, briefs and finals. This breaks separation of duties on sign-off (OWASP A01). The change is logged as discipline_set, so it is detectable after the fact. | π High |
admin/users/[id]/reset-link (route.ts:20-41) | Admin-gated, but no isProtectedAccount check, unlike the PATCH and DELETE routes on the same resource (admin/users/[id]/route.ts:59,133). Any admin can mint a recovery link for the owner or break-glass account. | Defeats "owner protection"; with F2, gives permanent takeover | π‘ Medium |
admin/ad-formats/[id]/usage (route.ts:20-23) | Checks only that a user is signed in, then counts ad_units across all campaigns with the service client. | Minor cross-campaign information disclosure (counts only) | βͺ Low |
campaigns/[id]/questions POST carryFrom (route.ts:53-58) | Checks edit rights on the target campaign, then reads season_findings from any carryFrom campaign id with the service client, without checking access to the source. Compare copy-ad-units, which does check (route.ts:25). | Cross-campaign read of recommendations if a campaign UUID is known | βͺ LowβMedium |
invite/[token]/accept | The invite is not tied to the invited email. Any signed-in account holding the token gets the seat. | Forwarded invite grants access; mitigated by 256-bit token, 7-day expiry, single use | βͺ Low |
review/upload-url, upload/[token]/url | No size or MIME check in the app. The extension comes from the client but is sanitised ([^a-z0-9] stripped). The bucket enforces a 250 MB cap and a MIME allowlist (baseline.sql:1613-1628, 2050ff). Supplier tokens are not refused when the campaign is archived. | Acceptable; relies on bucket configuration | βͺ Low |
F4. Injection and output encoding
| Area | Finding | Tag |
|---|---|---|
| SQL | No raw SQL in the app. Only one .rpc() call, log_share_link_access with a bound parameter (preview/[token]/page.tsx:51). No .or() / .filter() strings built from user input. One .ilike('email', email.trim()) (campaigns/[id]/invite/route.ts:56) does not escape %/_, but it only looks up a default discipline. | [Observed] |
| Stored XSS: brief | The brief page renders briefTextToHtml(campaign.brief_text) (brief/page.tsx:87). That function passes HTML through untouched when it sees any common tag (src/lib/brief.ts:192), and the result goes into dangerouslySetInnerHTML (brief-view.tsx:557). The brief route sanitises with an allow-list rebuild (lib/brief.ts:157-184, reasonable but regex-based). The generic campaign PATCH route accepts brief_text raw from any campaign editor (F3). An editor, or a supplier/agency person given editor membership, could save markup with an event-handler attribute. It would run in the browser of every admin or member who opens the brief. There is no CSP to contain it, and the Supabase session cookies set by @supabase/ssr are readable by script. | [Observed] path; [Inferred] exploitability |
| Latent XSS: Ask sheet | ask-sheet.tsx:104-115 interpolates unit names, section labels and for-you item titles into HTML without escaping, then renders with dangerouslySetInnerHTML (:165). Not reachable today because SHOW_ASK = false (src/lib/flags.ts:10). | [Observed] |
| Email HTML injection | Forgot-password escapes the org name. The campaign invite email does not escape campaignName or inviterName (campaigns/[id]/invite/route.ts:113,131), even though CLAUDE.md says names go through escapeHtml. An editor can rename a campaign, and any user can rename themselves (users/me), so either can inject links or markup into a genuine email from the organisation's sender. | [Observed] |
| Upload paths | upload-previews builds ${id}/${unit}/preview-<ts>.${ext} with an unsanitised extension (route.ts:129-130), unlike the other upload routes. The upload goes through the user's client, so storage RLS on the first path folder limits any traversal to the caller's own campaign folder. Hygiene issue. | [Observed]/[Inferred] |
| SVG | image/svg+xml is allowed in the bucket and for logos and avatars. Files are served from the Supabase storage origin via signed URLs and rendered in <img>, where SVG script does not run. | [Observed] Low |
| SSRF | No server-side fetch of user-supplied URLs (grep fetch( src/app/api src/lib: only browser-side calls). file_store_url and figma_frame_url are stored as text and never fetched server-side. google-auth-library is a dependency but is never imported. | [Observed] |
| Open redirect | src/app/auth/callback/route.ts:7,14 builds `${origin}${next}` from a raw next query parameter. A value beginning with @ or . turns the app origin into userinfo or a subdomain prefix, so it can redirect off-site after a legitimate auth exchange. The login form validates next correctly (login-form.tsx:19-20). | [Observed] |
| Host-header trust | Reset and invite links use NEXT_PUBLIC_APP_URL ?? new URL(request.url).origin (forgot-password/route.ts:22). This is safe only while the env var is set, and CLAUDE.md lists it as mandatory. | [Observed] Low |
F5. Public, token and secret-gated surfaces
| Surface | Generation | Expiry | Revocation | Verdict |
|---|---|---|---|---|
Share link /preview/<token> | DB default encode(gen_random_bytes(32),'hex'), 256-bit (baseline.sql:405) | Optional: 7/30/90/180 days, default 30, "never" allowed (src/lib/share-links.ts:28-36) | DELETE route and rotate (share-link/route.ts) | Strong. Checked on every request (preview/[token]/page.tsx:25-27, custody/route.ts:61). Only the token tail is logged (activity.ts redactPath). |
| Share link custody writes | Same token | Same (can be "never") | Same | Integrity concern, verified: anyone holding the link, which is forwardable, needs no account and may never expire, can post { event: 'ingest', name }. That stamps campaigns.dam_archived_at with a free-text name (custody/route.ts:97-119). The only guard is a 409 if it is already stamped (:107-112), and nothing checks that the finals actually reached the archive. purgableAfterHandover (src/lib/retention.ts:55-63) purges an archived job's files 30 days after the later of archived_at and dam_archived_at. So a false or mistaken confirmation through the link starts the purge of the finals on an archived job. Signed-in confirmation goes through campaigns/[id]/dam-archive with canConfirmArchiveIngest. |
Supplier upload /upload/<token> | replace(gen_random_uuid()::text,'-',''), about 122 bits (baseline.sql:687) | Mandatory, default 30 days | DELETE (upload-links/route.ts:45-63) | Adequate |
| Campaign invite | 256-bit, as above (baseline.sql:394) | 7 days | Single-use; DELETE route | Adequate (not bound to email, F3) |
Old Present-wall pw_ rows | n/a | n/a | Refused everywhere (isPresentToken) | Correctly closed |
| Cron | Authorization: Bearer ${CRON_SECRET} | n/a | n/a | Fails closed when unset (cron/*/route.ts:28-32). The comparison is a plain !==, not timing-safe (Low). |
| Rate limiting | None anywhere. The repo says so in custody/route.ts:30 and docs/API.md:137. | forgot-password can be used to email-bomb any address. Sign-in uses Supabase's built-in limits. |
F6. Middleware (src/proxy.ts) and the Next.js 16.2.0 advisories
[Observed] proxy.ts:5-8 only refreshes the Supabase session cookie (lib/supabase/middleware.ts) and, in development, auto-signs-in a dev user. It enforces no authorisation. Pages check getUser() in (app)/layout.tsx:9-14 and routes check per handler. The several "Middleware / Proxy bypass" advisories against next@16.2.0 (/tmp/claude-0/dd/audit.txt) therefore have low direct relevance: getting past the proxy gains nothing. The advisories that do matter are the critical next/og ImageResponse RCE (used in src/app/icon.tsx and apple-icon.tsx, where the input is the admin-set logo URL, so F2 makes it attacker-reachable), the DoS and cache-poisoning advisories, and the Image Optimisation advisories (/_next/image is on by default even though next/image is not imported). See module 08 for upgrade guidance.
QA harness reachable in production. [Observed] When NEXT_PUBLIC_QA === '1' the proxy hands every request to harness(), which trusts an x-qa-user header for identity (proxy.ts:20-27). That flag is build-time, and production must never set it, but nothing in CI or config asserts that. Separately, the harness route handlers have no env gate: src/app/qa/api/[...path]/route.ts (exports at :451-456), qa/reset, qa/state, qa/demo/as and qa/demo/reset. The notFound() in src/app/qa/layout.tsx:11 covers pages only, because App Router layouts do not wrap route.ts handlers. In production the proxy calls updateSession (src/lib/supabase/middleware.ts), which refreshes cookies but does not require sign-in, so nothing in front of these handlers stops an anonymous request. [Inferred] These handlers are compiled into every production build and answer unauthenticated requests against the in-memory fixture store (src/app/qa/store.ts). They do not touch the Supabase database, so no real data is exposed, but this contradicts the documented claim that "The harness routes exist only when NEXT_PUBLIC_QA=1" (CLAUDE.md; store.ts:158). It also adds unnecessary attack surface, including unbounded in-memory writes per serverless instance (OWASP A05). Severity LowβMedium. [Unavailable] Confirming that they respond on the live deployment needs a request to it, which was deliberately not sent.
F7. Security headers and transport
[Observed] next.config.ts:36-55 sets X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin and a restrictive Permissions-Policy. There is deliberately no Content-Security-Policy (comment at :33-35). HSTS is said to come from Vercel [Unavailable β verify on live headers]. proxyClientMaxBodySize: '200mb' (:9) raises the per-request body ceiling, which increases DoS cost on serverless functions.
F8. Row-level security (sample review)
[Observed] The 040/061 hardening is folded in at the end of the baseline (baseline.sql:1885ff). It scopes approvals and utm_log, which earlier policies at :1155-1166 left open, and pins search_path on security-definer functions. supabase/tests/040_rls.sql with scripts/rls-check.sh is a genuine RLS regression test, which is uncommon and good.
These SELECT policies still read auth.uid() is not null, so any signed-in account, including global reviewers and external agency or supplier staff given accounts, can read every row:
| Table | Line | In active use? | Exposure |
|---|---|---|---|
users | 1041-1042 | β Yes | Every user's name, email, role, avatar path |
briefs | 1338 | 6 files reference it | Cross-campaign brief records |
brief_sections, brief_checklist_items, dam_references | 1359, ~1376, 1388 | Partly | Cross-campaign |
review_rounds, review_comments, round_reviewers | 1409, 1421, ~1434 | review_rounds in 7 files | Cross-campaign review metadata and comments |
settings, business_units, campaign_categories, campaign_sections, asset_specs, campaign_custom_fields | various | β Yes | Organisation configuration (low sensitivity; settings holds org name, logo and domain only) |
This confirms CLAUDE.md's own admission ("any signed-in account can read them") and shows it reaches further than the brief and review workflow tables. Storage policies (baseline.sql:1640-1718) scope objects by first path folder = campaign id. Any accepted editor member can therefore read, write or delete every object in that campaign's folder directly, bypassing the discipline checks the routes apply. Objects under review/, concept/ and branding/ are admin-only via RLS and served through service-client routes.
F9. Secrets and configuration
[Observed]gitleaks found no secrets in history or tracked files (brief)..gitignoreignores.env*except two examples, and the example values are placeholders.[Observed]SUPABASE_SERVICE_ROLE_KEYis read only viasupabaseServiceRoleKey()(lib/supabase/env.ts). No'use client'file importscreateServiceClient, checked by scanning every.tsximporter.service.tsdoes notimport 'server-only', so an accidental client import would fail at runtime rather than at build time.[Observed]NEXT_PUBLIC_*variables in use:SUPABASE_URL,SUPABASE_ANON_KEY,APP_URL,PRODUCT_NAME,UNIT_COMMENTS,QA,QA_TODAY. None is sensitive, since the anon key is public by design. The anon key's power, however, is exactly what F2 exploits.[Observed]src/lib/protected-accounts.tsfalls back toowner@example.com/ghost@example.comwhen env is unset, so a deployment without the vars has no effective owner protection (documented).[Observed]Raw Supabase error messages go back to the browser in about 121 places insrc/app/api(error: x.message). This leaks schema and constraint names (OWASP A05, Low).
F10. Security tooling in the pipeline
[Observed] .github/workflows/ci.yml runs typecheck, unit tests, a confidentiality scrub (scripts/scrub.js, which looks for client-identifying strings, not secrets), the harness contract, lint and build. There is no npm audit, Dependabot or Renovate, CodeQL or other SAST, secret scanning, or DAST. walk.yml is manual. The two gates that most help security are custom: write-results.test.ts (writes must read their result) and schema-columns.test.ts.
F11. Data protection (GDPR / POPIA)
| Item | Evidence | Tag |
|---|---|---|
| Personal data held | users (email, name, role, discipline, avatar). activity_log holds user_id, user_email, path, action and a JSON detail (baseline.sql:625-635): page views, panel opens and asks from track() (src/lib/track.ts), plus server events (renames, role changes, uploads). Also feedback (free text, emailed to OWNER_EMAIL), comments, supplier labels and emails on upload links, invite emails, and archive-confirmer names typed into the public link. No IP addresses or user agents are stored (grep of track/route.ts, activity.ts). | [Observed] |
| Token hygiene in logs | redactPath keeps only a 6-character tail of bearer tokens (src/lib/activity.ts:10-17). | [Observed] good |
| Retention | Only review uploads and archived-campaign files are purged (cron/purge-review, src/lib/retention.ts). activity_log, feedback, comments and expired invites have no lifetime and no deletion job. The repo's own design/COMPLIANCE.md:93 says the same. | [Observed] |
| Data-subject rights | No access or erasure route. COMPLIANCE.md:94 says "Today it is a hand-written database query". user_email is denormalised into activity_log, so deleting a user (on delete set null) leaves their email in the log. | [Observed] |
| Sub-processors | Supabase (EU region), Vercel, Resend (design/COMPLIANCE.md:70-80). Cross-border POPIA s72 basis is noted as needing to be written down (docs/NEW_DEPLOYMENT.md:111-114). Session replay (Clarity) was removed. | [Observed] |
| Privacy notice | None in the product (grep -rli "privacy" src returns nothing). COMPLIANCE.md lists it as outstanding. | [Observed] |
| Observability for breach scoping | No error tracker or log drain. The app_errors table has no writer (grep app_errors src shows only types and the QA store). | [Observed] |
π§Ύ Evidence
- Facts reused from the shared brief: npm audit (24 vulns; production 11; next@16.2.0 about 26 advisories including critical
next/ogRCE;xlsxunfixed), gitleaks clean, CI contents. - Route classification script: for every
find src/app/api -name route.ts,grepfor exported handlers,getUser(,createServiceClientand the auth-helper names, followed by manual reads of the 30 routes the scan did not classify. - Files read in full or in part:
src/lib/api-auth.ts,src/lib/review-auth.ts,src/lib/supabase/{service,middleware,env,client}.ts,src/proxy.ts,next.config.ts,src/lib/share-links.ts,src/lib/brief.ts:147-195,src/lib/protected-accounts.ts,src/lib/activity.ts,src/lib/track.ts,src/app/auth/callback/route.ts,src/app/login/login-form.tsx,src/app/preview/[token]/page.tsx,src/app/qa/layout.tsx,src/app/qa/api/[...path]/route.ts, and the route files cited above. supabase/baseline.sql: tables at 116-125, 368-414, 625-645, 684-693; functions 905-950; policies 1036-1720; 040 section 1885ff.supabase/migrations/028_fix_users_rls_recursion.sql.- Commands:
grep -n "auth.uid() is not null" supabase/baseline.sql(60+ hits);grep -rn "revoke\|grant " supabase/(none functional);grep -rn "\.rpc(\|dangerouslySetInnerHTML\|fetch(" src;grep -rln createServiceClient --include=*.tsx srcwith a'use client'check (none);grep -rn "error: .*\.message" src/app/api | wc -lβ 121. - Not done, deliberately: no request was sent to any live deployment, and no exploit was attempted.
β οΈ Risks
| # | Risk | Severity | Effort to fix |
|---|---|---|---|
| R1 | Self-promotion to admin via the unrestricted users UPDATE policy, and possibly via sign-up metadata (F2) | π΄ Critical | S |
| R2 | Next.js 16.2.0 critical advisories, including next/og RCE on a used code path (F6, module 08) | π΄ Critical | SβM |
| R3 | Stored XSS through unsanitised brief_text on the campaign PATCH route, with no CSP (F4) | π High | S |
| R3b | Separation of duties on sign-off: anyone holding the people area (PM, traffic, or a member with no discipline) can make themselves or anyone else brand_manager, the approving discipline (F3, members/[memberId]/route.ts:27-35) | π High | S |
| R4 | QA harness route handlers compiled into production with no env gate and reachable unauthenticated (fixture data only). Separately, a NEXT_PUBLIC_QA misconfiguration would hand identity to a request header (F6) | βͺ LowβMedium (latter: High if it ever occurred) | S |
| R5 | Open redirect in /auth/callback (F4) | π‘ Medium | S |
| R6 | Admin can mint a recovery link for protected owner or ghost accounts (F3) | π‘ Medium | S |
| R7 | Cross-tenant-style over-read: users, briefs and review tables readable by every signed-in account; editors bypass discipline via direct Storage access (F8) | π‘ Medium | M |
| R8 | HTML injection in invite emails via campaign or inviter names (F4) | π‘ Medium | S |
| R9 | No retention or erasure for activity log, feedback and comments; no privacy notice; POPIA s72 basis unwritten (F11) | π‘ Medium (High for an enterprise sale) | M |
| R10 | No security scanning in CI; no rate limiting on public endpoints (F5, F10) | π‘ Medium | SβM |
| R11 | Anyone holding a share link, which can be set never to expire, can stamp the archive-ingest date that starts the 30-day purge of finals on an archived job (F5, custody/route.ts:97-119, retention.ts:55-63) | π‘ Medium | S |
| R12 | Minor: questions carryFrom IDOR, ad-formats/usage cross-campaign count, non-timing-safe cron compare, raw DB errors returned, missing server-only, unsanitised extension in upload-previews | βͺ Low | S |
β Recommendations
| Priority | Action | Effort |
|---|---|---|
| P0 (before close) | Replace the users self-update policy with one that prevents changing role, email and discipline: a with check against the old values via a trigger, or column-level revoke update with grant update (name, avatar_path). Change handle_new_user to ignore raw_user_meta_data->>'role' (always editor, with the role set by the admin invite route). Confirm sign-ups are disabled. Add both cases to 040_rls.sql. Audit the live database for accounts whose role changed outside /api/admin/users (activity_log user_role_changed vs current admins). | S |
| P0 | Upgrade Next.js to β₯16.3.6 (ideally the latest 16.x) and add an automated dependency-advisory gate. | SβM |
| P0 | Sanitise brief_text in campaigns/[id] PATCH (or remove the key there), and sanitise on render as well. Replace the regex sanitiser with a maintained one (e.g. DOMPurify/sanitize-html). | S |
| P1 | Gate every src/app/qa/**/route.ts handler on process.env.NEXT_PUBLIC_QA === '1', or exclude the folder from production builds. Add a CI assertion that production builds have NEXT_PUBLIC_QA unset. | S |
| P1 | Validate next in /auth/callback exactly as the login form does. | S |
| P1 | Restrict granting brand_manager (the approving discipline) to admins or existing approvers, forbid changing one's own discipline, and notify the job's approvers on any discipline change. | S |
| P1 | Remove ingest confirmation from the anonymous share link, or require a separate single-purpose, short-lived confirmation token. Cap share-link lifetime. | S |
| P1 | Apply isProtectedAccount in reset-link. Escape every interpolated value in email HTML. | S |
| P1 | Introduce a CSP (start in report-only), and add import 'server-only' to service.ts. | M |
| P2 | Scope the auth.uid() is not null SELECT policies to membership (is_campaign_member), and restrict users reads to the fields a typeahead needs. Narrow storage policies by discipline or route all writes through the API. | M |
| P2 | Add rate limiting (e.g. Vercel WAF or Upstash) to forgot-password, token routes and track. Use crypto.timingSafeEqual for the cron secret. Map DB errors to generic messages. | SβM |
| P2 | Close the data-protection gaps already listed in design/COMPLIANCE.md: retention jobs for activity_log and feedback, a data-subject export and erasure path, a privacy notice, a written s72 basis and breach process. Add error tracking (and list it as a sub-processor). | M |
| P3 | Add Dependabot or Renovate, CodeQL (or Semgrep) and secret scanning to CI. Run a third-party penetration test after the P0/P1 fixes. | S |