securityadvisor-triage-2026-08-12 · v11099:claude-cowork-soc22026-08-12served from databaseAll documents

Supabase advisor triage (2026-08-12) - 3 live exploitable findings fixed: portal view disclosure, paywall stub, anon request amplification

11 — Remediation pass 3: security advisors

12 August 2026 · Actor 1099:claude-cowork-soc2 · Session fad23e8d

The Supabase security advisors were skipped in earlier passes because the raw output is ~96–146 KB per project. Read in full this time (432 KB across four projects, in isolation), cross-checked against the live catalog, and triaged. Three live exploitable findings. All three fixed and verified.


Advisor counts

LintFedM8NodeDoughCipherDeckjbnx
authenticated_security_definer_function_executable741141423
rls_enabled_no_policy13193765
security_definer_view00048
anon_security_definer_function_executable6553
function_search_path_mutable0003
extension_in_public1200

rls_disabled_in_public: zero everywhererelrowsecurity = true on every table in every API-exposed schema, verified in the catalog.


Fixed: three live exploitable findings

1. Portal views disclosed everything to any signed-in account (jbnx)

pf_project_audits, pf_delivery and pf_requests are SECURITY DEFINER views over the portfolio schema with SELECT granted to authenticated and no authorization predicate. A non-invoker view runs with the owner's rights, so any signed-in account — including one that is not a portfolio member — read 100% of rows: every project's audit posture and evidence URLs, delivery and WIP metrics, and inbound requests including submitted_by, contact, body and triage_note.

The other 43 pf_ views already carry portfolio.is_member_reader(). These three were missed.

Fix: added the same predicate. Not security_invoker = on — the browser role has no grant on the portfolio schema, which is the entire reason these views exist, so invoker mode would have returned zero rows for legitimate members and blanked the portal.

Verified empirically across all 41 populated pf_ views, impersonating a real auth.users id that is not in portfolio.members:

ViewOwner rowsNon-member, beforeNon-member, after
pf_project_audits1541540
pf_delivery14140
pf_requests660
all 38 othersvarious00

Owner context still returns 154 rows — the portal is not broken.

2. The CipherDeck paywall was a stub returning true

public.cd_is_subscribed(uuid) had the literal body select true;. It is the entire premium check in the WITH CHECK of two policies — inventory_sessions."inv insert own, premium only" and price_alerts."price_alerts own insert, premium only" — so every signed-in user, paying or not, could write premium rows.

Fix: implemented against the real entitlement source, profiles.subscription_status with the comped_until escape hatch, treating past_due as entitled to match FedM8's my_plan() convention.

Deliberately not implemented against public.subscribers — despite the name, that is newsletter email capture (source='cipherdeck.com'), and keying a paywall to it would entitle anyone who ever submitted an email address. Checking that before writing the predicate was the difference between a fix and an outage.

Verified: unconditional true is gone; a random UUID is denied; anon can no longer execute it. 12 of 13 profiles still resolve as entitled — correct, not a regression: they hold an explicit comped_until = 2099-01-01 grant. Only the single profile with no comp and no subscription is denied.

Business observation, not a security finding: 10 users have an open-ended comp to 2099 with no plan and subscription_status = 'none'. If that was a beta grant, it never expires and no amount of paywall correctness will bill them.

3. Unauthenticated request amplification (CipherDeck)

cd_uptime_ping() was executable by anon. Every unauthenticated RPC call made an outbound HTTP GET to app.cipherdeck.com/api/health and inserted a row into uptime_checks — 2,080 rows since 5 August. Anyone could use your own database to generate traffic against your app and grow a table without bound.

Fix: revoked from public, anon and authenticated. service_role retains EXECUTE, so the legitimate scheduled ping is unaffected — verified.


Real but not reachable — recorded, not fixed

pp_vault_entries. Not reachable: every policy is auth.uid() = user_id, which is NULL for anon. Veteran PII is closed. Dead grants worth revoking so a future permissive policy cannot silently open them.

exist anywhere in the schema.

including google_tokens, webauthn_credentials, sessions. RLS on, no anon policies → denied. The highest-value dead grant in the estate. Note the portal Supabase project is shared with nguy and support, so scope any revoke carefully.

insert into invite_attempts and bad_tries increment are both rolled back by the raise exception that follows, so the counter is permanently 0. No impact today — only 2 invites exist and redemption needs the exact invited email — but the rate limiter does not function.

Table is empty today; live the moment household invites are issued.

Noise, with reasons

intended RPC API. The admin_* families each open with an internal guard, made fail-closed on 12 Aug. Not actionable as a class — and the one genuinely broken guard, cd_is_subscribed, is fixed above.

service-role-only tables: RLS on with no policies denies everyone. Do not "remediate" by adding policies — that would open them.

verified closed. Only the 3 above were real (plus pf_briefs, already gated by is_owner()).

anon/authenticated EXECUTE. Mutable search_path is an escalation vector only on SECURITY DEFINER functions.

Gap this pass could not close

get_advisors returns database linter output only. There are zero auth_otp_long_expiry, auth_leaked_password_protection or insufficient_mfa_options entries in any payload — confirmed by grep over the full text, not inferred from absence. OTP expiry, leaked-password protection and MFA enrolment are unassessed and cannot be read over SQL. They need the dashboard Auth settings or the Management API.