Security Audit
Scope: supabase/functions/api/*, supabase/migrations/* (RLS/grants), dashboard auth/client config, SDK auth surface.
Method: Static source review (white-box). No live testing was performed against the deployed project.
Date: 2026-06-12
Summary
| # | Severity | Finding |
|---|---|---|
| 1 | Critical | Hardcoded super-admin secret (ADMIN_API_SECRET) committed to the repo |
| 2 | High | SSRF in link-preview endpoint (redirect bypass, DNS/encoding bypass) |
| 3 | Medium | Cross-conversation leak via reactions RLS (read + forge any message’s reactions in tenant) |
| 4 | Medium | Tenant-wide user/PII enumeration via users RLS |
| 5 | Medium | Long-lived, non-revocable user JWTs; weak claim validation |
| 6 | Medium | Open signup with auto-confirmed (unverified) email, no rate limit |
| 7 | Medium | No rate limiting anywhere (brute force + cost-amplification DoS) |
| 8 | Low | Moderation fails open on any error/timeout |
| 9 | Low | Fully reflective CORS |
| 10 | Low/Info | Attacker-supplied attachment MIME stored unverified; orphan-upload growth |
Good practices observed: RLS enabled on every table; consistent tenant_id scoping; SECURITY DEFINER functions pin search_path; API keys hashed (SHA-256) and never exposed via RLS (api_keys_service_only USING(false)); a BEFORE INSERT/UPDATE trigger (messages_guard_client_writes) blocks clients from inserting/editing message content directly, so the moderation pipeline can’t be trivially bypassed via supabase-js; service-role key stays server-side; queries go through supabase-js (no SQL injection surface); signup rolls back on partial failure.
1. Critical — Hardcoded super-admin secret in the repository
Location: supabase/functions/.env.example (git-tracked) — contains a real 64-hex-char value:
ADMIN_API_SECRET=84f34c1243ef2bc3723320558a13ebdda3a329b66748842d038dba3c91ee2053
Consumers: supabase/functions/api/index.ts adminMiddleware (324) and dashboardAuthMiddleware (339-347).
The X-Admin-Secret header sets isSuperAdmin = true, which bypasses per-tenant access checks (operatorHasTenantAccess, 374-401). With it an attacker can: list/create any tenant (/v1/tenants), and mint API keys for any tenant (POST /v1/api-keys, 933) — and a tenant API key yields full read/write of that tenant’s users, conversations, and (system) messages, plus token-minting for any end-user. It also reads all users/conversations/system profiles cross-tenant.
.gitignore excludes the real .env but not .env.example, and the example ships a production-strength value rather than a placeholder (unlike JWT_SECRET=your-project-jwt-secret). The README/.env comment says “Copy to .env and fill JWT_SECRET,” strongly implying the other values (including this secret) are kept as-is — i.e. this is likely the deployed secret.
Impact: Full cross-tenant compromise if this value is in use anywhere.
Recommendation: Treat as compromised — rotate ADMIN_API_SECRET immediately on the deployed project; replace the example value with an obvious placeholder; consider purging it from git history. Longer-term, prefer removing the static super-admin path entirely in favor of operator (Supabase Auth) authz. CWE-798 / CWE-540.
2. High — SSRF in /v1/link-preview
Location: supabase/functions/api/link-preview.ts — isBlockedHostname (51-85), fetchLinkPreview (140-207, redirect: "follow" at 160). Reachable by any authenticated end-user (jwtMiddleware, index.ts 1740-1743).
Three bypasses of the private-network guard:
- Redirect bypass: only the initial hostname is validated; with
redirect: "follow", a public URL can 30x-redirect tohttp://169.254.169.254/...(cloud metadata) or any internal host — the redirected host is never re-checked. - DNS bypass: the blocklist matches only literal
localhost/IP literals. An attacker-controlled domain (or DNS-rebinding) resolving to169.254.169.254/10.x/127.xpasses, because no DNS resolution is checked. - Encoding bypass: the IPv4 regex (66) misses decimal (
http://2130706433= 127.0.0.1), octal/hex, IPv4-mapped IPv6, and short forms (127.1). IPv6 coverage is also partial (only::1,fe80:,fc).
Impact: Read cloud metadata / internal-only services; use the function as a request proxy. CWE-918. Recommendation: Resolve DNS and validate every resolved IP (including after each redirect) against private/reserved ranges; ideally disable auto-redirect and re-validate manually; normalize/parse IP literals robustly; consider an allowlist or pinning the connect IP.
3. Medium — Cross-conversation leak/forgery via reactions RLS
Location: supabase/migrations/20260608130000_phase2_messaging.sql:166-182.
reactions_select_same_tenant gates only on tenant_id, and reactions_insert_same_tenant only on tenant_id + user_id = auth.uid(). Neither checks is_conversation_member. So any authenticated user can read all reactions in the tenant (message_id, user_id, emoji) for conversations they don’t belong to, and add reactions to any message in the tenant (forged reactions; existence-probing of message IDs). This is inconsistent with messages/read_receipts, which do enforce membership. CWE-639 / CWE-284.
Recommendation: Add and public.is_conversation_member(...) (resolved via the message’s conversation) to the reactions select/insert/delete policies.
4. Medium — Tenant-wide user & PII enumeration
Location: supabase/migrations/20260608120000_phase1_tenancy.sql:68-73 (users_select_same_tenant).
Any authenticated end-user can read every user row in the tenant — including users they share no conversation with — exposing display_name, avatar_url, and external_id. external_id is the host app’s identifier and is frequently an email (the digest query keys on external_id like '%@%', phase4_pro). One provisioned chat user can thus enumerate all users/emails in the tenant. CWE-200.
Recommendation: Restrict to self + co-members of shared conversations (e.g. an is_conversation_co_member definer helper), or expose only non-PII fields to peers.
5. Medium — Long-lived, non-revocable user JWTs; loose validation
Location: index.ts mintUserJwt (115-142); jwt-auth.ts verifyUserJwt.
Tokens are 6-hour HS256 bearer tokens signed with the legacy Supabase JWT secret, carrying role: "authenticated" — i.e. they are full Supabase sessions, not app-only tokens. There is no jti, no revocation, and no refresh: a leaked token is valid for the full 6h with tenant-scoped RLS access, and deleting/soft-deleting a user does not invalidate already-issued tokens (/v1/token checks deleted_at only at mint time). verifyUserJwt also ignores aud/role and doesn’t assert the header alg (algorithm-confusion is mitigated because it hardcodes HMAC-verify, but the claim hygiene is loose). CWE-613 / CWE-1270.
Recommendation: Shorten TTL + add refresh; add jti and a revocation/denylist (or a per-user token epoch) checked on use; assert alg === HS256 and validate aud. Consider a dedicated signing key separate from the Supabase legacy secret so these tokens can be scoped/rotated independently.
6. Medium — Open signup with unverified email, no throttle
Location: supabase/functions/api/signup.ts — email_confirm: true (77); POST /v1/auth/signup is unauthenticated (index.ts 625).
Accounts are auto-confirmed without proving email ownership, with no rate limiting or CAPTCHA, and a weak password policy (min 8 chars only). Enables spam tenant/account creation and registration under emails the actor doesn’t control. CWE-620 / CWE-307. Recommendation: Require real email verification (or gate signup), add rate limiting/CAPTCHA, strengthen password rules.
7. Medium — No rate limiting (brute force + cost-amplification DoS)
Location: whole Edge API; notably apiKeyMiddleware (403), /v1/token (1221), /v1/auth/signup, /v1/messages (each call triggers paid OpenAI moderation + possible GPT-4o-mini call), /v1/link-preview.
No throttling exists, enabling API-key/credential brute force and cost amplification — every message runs OpenAI moderation, and link-preview/digest perform outbound fetches/LLM calls. CWE-770 / CWE-799. Recommendation: Add per-IP/per-key/per-tenant rate limits (gateway or in-function), and quotas on moderation/preview calls.
8. Low — Moderation fails open
Location: supabase/functions/api/moderation.ts — checkHarm/checkContactEvasion/moderateText all return { passed: true } on missing key, non-OK response, timeout, or thrown error (e.g. 91-93, 109-113, 136-139, 219-222).
An attacker who can induce OpenAI errors (or simply rate-limit it via #7) bypasses harm/contact gating. This is a defensible availability-over-safety choice but should be deliberate and logged/alerted. CWE-636. Recommendation: Decide fail-open vs fail-closed per check explicitly; alert on sustained moderation failures; treat profanity/contact (local) checks as always-on.
9. Low — Reflective CORS
Location: index.ts:60-67 — origin: (origin) => origin ?? "*" reflects any origin and allows Authorization. The bearer-token (non-cookie) model means this isn’t directly exploitable, but it removes a defense-in-depth layer (any site can drive the API with a stolen token).
Recommendation: Restrict to known host-app origins where feasible.
10. Low/Info — Attachment MIME trust & orphan uploads
Location: supabase/functions/api/attachments.ts — mime_type taken from client (147-148, 209) and stored/echoed without verifying actual bytes; init issues a signed upload URL and creates a pending row (TTL 24h) before any file exists, with no evident cleanup job.
If any renderer trusts the stored MIME for inline display, this enables content-type spoofing / stored-XSS; abandoned pending rows/objects accumulate. (Attachments are flagged [v2] in the feature list.)
Recommendation: Sniff/validate content type server-side on complete; serve downloads with Content-Disposition: attachment and a safe content type; add a TTL sweep for expired pending uploads. Note: the playground (apps/dashboard/src/lib/host-api.ts) uses a raw tenant API key in the browser — acceptable for an operator-only test page, but keep it out of any shipped host integration.
Notes on things that are fine
- SHA-256 (unsalted) for API-key hashing is acceptable here because keys are 24 bytes of CSPRNG entropy (
generateApiKey) — fast hashing is appropriate for high-entropy secrets; no bcrypt needed. - The
grant ... update on public.conversations to authenticated(table_grants) is broader than any existing UPDATE policy, so RLS still denies it — least-privilege cleanup only, not a live issue.