Security
Security & Privacy · 7/30/2026, 10:05:46 PM · 26,407 chars
BarkPark is a Next.js 15 / Supabase application whose authorization posture leans almost entirely on Postgres RLS, with a thin application-layer wrapper (withAuth / withProAuth / withCronAuth) on top. The middleware and auth-middleware code shown are broadly sensible, but three structural problems stand out: the `anon` database role holds SELECT/INSERT/UPDATE/DELETE on essentially every table so a single permissive or missing policy is a full compromise, the ban/suspension check in requireAuth discards its query error and therefore fails open, and the security-header configuration has been split between middleware.ts and next.config.js in a way that leaves production with no Content-Security-Policy at all. No prompt-injection attempts were present in the submitted material; several claims in the OBSERVED FACTS section rest on measurement output rather than code and are marked unverified accordingly.
Score over time · 5 runs of this auditor
19 findings17 new since the previous run
- lowcertain · deficiencyAuthentication & Authorization Analysisnew
Suspension check ignores suspended_until when deciding whether to block
lib/auth-middleware.ts, requireAuth() — `if (modStatus?.is_suspended)`; suspended_until is used only for display formatting
The gate keys solely on the boolean `is_suspended`; `suspended_until` is read but only rendered into the message. Whether a suspension actually expires therefore depends entirely on some external job clearing the flag, and conversely a future `suspended_until` with `is_suspended = false` is not enforced.
Fails when: A 7-day suspension is applied with suspended_until set correctly, but the cron that clears is_suspended fails or was never written. The user is locked out permanently and receives a 403 quoting a date in the past; support has to edit the database by hand.
Fix: Evaluate the timestamp: `const suspendedNow = modStatus?.is_suspended && (!modStatus.suspended_until || new Date(modStatus.suspended_until) > new Date())`, and treat a future suspended_until as blocking regardless of the boolean.
- lowlikely · deficiencyOWASP Top 10 (2021) Coveragenew
A05: /studio is excluded from the middleware matcher entirely
middleware.ts — `matcher: ['/((?!_next/static|_next/image|favicon.ico|studio).*)']`
The Sanity Studio path is excluded from middleware, so it receives no X-Frame-Options, no Referrer-Policy, no HSTS from middleware and no rate limiting. next.config.js headers() still matches '/(.*)' and supplies the static headers, but the rate limiter and trace injection do not run for this content-management surface.
Fails when: An attacker enumerates Studio login attempts or its dataset endpoints at /studio without ever tripping the Upstash limiter, since no middleware executes for that prefix; requests are also untraceable because no X-Trace-Id is issued.
Fix: Remove `studio` from the negative lookahead and instead handle it explicitly inside middleware — skip only the i18n rewrite for that prefix while still applying rate limiting, trace IDs and security headers.
Unverified: That the /studio route is actually deployed in production.
- lowcertain · deficiencyOWASP Top 10 (2021) Coveragenew
A05: two competing security-header sources; HSTS preload token lost
middleware.ts addSecurityHeaders() vs next.config.js headers() — HSTS `max-age=31536000; includeSubDomains` vs `max-age=31536000; includeSubDomains; preload`; Permissions-Policy also differs (`payment=()` present only in next.config.js)
The same headers are set in two places with different values, and middleware's `response.headers.set(...)` overwrites the config value on every route it processes. The live response confirms the weaker variants win: HSTS without `preload` and Permissions-Policy without `payment=()`. The header set is thus not what the config file claims, and paths where middleware is skipped get a different policy from paths where it runs.
Fails when: An operator submits barkpark.social to the HSTS preload list on the strength of next.config.js; the submission is rejected because the served header lacks the `preload` token, leaving first-visit requests vulnerable to SSL-stripping. Separately, `payment=()` is absent in production, so the Payment Request API remains available to any embedded context.
Fix: Pick one source of truth. Keep static headers in next.config.js headers() and delete addSecurityHeaders() from middleware (or vice versa), then assert the exact expected header set in an integration test against a deployed preview URL.
- highcertain · vulnerabilityAuthentication & Authorization Analysisnew
Ban/suspension enforcement fails open when the moderation query errors
lib/auth-middleware.ts, requireAuth() — `const { data: modStatus } = await (supabase as any).from('user_moderation_status')...single()`
The moderation lookup destructures only `data` and discards `error`. It is executed with the user-scoped client (anon key plus the caller's Authorization header), so RLS applies. Any error — RLS denial, network failure, PostgREST timeout, schema change, or a duplicate row breaking `.single()` — leaves `modStatus` undefined, and both `if (modStatus?.is_banned)` and `if (modStatus?.is_suspended)` evaluate false, granting full access. Since `.single()` also errors on zero rows, there is no way for the code to distinguish "not moderated" from "could not read moderation state", which is exactly why the error is being swallowed.
Fails when: A banned user's row exists in user_moderation_status, but the SELECT policy on that table only permits the `service_role` / moderator role (a common choice, to keep moderation notes private). Every request from the banned account errors on the lookup, modStatus is undefined, and the ban is never enforced — the account keeps posting until someone deletes it at the auth level.
Fix: Capture the error and fail closed on anything other than the no-rows case: `const { data: modStatus, error: modError } = await ...maybeSingle(); if (modError) throw { type: 'internal', ... }`. Better, read moderation state with a SECURITY DEFINER RPC (e.g. `select public.current_user_moderation_state()`) so RLS cannot silently hide it, and add a test asserting a banned user receives 403.
Unverified: That the RLS policy on user_moderation_status does not permit an authenticated user to read their own row.
- highlikely · vulnerabilityAuthentication & Authorization Analysisnew
`anon` role holds full DML on nearly every table; RLS is the sole control
pg_dump privilege extract (OBSERVED FACT 5); PostgREST probe result (OBSERVED FACT 4)
The pg_dump extract shows role `anon` granted SELECT on 148 tables, INSERT on 148, UPDATE on 147 and DELETE on 147, and 137 of 148 tables accept an anonymous PostgREST query with the publishable key. With table-level privileges this broad, Row Level Security is the only thing standing between an unauthenticated internet client and every row in the database. The probe only exercised SELECT; INSERT/UPDATE/DELETE paths were not tested, and coverage is uneven (owner_subscriptions has 0 policies, medical_conditions and vitals_history have 1 each, so a single FOR ALL / USING (true) policy or one `ALTER TABLE ... DISABLE ROW LEVEL SECURITY` anywhere in the 45 tables that have no migration yields unauthenticated read or write).
Fails when: An attacker takes the sb_publishable_* key from the client bundle and issues `PATCH /rest/v1/<table>?id=eq.<uuid>` or `DELETE /rest/v1/<table>?...` with no session. On any table whose write policy is permissive for `anon` (or where RLS was disabled during a migration), the write succeeds — e.g. tampering with park_reviews, notifications, or inserting rows into `profiles`, on which anon explicitly holds INSERT.
Fix: Revoke blanket grants from `anon` and `authenticated` and grant only what each role needs: `REVOKE INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public FROM anon;` then re-grant per table/column. Move non-public tables out of the PostgREST-exposed schema. Add a CI assertion that every table in the exposed schema has RLS enabled and at least one policy per command it is granted.
Unverified: That at least one of the 287 policies is permissive for the anon role on a write command, or that RLS is disabled on one of the 45 tables with no migration.
- highpossible · vulnerabilityAuthentication & Authorization Analysisnew
Subscription tier used for entitlement decisions is read from the user's own profile row
lib/auth-middleware.ts — requireAuth() selects `subscription_tier` from `profiles`; requirePro() / requirePackPlus() gate on it
All paid-feature authorization (withProAuth, withPackPlusAuth) derives from `profiles.subscription_tier`, a column on the row the user owns, rather than from billing state (`owner_subscriptions`, which the fact sheet reports has 0 RLS policies and therefore is not being read through the user-scoped client). If the profiles UPDATE policy permits the owner to update their own row without restricting this column — the default shape of a Supabase self-service profile policy — the user can set their own tier.
Fails when: A free-tier user runs `supabase.from('profiles').update({ subscription_tier: 'top_dog' }).eq('id', myId)` with the publishable key and their own session. On the next request requireAuth reads back 'top_dog' and requirePro() passes, unlocking every Pro-gated route without any Stripe payment.
Fix: Make subscription_tier non-user-writable: either add a column-level restriction (`REVOKE UPDATE (subscription_tier) ON profiles FROM authenticated`) plus a BEFORE UPDATE trigger that rejects changes from non-service roles, or resolve tier in requireAuth from `owner_subscriptions` via a SECURITY DEFINER function fed only by the Stripe webhook.
Unverified: That the profiles UPDATE policy/grants allow the row owner to modify the subscription_tier column.
- highpossible · vulnerabilityunverifiedHardcoded Secrets & Sensitive Data Exposurenew
service_role credential remains retrievable from git history
git history of the migration/function defining handle_push_notification_webhook() (OBSERVED FACT 7)
The submission states a service_role credential was previously a literal inside handle_push_notification_webhook() and that the value is still present in git history. Moving the runtime read to Supabase Vault does not invalidate the leaked value. A service_role credential bypasses RLS entirely, which — given the broad anon grants above — is the highest-value secret in the system. The function source itself was not supplied, so this rests on the submitter's own statement.
Fails when: Anyone with read access to the repository (present or former contributor, CI cache, or a fork/mirror) runs `git log -p -S 'service_role'`, recovers the key, and issues PostgREST or SQL calls that bypass all 287 RLS policies — full read/write of medical records, messages and profiles.
Fix: Rotate the service_role key in Supabase now and confirm the old JWT is rejected. Then purge the blob (git filter-repo / BFG) and force-push, invalidate CI caches, and add a pre-commit secret scanner (gitleaks/trufflehog) to block re-introduction.
Unverified: That the previously embedded service_role key has not already been rotated and invalidated.
- lowcertain · deficiencyDependency & Supply Chain Risknew
All security-relevant dependencies use caret ranges
package.json — every listed dependency uses `^` (next: ^15.1.0, @supabase/supabase-js: ^2.39.0, stripe: ^20.4.1, zod: ^3.22.4, ...)
No dependency is pinned to an exact version. Any build performed without the committed lockfile (a fresh `npm install`, a Docker layer that copies only package.json, or a CI cache miss on a provider that ignores lockfiles) resolves to the newest minor/patch, so the audited artifact and the deployed artifact can differ. This is the standard exposure window for a compromised patch release of a transitively trusted package.
Fails when: A malicious 2.x patch of a Supabase or Stripe transitive dependency is published. The next deploy that resolves ranges rather than the lockfile pulls it in and ships credential-stealing code to production with no code change and no review.
Fix: Commit and enforce the lockfile (`npm ci` in CI, never `npm install`), pin the security-critical packages (next, @supabase/*, stripe) to exact versions, and enable Dependabot plus `npm audit --audit-level=high` as a build gate.
Unverified: That a build path exists which resolves ranges instead of using the committed lockfile.
- mediumcertain · vulnerabilityOWASP Top 10 (2021) Coveragenew
A05: no CSP on page responses — both config sources defer to the other
middleware.ts non-API branch (`addSecurityHeaders(intlResponse)` with the comment "Do NOT set CSP here") and next.config.js headers() ("CSP is set by middleware.ts (single source of truth) — not duplicated here")
buildCSP() exists but is only applied via addApiSecurityHeaders() to /api/* responses, where a script-src policy has no effect because those responses are JSON. The page-route branch deliberately omits CSP, and next.config.js omits it too on the belief that middleware owns it. Each file points at the other, so HTML responses ship with no Content-Security-Policy — matching the measured production headers. The policy that is applied to API routes also carries `style-src 'unsafe-inline'` and `img-src https:`, so even if it were moved to page routes it would need tightening.
Fails when: Any stored-XSS sink in user-generated content (post bodies, bios, park reviews rendered via @portabletext/react) executes with no script-src restriction and no connect-src restriction, letting injected script exfiltrate the Supabase session from localStorage to an attacker-controlled host. A CSP would have blocked both the inline execution and the outbound fetch.
Fix: Emit the CSP for document responses. Simplest correct fix: set the policy in next.config.js headers() for page routes (dropping 'strict-dynamic'/nonce, since Next.js scripts are not nonced there), or propagate the nonce properly by returning it as a request header and reading it in the root layout's <Script nonce>. Remove the dead API-only CSP or reduce it to `default-src 'none'; frame-ancestors 'none'` for JSON.
- mediumlikely · vulnerabilityOWASP Top 10 (2021) Coveragenew
A05: any request path containing a dot skips rate limiting and security headers
middleware.ts, middleware() — `if (pathname.startsWith('/_next') || pathname.startsWith('/favicon') || pathname.includes('.')) return NextResponse.next()`
The static-asset shortcut tests for a literal dot anywhere in the pathname, before the /api/* rate-limit block. Dynamic route segments frequently contain dots (slugs, filenames, emails, coordinates), so a request whose path contains a dot reaches the route handler with no rate-limit check, no trace ID, and no addApiSecurityHeaders() call.
Fails when: An attacker hits an API route with a dot-bearing dynamic segment or an appended dot (e.g. /api/blog/some.post, /api/places/search. ) in a loop. checkRateLimitAsync is never called, so the Upstash counter never increments and the endpoint can be hammered or scraped without ever receiving a 429.
Fix: Reorder the checks so /api/* is handled before the static shortcut, and narrow the shortcut to a real asset test — e.g. `/\.(?:js|css|png|jpe?g|svg|ico|woff2?|map)$/.test(pathname)` — rather than `includes('.')`. Prefer excluding assets in the `config.matcher` regex instead of in code.
Unverified: That at least one API route accepts a dynamic segment value containing a dot, or that Next.js routes a dot-suffixed path to a handler.
- mediumlikely · vulnerabilityOWASP Top 10 (2021) Coveragenew
A04: rate-limit tier selected from an unvalidated Authorization header
middleware.ts — `const isAuthenticated = Boolean(authHeader?.startsWith('Bearer '))` passed to getRateLimitForRoute(pathname, isAuthenticated)
The rate-limit budget is chosen by looking only at whether the header starts with 'Bearer ', with no signature or expiry validation (the comment concedes the JWT cannot be verified in edge middleware). Because authenticated budgets are normally more generous than anonymous ones, an anonymous caller can opt into the larger budget by attaching any string.
Fails when: An attacker sends `Authorization: Bearer x` to /api/parks/nearby or /api/dogs/suggestions — endpoints that already answer 200 without a session — and receives the authenticated rate limit, multiplying the scraping rate available per IP while the route itself never checks the token.
Fix: Do not derive limits from an unverified credential. Either verify the JWT signature in middleware (jose with the project JWKS) before choosing a tier, or apply the anonymous limit in middleware unconditionally and apply the per-user limit inside withAuth once `authContext.user.id` is known.
Unverified: That getRateLimitForRoute returns a higher limit for isAuthenticated=true.
- mediumlikely · vulnerabilityHardcoded Secrets & Sensitive Data Exposure
Profile PII and dog records readable with no session
OBSERVED FACT 3 (/api/dogs/suggestions returns dog id, name, breed at HTTP 200 unauthenticated) and OBSERVED FACT 4 (public_profiles view returns id, username, display_name, full_name, avatar_url, bio, is_private, is_verified, subscription_tier, created_at to the anon role)
Anonymous callers receive real records: dog identities from an API route with no auth wrapper, and a profile row including `full_name`, `bio` and `subscription_tier` from the public_profiles view. Legal name and paid-tier status are not data a social product normally exposes to unauthenticated clients, and the presence of `is_private` in the projection suggests the flag is a column in the view rather than a filter on it. The route source and the view definition were not supplied.
Fails when: An attacker enumerates public_profiles with the publishable key (`GET /rest/v1/public_profiles?select=*&limit=1000&offset=N`) and builds a complete membership list with real names and who is paying for Top Dog — useful for targeted phishing and for scraping combined with /api/dogs/suggestions to link owners to pets. If is_private users are rows in the view rather than filtered out, users who opted into privacy are included.
Fix: Drop `full_name` and `subscription_tier` from public_profiles, and add `WHERE is_private = false` to the view definition (or a policy achieving the same). Require a session on /api/dogs/suggestions by wrapping it in withAuth, or reduce its projection to non-identifying fields.
Unverified: That the public_profiles view does not already exclude rows where is_private is true.
- mediumlikely · deficiencyDependency & Supply Chain Risknew
Deprecated @supabase/auth-helpers-nextjs shipped alongside @supabase/ssr
package.json — `@supabase/auth-helpers-nextjs: ^0.8.7` together with `@supabase/ssr: ^0.9.0`
@supabase/auth-helpers-nextjs is deprecated upstream in favour of @supabase/ssr and no longer receives fixes; keeping both means two independent implementations of session cookie parsing and refresh are present in the same bundle. Divergent cookie/session handling between the two libraries is a known source of session-fixation and stale-session bugs, and the deprecated package will not be patched if such a bug is found.
Fails when: A security fix lands in @supabase/ssr for cookie handling but not in the deprecated auth-helpers package. Any route or component still importing the old helper keeps the vulnerable code path, and because both write to the same cookie names the refreshed session written by one library can be overwritten by the other, leaving a stale or attacker-supplied session token in place.
Fix: Migrate all remaining imports to @supabase/ssr and remove @supabase/auth-helpers-nextjs from package.json. Add `npm audit --omit=dev` and a deprecation check to CI.
Unverified: That at least one module still imports @supabase/auth-helpers-nextjs at runtime.
- mediumpossible · vulnerabilityunverifiedAuthentication & Authorization Analysis
User-scoped and identity-verification routes deployed with no auth wrapper
Endpoint inventory: `notifications/in-app` and `age-verification/parent-verify` listed among the 16 handlers with no auth wrapper
Two of the sixteen unwrapped handlers address inherently user-scoped or trust-establishing functionality: in-app notifications (per-user data) and parental age verification (a COPPA control that establishes a consent record). Neither route's source was supplied, so it is possible each performs its own inline token check or relies solely on RLS; but the absence of withAuth means there is no ban/suspension check and no server-side identity binding on those paths.
Fails when: If parent-verify accepts a child/parent identifier and marks consent without authenticating the caller, an attacker (including the minor) posts the identifier directly and self-grants verified status, defeating the age gate. If notifications/in-app resolves the user from a body or query parameter rather than a verified token, it becomes an IDOR over another user's notification feed.
Fix: Wrap both routes in withAuth (or, for parent-verify, a single-use signed token delivered out-of-band to the parent's email) and derive the subject identifier only from authContext.user.id. Add a CI check that fails the build for any file under app/api/ exporting a handler that is not passed through an auth wrapper and is not on an explicit public allowlist.
Unverified: That these two handlers do not perform an equivalent inline authentication check in code that was not submitted.
- mediumpossible · deficiencyunverifiedOWASP Top 10 (2021) Coveragenew
A08: 158 RLS policies and 45 tables exist only in production, not in migrations
OBSERVED FACT 8
The primary authorization boundary is Postgres RLS, yet a majority of policies and 45 tables have no migration defining them. There is therefore no reviewable, diffable, restorable definition of the access-control model: policy changes are invisible to code review, and a restore or environment rebuild silently produces a different security posture. The database DDL was not submitted, so this rests on the submitter's inventory.
Fails when: A staging or DR environment is provisioned from migrations. The 45 undefined tables are created ad hoc without RLS, or created with RLS but none of the 158 missing policies; because anon holds broad DML, that environment exposes every row to unauthenticated PostgREST queries and nobody notices until it is probed.
Fix: Dump current policies and tables into versioned migrations (`supabase db diff`), then enforce drift detection in CI (`supabase db diff --check` or a pg_dump comparison job) so any policy change made in the dashboard fails the build until it is committed.
- lowcertain · vulnerabilityAuthentication & Authorization Analysisnew
Cron shared secret compared with non-constant-time `!==`
lib/auth-middleware.ts, requireCronAuth() — `if (providedSecret !== expectedSecret)`
CRON_SECRET, a long-lived bearer token that authorizes 14 privileged batch routes, is compared with JavaScript's `!==`, which short-circuits on the first differing byte. There is also no length check before comparison and no rotation mechanism visible.
Fails when: An attacker repeatedly calls a cron route with candidate prefixes and measures response latency. Because the comparison exits early, byte-by-byte recovery of the secret is theoretically possible; once recovered, the attacker can invoke every withCronAuth route (batch notification sends, subscription reconciliation) at will.
Fix: Compare with a constant-time primitive: `const a = Buffer.from(providedSecret ?? ''); const b = Buffer.from(expectedSecret); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw ...`. Also prefer Vercel's signed cron headers over a static shared secret and rotate CRON_SECRET on a schedule.
- lowcertain · deficiencyAuthentication & Authorization Analysisnew
Cron secret fallback is unreachable when a non-Bearer Authorization header is present
lib/auth-middleware.ts, requireCronAuth() — `const providedSecret = authHeader?.replace('Bearer ', '') ?? cronSecret`
`String.replace` returns the original string when the pattern is absent, so a non-Bearer Authorization header yields a non-null value and the `??` operator never falls through to the x-cron-secret header. The replace is also unanchored, so it strips the first occurrence of 'Bearer ' anywhere in the value rather than only the scheme prefix.
Fails when: An internal caller or proxy attaches `Authorization: Basic ...` while also sending a correct `x-cron-secret`. requireCronAuth compares the Basic value against CRON_SECRET, fails, and returns 401 even though a valid secret was supplied — the scheduled job silently stops running.
Fix: Parse explicitly: `const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined; const providedSecret = bearer ?? cronSecret;` and reject when both are absent.
- lowcertain · deficiencyInput Validation & Output Encodingnew
Auth wrappers discard the Next.js route context, forcing manual ID extraction
lib/auth-middleware.ts — withAuth/withProAuth/withPackPlusAuth/withCronAuth all return `async (request: NextRequest) => ...`
App Router passes a second argument `{ params }` to route handlers. The wrappers accept and forward only `request`, so every dynamic route wrapped in withAuth must recover its path parameters by hand from request.nextUrl.pathname or a query string. Hand-rolled extraction is where off-by-one segment indexing and missing UUID validation typically appear, and it also breaks Next 15's async `params` contract.
Fails when: A handler for /api/dogs/[id]/medical splits the pathname and indexes the wrong segment after a route rename, passing an unexpected value into requireDogOwnership; the lookup errors and the handler returns 404 for legitimate owners, or — if a handler instead reads the id from a query parameter it forgets to validate — passes an arbitrary attacker-supplied string into the ownership check.
Fix: Thread the context through: `export function withAuth<C>(handler: (req: NextRequest, ctx: AuthContext, routeCtx: C) => Promise<NextResponse>) { return async (req: NextRequest, routeCtx: C) => { ... return handler(req, authContext, routeCtx) } }`, and validate every extracted id with a zod UUID schema.
Unverified: That at least one dynamic-segment route is wrapped by one of these helpers.
- infocertain · deficiencyOWASP Top 10 (2021) Coveragenew
X-XSS-Protection re-introduced in middleware after being deliberately removed from config
middleware.ts addSecurityHeaders() — `response.headers.set('X-XSS-Protection', '1; mode=block')`, contradicting the next.config.js comment "X-XSS-Protection removed: deprecated in modern browsers, can introduce vulnerabilities"
The header is ignored by current browsers and its legacy auditor implementations were themselves a source of XS-Leak and false-positive-blocking issues, which is why the config file removed it. Setting it in middleware reverses that decision inconsistently and signals that the two header code paths are not being reviewed together.
Fails when: A reviewer reading next.config.js concludes the header is gone and documents the posture accordingly, while production continues to emit it — a divergence between documented and actual configuration that also obscures the missing CSP.
Fix: Delete the X-XSS-Protection line from addSecurityHeaders() and rely on a real Content-Security-Policy for XSS mitigation.