Scope & Objectives
This module audits the managed platforms where modern products actually run. It is configuration-first: the platform vendor secures its infrastructure; the application owner secures everything configured on top of it, and on every platform below there is a default whose failure mode equals full-database compromise.
Shared-responsibility baseline. The vendor patches Postgres, scales Firestore, signs TLS, and operates the control plane. You own, without exception:
- Row Level Security (RLS) enablement and policy quality on every table.
- Security rules files (Firestore / Realtime Database / Storage).
- Network access lists and credential hygiene on connection-string databases.
- Which environment variables reach the browser and who can open preview deployments.
- Whether fulfillment logic verifies provider signatures before granting anything.
- CMS core/plugin/theme lifecycle and hardening constants.
In scope: Supabase; Firebase (Firestore, Realtime Database, Storage); connection-string-only platforms (Neon, Supabase-Postgres direct connections, Railway, Render, Upstash, MongoDB Atlas); serverless hosts (Vercel, Netlify and similar); Stripe-shaped payment webhooks; WordPress-class managed CMS.
Out of scope: cloud IAM deep dives (see the IAM module), TLS/certificate posture, DNS takeover (dedicated module), mobile-specific storage.
Objectives — produce a finding only when you can name the artifact:
- Detect every data surface reachable by the anonymous/public identity without authorization logic (RLS off, permissive rules, 0.0.0.0/0 allowlists).
- Detect privileged material (service_role keys, service-account JSON, Upstash REST tokens, connection strings) reaching clients, bundles, or VCS — cross-reference the SECRETS module.
- Verify payment-webhook handlers verify provider signatures, recompute amounts server-side, and dedupe deliveries idempotently.
- Verify host-level exposure: preview deployment protection, published source maps, header/redirect misconfigurations.
- Verify CMS update posture and hardening constants.
- Mark every live-data probe
requires-db-credentialsorauthorized-only; prefer static evidence from repo artifacts first because console or database access is often unavailable to the auditor.
Prerequisites & Vocabulary
Zero-background primer: the terms this module uses, one line each. Deeper plain-language explanations for every class live in the repository GUIDE.md glossary.
- RLS (Row Level Security): database feature deciding which rows each user may read or write
- anon key: the public Supabase key shipped in every browser bundle; safe only while RLS guards every table
- service_role key: master key that bypasses RLS entirely; must never reach client code
- security rules: Firebase's configuration file deciding who may read or write each collection and file
- preview deployment: auto-generated public URL per branch that can leak staging data and variables
- webhook signature verification: checking the payment provider cryptographically signed a callback before granting anything
- taint flow / source→sink: path untrusted data travels from entry point to dangerous function
- finding status: Confirmed > Probable > Needs-Review; evidence rules in templates/finding-report.md
Mental Model
Five invariants drive every check in this module:
- The public client key is public by design. The Supabase anon key ships in every browser bundle. RLS is therefore the only boundary between the internet and your tables. Any table without RLS enabled is world-readable AND world-writable through PostgREST, regardless of how "internal" it looks in the schema.
- Platform defaults favor onboarding speed, not security. New Supabase tables ship with RLS disabled; blank Firebase rule sets have historically been permissive test-mode; Mongo Atlas accepts
0.0.0.0/0network access with one click. Assume defaults are wrong until proven otherwise. - Anything inside the browser bundle is attacker-readable. Env prefixes (
NEXT_PUBLIC_,VITE_,NUXT_PUBLIC_) are compile-time public-by-construction; so is any secret accidentally prefixed, bundled, or shipped in a source map. - Any URL that renders content without authentication is attacker-reachable. Preview deployments mint a public URL per branch by default, carrying staging data and preview-scoped env vars.
- An endpoint granting value must prove the sender and dedupe replays. An unauthenticated webhook route that upgrades accounts after reading attacker-supplied JSON is the classic vibe-coder Critical.
Platform footgun matrix:
| Platform | Footgun | Detection | Hardened state |
|---|---|---|---|
| Supabase | New tables default to RLS OFF; anon key public by design | Migrations scan (create table with no following enable row level security); pg_tables introspection (requires-db-credentials) |
RLS enabled + owner-scoped policies covering select/insert/update/delete; documented exceptions only |
| Supabase | service_role bypasses RLS entirely |
Grep client code/bundles/env for the key reaching frontend (cross-ref SECRETS) | Key exists only in server-side runtime env; rotated if ever client-exposed |
| Firebase | allow read, write: if true scaffolding; forgotten Storage rules file |
Review *.rules; emulator/simulator evaluation |
Auth-scoped granular rules, simulator-tested, Storage rules reviewed same session |
| Firebase | Test-mode expiry timer past date = permanently open | Parse request.time < timestamp(X) against current date |
Rules migrated off test timers before expiry; prod rules never rely on time alone |
| Neon/Railway/Render/Upstash/Atlas | Public endpoint + credentials-in-connection-string auth; Atlas accepts 0.0.0.0/0 |
Connection-string scan; access-list config review | TLS-enforced strong credentials; IP-restricted or private networking; tokens never in clients |
| Vercel/Netlify | Every branch gets a public preview URL by default | Deploy logs/commit-status hosts; unauth fetch probe | Deployment protection enabled for all non-production targets |
| Payment webhooks | Unsigned handler grants entitlements from client JSON | Handler code review: constructEvent absent |
Signature verification + event-type allowlist + consumed-once idempotency |
| WP-class CMS | Stale plugins, admin file editor on, xmlrpc.php open |
wp-config.php review; authorized curl probes |
Patched components, editing disabled, xmlrpc off, application passwords, debug off |
Corollary used throughout What To Check: rules are not filters (Firebase) and policies are not presence (Supabase). A rule that does not constrain the query shape cannot rescue a broad query; a policy that merely exists but says using (true) protects nothing. Presence of the control is checked separately from quality of the control.
What To Check
Work platform by platform. A finding is complete only when it names the failing control and the artifact that proves it (migration file, rules file, handler route, config constant). Static evidence first; live probes only per Exploitation & Reproduction.
Supabase
-
RLS enablement (the flagship check). Scan every SQL migration for
create tablestatements in thepublicschema and verify a followingalter table <schema>.<table> enable row level security;exists in the same migration set or any later one. Absence = world-readable AND world-writable via PostgREST using the anon key. -
Live-database confirmation (
requires-db-credentials). When database credentials are supplied, run:-- requires-db-credentials: live RLS state, read-only select schemaname, tablename, rowsecurity from pg_tables where schemaname = 'public' order by tablename;Every row must show
rowsecurity = true, or carry a documented decision that the table is intentionally public. -
Policy quality. For each RLS-enabled table, audit the
create policystatements:- Flag scaffolding placeholders: policies whose expression is
using (true)orwith check (true)with no role or ownership predicate. - Verify predicates reference identity correctly:
auth.uid() = user_id/owner_idcolumn comparisons, not free variables that never bind. - Map coverage per operation: a table readable only through a
for selectpolicy still allows anonymous INSERT/UPDATE/DELETE attempts to be evaluated against no policy (denied) — but the inverse is the killer: write-capable policy with no select policy, or an UPDATE policy missing itswith checkhalf, letting rows be moved to another owner. Enumerate which offor select / insert / update / deletehave policies and whether each gap is intentional.
- Flag scaffolding placeholders: policies whose expression is
-
service_role exposure. The service_role key bypasses RLS entirely. Grep client code, committed bundles,
.env*files consumed by frontend builds, and anyNEXT_PUBLIC_/VITE_prefixed variable for the service_role value or its variable name (cross-ref SECRETS). Any hit = full unauthenticated database compromise once the bundle ships. -
Storage buckets. Inspect bucket creation migrations (
insert into storage.buckets ... public true) andstorage.objectspolicies. Public buckets are legitimate for avatars-by-design but must be documented; flag undocumented public buckets holding non-public object classes. Remember storage access also evaluates RLS onstorage.objects— over-broad policies there leak every bucket. -
Edge functions secrets. Edge functions should read secrets from the runtime-managed secrets store (e.g.,
Deno.env.get(...)), never from hardcoded literals or files inside the function directory that get bundled.
Firebase
- Rules file review. Read every rules file:
firestore.rules,storage.rules,database.rules.json. Flag these shapes:allow read, write: if true;— fully open.allow read, write: if request.auth != null;— better, but grants any authenticated user any document; flag when data is per-user.- Missing
request.auth != nullon write paths. - Wildcard path grants like
match /{document=**}applied project-wide, or broad segment wildcards (match /data/{id}) where sub-collections intended narrower scope. - Separate-operation gaps:
allow readgranted whilewriteis split into create/update/delete with only some covered.
- Rules are not filters. A rule cannot rescue a query that asks for broader data unless the rule itself constrains the query shape: if the client issues a collection query without the constraint the rule checks per-document, Firestore rejects the whole query rather than filtering results. Conversely a permissive rule plus a narrow client-side filter equals a leak — anyone queries directly. Audit rules as the authority, never the client code's query habits.
- Storage rules are a separate file, often forgotten. Projects harden Firestore and leave
storage.rulesat test-mode openness. Review it in the same pass; flag uploads readable/writable byrequest.auth == null. - Test-mode expiry timers. Scaffolded test rules end in
allow read, write: if request.time < timestamp(<future-date>);. Parse the date; if it is in the past, the rule is inert-but-open depending on surrounding matches — treat expired-timer-plus-permissive-fallback as open. If future-dated, note the expiry date in the finding and require migration before expiry. - Admin SDK / service accounts. Flag any
*-firebase-adminsdk-*.json,serviceAccountKey.json, or tracked JSON containing"type": "service_account"and a"private_key"field (cross-ref SECRETS). Also flag Admin SDK initialization imported into client-reachable code paths.
Connection-String Databases (Neon / Railway / Render / Upstash / Mongo Atlas)
- Default endpoint publicity. These platforms provision internet-reachable endpoints; the connection string's credentials are the ONLY authentication. Treat every committed or logged connection string as a full-compromise credential (cross-ref SECRETS).
- TLS enforcement. Verify TLS is mandatory, not optional: Postgres-family strings should carry
sslmode=requireor stronger (notsslmode=disable/prefer); Redis-family connections should use TLS endpoints; Atlas enforces TLS by default — confirm nothing downgrades it. - Upstash REST tokens. Upstash REST API tokens are bearer credentials: possession = full data access including writes. Flag them anywhere near client bundles, mobile apps, or browser-exposed env prefixes; they belong server-side only.
- Mongo Atlas network access list. The classic trap: project network access list containing
0.0.0.0/0(allow from anywhere). Console screenshots described by the user count as evidence; repo-side, look for Terraform/CLI provisioning of access lists with world-open CIDRs. Recommend specific egress IPs or private endpoints. - Credential sprawl. Check how many distinct copies of
DATABASE_URL/MONGO_URL/REDIS_URLexist across shell scripts, CI logs, docker-compose environment blocks, and preview-environment configs; each copy is another leak surface.
Serverless Hosts (Vercel / Netlify)
- Env var scoping. Variables prefixed
NEXT_PUBLIC_,VITE_, orNUXT_PUBLIC_(and equivalents) are inlined into the browser bundle at build time — they are published values, not configuration (cross-ref SECRETS; this trap is named here once and pointed to). Grep those prefixes for secret-shaped names (keys, tokens, URIs with credentials). - Preview deployments. By default every git branch/push gets a publicly fetchable deployment URL carrying staging data and preview-scoped env vars. Verify protection is enabled via console guidance phrasing: in the host's project settings, deployment-protection options (SSO-gated access, shared password, bypass-token schemes) must cover all preview/branch deployments, not just production. If the user provides console screenshots, read them for the protection toggle state.
- Source maps. Production source maps published to the browser hand attackers your original source. Check
next.config.jsforproductionBrowserSourceMaps: true, build scripts emitting maps into the publish directory, and confirm by fetching<bundle>.js.mapfrom production (authorized target). - Header and redirect misconfigurations. Review
vercel.json/netlify.toml/_headers/_redirects: wildcard catch-all rewrites that mask internal routes, redirects with!negation mistakes creating open redirects, missing security headers (CSP, X-Frame-Options) on all routes. - Environment sprawl across environments. Preview/prod env divergence means a secret removed from production may still live in preview context — reachable through the unprotected preview URL. Inventory which variables exist per environment and prune.
Payment Webhooks (Stripe-Shaped, Generic Pattern)
- Signature verification is mandatory before granting anything. Fulfillment endpoints MUST verify the provider signature over the RAW request body using the endpoint signing secret. Stripe shapes, name them confidently:
- Node:
stripe.webhooks.constructEvent(rawBody, sigHeader, endpointSecret)— throws on failure; reject with 4xx. - Python:
stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)— raisesValueError(payload parse) orstripe.SignatureVerificationError; reject with 4xx. - Flag handlers that parse JSON first (
express.json()before the raw-body capture,request.get_json()then re-serialize) — signature verification fails or is silently skipped.
- Node:
- Never trust prices/amounts/currency from the client. Recompute totals server-side from the catalog/price IDs known to the provider. On return/redirect, validate the checkout session server-side (retrieve the session by ID from the provider, not from client-supplied fields) before granting entitlements.
- Idempotent fulfillment. Providers retry deliveries. Dedupe by event ID with a consumed-once table: insert the event ID inside the same transaction as fulfillment; unique-violation = already processed = return success without re-granting.
- Event-type allowlist. Handle only event types you explicitly define (
checkout.session.completed,invoice.paid, ...); ignore-and-ACK everything else. Flag switch/if chains acting on arbitraryevent.typestrings with default-grant behavior. - The classic vibe-coder Critical: an unauthenticated
/api/webhook|/upgraderoute reading attacker-posted JSON ({"type":"checkout.session.completed","email":"victim@x"}) and upgrading the account with no signature check. Any "webhook" route that mutates entitlements without provider-signature verification is this finding regardless of naming.
Managed CMS (WordPress-Class)
- Update posture. Inventory core/plugin/theme versions from repo artifacts or authorized probes; flag anything visibly stale relative to current stable, and any plugin/theme present but not activated/used — remove unused ones entirely.
- Disable in-admin file editing. Verify
wp-config.phpdefinesDISALLOW_FILE_EDITastrue(absence = vulnerable default: admin users can edit plugin/theme PHP, turning any compromised admin session into remote code execution). - xmlrpc.php. If unused (no Jetpack-class integration requiring it), disable it; left open it serves brute-force amplification and pingback abuse. Existence probe is in Exploitation & Reproduction (
authorized-only). - Application passwords. Integrations (mobile apps, XML-RPC clients, automation) should use per-application passwords revocable independently of the main account password.
- wp-config hygiene. Unique salts/auth keys defined (no placeholder phrases);
WP_DEBUGfalse in production; debug log must not be web-readable. - Admin surface.
/wp-adminandwp-login.phpshould be protected with MFA and/or rate limiting. Path obscurity (renamed login) is a minor, honest note only — it is not a control.
Serverless functions (Lambda / Cloud Functions / Workers)
- One IAM role per function. A shared execution role turns any single function's compromise into fleet-wide reach; wildcard resources (
Resource: "*","Action": "*", broad service wildcards) in a function role are the finding shape — scope each role to exactly that function's inputs and outputs. - Every event source is untrusted input. API Gateway requests, S3 event records, queue messages, scheduled triggers: payloads arrive attacker-influenced no matter how internal the wiring looks. Validate schema and identity inside the handler boundary; never trust the invoking platform to have screened the payload.
- Limits AND concurrency caps. Timeout and memory settings bound one invocation; concurrency/reserved-capacity settings bound the bill. Cost-based DoS is a real category: one viral bug fanning out into unlimited concurrent executions becomes a five-figure invoice overnight.
- Function URLs / HTTP triggers require auth. Default-invoke function URLs and public HTTP triggers are the accidental-exposure classic: verify every trigger authenticates (IAM-signed requests or an authenticating gateway in front) — "no public invoke by accident" is a checklist item, not an assumption.
- Secrets fetched from the platform secrets store at cold start. Initialization reads them from the managed secrets/parameter store; never baked into deployment packages, bundled layers, or artifact-shipped env snapshots.
Where To Look
Supabase
supabase/migrations/*.sql— table definitions,create policy,enable row level security, bucket inserts.supabase/config.toml— project linkage;supabase/functions/**— edge function code and secret reads.- Client init:
src/lib/supabase*.ts, any file importing@supabase/supabase-jsand callingcreateClient(. .env*files consumed by frontend builds; committed bundles underdist/,.next/,public/.
Firebase
firestore.rules,storage.rules,database.rules.json,firebase.json(rules file wiring).functions/— Admin SDK usage (firebase-adminimports,initializeApp(with credential arguments).- Tracked JSON matching
*serviceAccount*.json,*-firebase-adminsdk-*.json, or containing"type": "service_account".
Connection-String Databases
.env*,docker-compose*.ymlenvironment blocks, CI definitions, shell scripts.- ORM config:
prisma/schema.prismadatasource url env indirection, Sequelize/Knex config files. - Provisioning-as-code: Terraform for Atlas access lists / Neon projects; platform CLI scripts in
scripts/.
Serverless Hosts
vercel.json,netlify.toml,_headers,_redirects,public/_headers.next.config.js(productionBrowserSourceMaps), build scripts inpackage.json.- Deploy logs / commit status checks (user-supplied) revealing preview hostnames per branch.
Payment Webhooks
- Route handlers: Next.js
app/api/**/route.tsandpages/api/**, Express/Fastify route registrations, Django/Flask/FastAPI URL configs — search for path fragmentswebhook|stripe|checkout|billing|fulfil. - The handler body: presence/order of
constructEventvs JSON body parsing; entitlement mutations (update ... plan/pro/tier) reachable from it.
Managed CMS
wp-config.php(constants),wp-content/plugins/andwp-content/themes/listings,xmlrpc.phppresence, web-server config (nginx.conf,.htaccess).
Patterns & Signatures
Ripgrep-compatible signature hunt over repo artifacts (run from repo root):
# BaaS SDK surfaces
rg -n "supabase" -g '!node_modules' -g '!package-lock.json'
rg -n "createClient\(" -g '!node_modules' --type ts --type js
rg -n "from\(['\"]" src/ -g '!node_modules' # supabase-js table access surfaces to diff against RLS-enabled set
# Firebase artifacts and rules
rg -n "firebaseio\.com|firestore\.rules|database\.rules|storage\.rules"
rg -n "initializeApp\(|getFirestore\(|firebase-admin" -g '!node_modules'
# Webhook integrity
rg -n "constructEvent|SignatureVerificationError|stripe-signature"
# Browser-exposed env prefixes (cross-ref SECRETS)
rg -n "NEXT_PUBLIC_[A-Z_]+|VITE_[A-Z_]+|NUXT_PUBLIC_[A-Z_]+" -g '!node_modules'
# Privileged material
rg -n "service_role|SERVICE_ROLE" -g '!node_modules'
rg -n "postgres(ql)?://[^[:space:]\"']+" # connection strings — REDACT every match before reporting
# Permissive policy/rule shapes
rg -n "using\s*\(\s*true\s*\)|with check\s*\(\s*true\s*\)" supabase/
rg -n "allow\s+read,\s*write|if\s+true" --glob '*.rules'
Manual-judgment note: no grep can prove a migration lacks RLS enablement — SQL semantics require reading the migration sequence. Use the signature output to enumerate candidate tables (create table hits) and manually diff each against the set of enable row level security statements across all migrations, honoring later alter table additions. Report as Needs-Review until confirmed.
Paste-ready read-only sweep (values REDACTED always; run from repo root):
# aegis-sweep :: baas-platform :: read-only :: all secret-shaped values REDACTED
red() { sed -E 's/([A-Za-z_]*(KEY|TOKEN|SECRET|PASSWORD|PRIVATE)[A-Za-z_]*)[=:][^[:space:]"]+/\1=[REDACTED]/g; s#((postgres(ql)?|mongodb(\+srv)?|mysql|redis))://[^[:space:]"]+#\1://[REDACTED]#g'; }
echo "== platform markers =="
git ls-files | grep -E '(^|/)(firestore|storage|database)\.rules$|firebase\.json$|vercel\.json$|netlify\.toml$|(^|/)supabase/config\.toml$|schema\.prisma$|wp-config\.php$'
echo "== SDK entry points =="
git grep -lnE "createClient\(|initializeApp\(|new Pool\(|mongoose\.connect|stripe\(" -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.py' | head -30
echo "== migrations missing RLS enable (manual confirm) =="
for f in $(git ls-files 'supabase/migrations/*.sql'); do git show ":$f" | grep -qie 'create table' && ! git show ":$f" | grep -qie 'enable row level security' && echo "RLS-GAP? $f"; done
echo "== rules files (contents safe to print) =="
for f in $(git ls-files '*.rules'); do echo "--- $f"; cat "$f"; done
echo "== browser-exposed env prefixes =="
git grep -nE "NEXT_PUBLIC_[A-Z_]+|VITE_[A-Z_]+|NUXT_PUBLIC_[A-Z_]+" | red | head -40
echo "== privileged material in tracked files =="
git grep -niE "service_role|service_account|BEGIN (RSA )?PRIVATE KEY|constructEvent" | red | head -40
echo "== connection strings anywhere =="
git grep -niE "(postgres(ql)?|mongodb(\+srv)?|mysql|redis)://" | red | head -20
Interpretation: RLS-GAP? lines are candidates only (manual confirmation required); rules file contents are configuration, not secrets, and may be printed; every other stream passes through red() so no key value ever reaches the report or terminal.
Taint Tracing Guidance
Model each platform boundary as source-to-sink; the control that must sit on the path is named per pair.
| Source (attacker-influenced or public-by-design) | Sink | Required gate on path |
|---|---|---|
Supabase anon key + from('<table>') call in client bundle |
PostgREST row read/write | RLS enabled + operation-scoped policy on <table> |
service_role key value |
Anything client-reachable: bundle, repo, preview env | Server-only runtime env; never a public env prefix |
Env var with public prefix (NEXT_PUBLIC_, VITE_, NUXT_PUBLIC_) |
Compiled JS in browser | None possible — value is public at build time; only non-secrets may take this path |
Webhook route req.body / request.get_data() |
Entitlement mutation (plan, credits, license, unlock) | constructEvent over RAW body + event-type allowlist + consumed-once dedupe |
| Client-supplied amount/currency/priceId/email | Order record or fulfillment decision | Server-side recompute from provider catalog; session retrieved by ID server-side |
| Firebase query issued by client | Documents evaluated against rules | Rule must constrain the query shape itself (rules-are-not-filters) |
| Service-account JSON path | VCS history, forks, clones | Untracked + ignored + rotated if ever committed |
| Upstash REST token / connection string | Logs, CI output, client code, error reporters | Bearer-secret handling: server-side env only |
Worked traces:
- Client bundle imports
createClient(SUPABASE_URL, SUPABASE_ANON_KEY)and calls.from('documents'). Trace stops at the table name: diff it against the migrations' RLS-enabled set. No policy for anon role = anonymous read/write proof available via curl (procedure a). pages/api/stripe/webhook.tsbegins withconst event = req.bodyand noconstructEventappears anywhere in the file. The body taintsgrantPlan(...)directly — unsigned fulfillment confirmed statically; verify live with procedure (b).NEXT_PUBLIC_STRIPE_SECRET_KEYin.env.local: prefix taint guarantees bundling into shipped JS. Report as Critical-adjacent secret exposure even before fetching the bundle to confirm (cross-ref SECRETS).
Exploitation & Reproduction
Static-first throughout: console access may be absent, so every procedure starts from repo artifacts. Run live steps only against targets you are authorized to test. Never exfiltrate data beyond one-row existence proofs; REDACT all values in reports.
(a) Supabase Anonymous Read/Write Proof
- Extract from repo: project URL (
NEXT_PUBLIC_SUPABASE_URL, supabase config, or client init literal) and anon key variable reference. Do not print either. - Enumerate candidate tables from migrations (
create tablehits) minus the manually-confirmed RLS-enabled set. - Probe read (
requires target authorization):
BASE="$SUPABASE_PROJECT_URL"; ANON="$SUPABASE_ANON_KEY" # values never echoed
curl -s -o /dev/null -w "%{http_code}\n" \
"$BASE/rest/v1/<table>?select=*&limit=1" \
-H "apikey: $ANON" -H "authorization: Bearer $ANON"
# 200 -> anonymous read succeeds = Critical when rows are non-public
# 401/403/empty-with-error (e.g., permission code 42501) -> RLS enforcing as intended
- Probe write:
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"$BASE/rest/v1/<table>" \
-H "apikey: $ANON" -H "authorization: Bearer $ANON" \
-H "content-type: application/json" -H "prefer: return=minimal" -d '{}'
# 201 -> anonymous INSERT succeeds = Critical (world-writable table)
# 401/403/42501 -> insert policy blocking as intended
Expected observables: HTTP 200 with row JSON (read) or 201 (write) proves full-database compromise via the public key alone; 401/403 or an empty body with a PostgREST permission error demonstrates the policy layer working.
(b) Stripe Webhook Replay/Rejection Test
- Locate the fulfillment route in repo and confirm statically whether
constructEventgates the handler. - Unsigned probe against your own deployment:
curl -s -o /dev/null -w "%{http_code}\n" -X POST "http://localhost:3000/api/stripe/webhook" \
-H "content-type: application/json" \
-d '{"id":"evt_forged_001","type":"checkout.session.completed","data":{"object":{"customer_email":"attacker@example.com"}}}'
# Expected when correct: 400/verification-failure observable, no entitlement change.
# 200 plus account upgrade = Critical: unsigned fulfillment accepts forged events.
- Authorized test-mode round-trip using the stripe CLI conceptually:
stripe listen --forward-to <local-handler>thenstripe trigger checkout.session.completed; confirm the handler logs successful verification and fulfills once. - Tamper-and-replay: capture the delivered test event, alter its payload or resend it verbatim after fulfillment; the endpoint must reject tampering (signature mismatch) and must not double-grant on verbatim replay (idempotency). Observable: second delivery returns success but entitlement state unchanged exactly once.
(c) CMS Probes (AUTHORIZED-ONLY)
curl -s -o /dev/null -w "xmlrpc: %{http_code}\n" "https://TARGET/xmlrpc.php"
# 200/405 -> endpoint present and reachable (disable when unused); 403/404 -> blocked
curl -s -o /dev/null -w "debuglog: %{http_code}\n" "https://TARGET/wp-content/debug.log"
# 200 with PHP notices -> WP_DEBUG logging publicly readable; 403/404 -> acceptable
Do not brute-force, do not enumerate users, do not POST to xmlrpc methods; existence probes only.
(d) Preview URL Discovery And Protection Check
- Discover hosts without touching consoles: PR histories, commit status checks, and deploy logs expose per-branch deployment hostnames (patterns typically
<branch-or-hash>--<site>.netlify.appand<deployment>-<project>.vercel.app). - Fetch one discovered host unauthenticated:
curl -s -o /dev/null -w "%{http_code}\n" "https://<preview-host>/"
# 200 rendering the app shell with no auth challenge = unprotected preview
- Confirm scope minimally: note whether staging API base URLs or preview env markers appear in the served HTML/bundle references; do not dump staging data.
- Expected observable when protected: redirect to an SSO/login challenge or 401 before any app shell renders.
Remediation
Supabase
Enable RLS on every public-schema table and add owner-scoped policies covering all four operations:
-- VULNERABLE: table reachable by the public anon key, no RLS
create table public.documents (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null,
title text,
body text
);
-- FIXED: deny-by-default, then grant narrowly per operation
alter table public.documents enable row level security;
create policy documents_select_own on public.documents
for select using (auth.uid() = owner_id);
create policy documents_insert_own on public.documents
for insert with check (auth.uid() = owner_id);
create policy documents_update_own on public.documents
for update using (auth.uid() = owner_id)
with check (auth.uid() = owner_id); -- prevents re-parenting rows to another owner
create policy documents_delete_own on public.documents
for delete using (auth.uid() = owner_id);
Tables used only by server code (service_role) should have no anon/authenticated policies at all — RLS enabled plus zero policies already denies the public roles.
service_role isolation pattern: the service_role key lives only in server-side runtime env (process.env.SUPABASE_SERVICE_ROLE_KEY behind a non-public name). It must never receive a NEXT_PUBLIC_/VITE_/NUXT_PUBLIC_ prefix, never appear in client-callable modules (in Next.js, import it only from modules marked server-only), never enter VCS or preview env contexts. If it ever shipped in a bundle, rotate it immediately — deletion of the variable does not un-leak it.
Firebase
// VULNERABLE: scaffolding left open project-wide
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if true;
}
}
}
Hardened starter (deny by default; grant narrowly; mirror the same shape in storage.rules):
// FIXED: starter - replace collection names/fields to match the real schema
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{uid}/private/{docId} {
allow read, write: if request.auth != null && request.auth.uid == uid;
}
match /posts/{postId} {
allow read: if true; // documented intentional public content
allow create: if request.auth != null
&& request.resource.data.authorUid == request.auth.uid;
allow update, delete: if request.auth != null
&& resource.data.authorUid == request.auth.uid;
}
// no catch-all match -> everything else denied
}
}
Migrate off test-mode timers before their timestamps pass; never combine a timer with a trailing permissive fallback. Validate iteratively in the emulator/rules playground before deploying.
Connection-String Platforms
- Rotate any credential that appeared in repo, logs, or clients; store replacements in platform secret managers only.
- Enforce TLS in the string:
?sslmode=require(Postgres-family); TLS endpoints for Redis-family; keep Atlas default TLS untouched. - Atlas: replace
0.0.0.0/0network access with specific egress IPs or private networking. - Upstash REST tokens are bearer secrets: server-side env only, scoped per database, rotated on suspicion.
Payment Webhooks
// VULNERABLE: trusts client JSON, no signature check -> forged upgrades
app.post("/api/stripe/webhook", express.json(), async (req, res) => {
const event = req.body;
if (event.type === "checkout.session.completed") {
await grantPlan(event.data.object.customer_email, event.data.object.amount);
}
res.sendStatus(200);
});
Node fixed middleware:
// FIXED (Node): verify over RAW body before anything else
app.post("/api/stripe/webhook",
express.raw({ type: "application/json" }), // raw bytes required for signature
(req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send("signature verification failed");
}
if (!["checkout.session.completed", "invoice.paid"].includes(event.type)) {
return res.status(200).end(); // allowlist: ACK unknown types, act on none
}
fulfill(event); // internally: recompute price from catalog,
return res.json({ received: true }); // dedupe by event.id (consumed-once)
});
Python fixed handler:
# FIXED (Python/Flask): construct_event over raw payload; reject on failure
import stripe
from flask import Flask, request, abort
@app.post("/api/stripe/webhook")
def webhook():
sig = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(
request.get_data(), sig, app.config["STRIPE_WEBHOOK_SECRET"]
)
except ValueError:
abort(400) # invalid payload
except stripe.SignatureVerificationError:
abort(400) # signature mismatch
if event["type"] not in HANDLED_EVENTS:
return ("", 200)
fulfill(event) # recompute + dedupe inside
return ("", 200)
Consumed-once table backing idempotent fulfillment:
-- FIXED: unique(event_id) makes replay inserts fail inside the fulfillment transaction
create table public.processed_webhook_events (
event_id text primary key,
provider text not null,
processed_at timestamptz not null default now()
);
-- flow: begin; insert into processed_webhook_events(event_id, provider) values ($1,'stripe');
-- on conflict do nothing -> skip fulfillment; commit after granting entitlements
Serverless Hosts — Preview Protection Checklist
Console-guidance phrasing (verify state via user-provided screenshots; no CLI):
- Project settings → deployment/access protection: require authentication for every non-production deployment (team SSO gate, shared password, or bypass-token scheme).
- Confirm protection covers branch and preview targets, not only production aliases.
- Remove secrets from preview-scoped env vars unless strictly required; treat preview context as public until protection is proven on.
- Disable production source maps (
productionBrowserSourceMapsunset/false; strip.mapfrom publish directory). - Audit
vercel.json/netlify.tomlredirects/rewrites for open-redirect and route-masking mistakes; add security headers globally.
Managed CMS Hardening Checklist
// FIXED - wp-config.php additions (production)
define('DISALLOW_FILE_EDIT', true); // blocks plugin/theme PHP editing from admin UI
define('WP_DEBUG', false); // no debug output or web-readable debug.log
- Update core/plugins/themes; delete unused plugins/themes rather than deactivating.
- Keep unique salts/auth keys in
wp-config.php(no placeholder phrases). - Disable
xmlrpc.phpwhen no integration requires it (web-server rule or removal). - Use per-application passwords for integrations; enforce MFA and rate limiting on logins; path obscurity is optional garnish, not a control.
Verification & Validation
Post-fix verification (positive):
- Rerun the
pg_tablesintrospection: every public-schema row reportsrowsecurity = true(or carries a documented public-by-design exception). Re-run procedure (a): reads/writes against protected tables now return 401/403 or permission errors via the anon key. - Firestore/RTDB/Storage: run the revised rule sets through the rules simulator (console playground) and the local emulator suite; every unauthenticated read/write test case must evaluate to deny, every intended grant to allow.
- Webhook:
stripe trigger checkout.session.completedin test mode fulfills exactly once; a verbatim replay updates no entitlement state; a tampered payload is rejected at signature verification. - CMS probes return non-reachable codes for
xmlrpc.phpandwp-content/debug.log;DISALLOW_FILE_EDITvisible in deployedwp-config.php.
Negative tests (product intent preserved):
- Intended anonymous reads still work: public landing/marketing tables return 200 through the anon key; public blog documents remain readable under hardened Firebase rules.
- A real customer checkout completes end-to-end in test mode and grants entitlements exactly once.
- Authenticated users can still perform their intended CRUD on their own rows.
Regression notes:
- Over-tight RLS can silently break realtime subscriptions — Supabase realtime evaluates your policies; a missing select policy for the subscribing role stops receiving events. Test subscriptions after policy changes.
- Rules tightened too far break legitimate client queries (rules-are-not-filters): a per-document rule without a matching query constraint rejects whole queries. Iterate against the emulator before deploying; watch client logs for permission-denied on valid flows.
- Revoking anon grants on tables the frontend legitimately reads will break those screens — diff client
.from('<table>')usage against the hardening set before applying.
Greps rerun post-fix: the Patterns & Signatures block should show zero hits for service_role outside server-only modules, zero secret-shaped names under public env prefixes, and constructEvent present in every fulfillment handler. Any residual hit needs a documented exception.
Severity Assessment
Vectors assume an internet-reachable deployment; adjust AV/PR downward only with documented network-boundary evidence.
| Anchor | Finding | CVSS v3.1 vector | Approx. score |
|---|---|---|---|
| Critical | PII-bearing table with RLS off, readable AND writable by anyone holding the public anon key | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
~9.8 |
| Critical | Firestore/RTDB rules fully open (allow read, write: if true) on user data |
same shape as above | ~9.8 |
| High | service_role key recoverable from shipped client bundle (RLS bypass = full DB control); treat as Critical-equivalent when live-confirmed | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L |
~9.2 |
| High | Unsigned webhook fulfillment granting entitlements from forged JSON | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N |
~8.1 |
| High | Unprotected public preview exposing prod-like data/env vars | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N |
~8.1 |
| Medium | Public-read storage bucket undocumented (avatar-style exposure of a broader object class than intended) | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N |
~5.3 |
| Medium | xmlrpc.php left open when unused (brute-force amplification/pingback abuse surface) |
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N |
~5.3 |
| Low | Platform/component version disclosure banners | CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:L/I:N/A:N |
~3.6 |
Anchor discipline: RLS enabled-but-permissive is graded by what its worst reachable operation exposes, not as "missing RLS"; expired-but-inert test-mode rules are graded by whether any live path currently grants access.
Common False Positives
- Tables intended to be public. Landing-page/marketing content tables reachable via the anon key are by-design when a documented decision exists. Verify writes are still denied before downgrading; an intentional public READ with open WRITE is never a false positive.
- RLS enabled-but-permissive is not RLS off. A table with
rowsecurity = trueandusing (true)policies fails differently: report it as policy-quality (High/Critical by exposure), not as the missing-RLS Critical anchor. Audit policy quality as a separate finding either way. - Future-dated Firebase test-mode timers. A fresh project legitimately carries
request.time < timestamp(<future>). Confirm project age against the timestamp; flag only expired-and-open rules or prod data living under a timer. - Internal tools bound to VPN/allowlists. A public Supabase endpoint or Atlas listener behind a verified network boundary makes raw internet-exposure scoring moot. Document the boundary evidence (access-list config, screenshot) and mark not-applicable-with-evidence instead of clean.
- Hash-named preview URLs. Un-guessable preview hosts reduce discovery but are not protection (URLs leak through referrers, logs, scanners). Note reduced exposure; still recommend enforced authentication.
- Public bucket with only avatar objects and a documented decision — informational; escalate if object classes broader than avatars land there.
constructEventpresent but fed parsed JSON. The call exists, so "unsigned handler" does not apply; the finding degrades to broken-verification (often still exploitable) rather than no-verification.
References
Official documentation roots:
- supabase.com/docs — Row Level Security guides, PostgREST API keys, Storage access control, Edge Functions secrets
- firebase.google.com/docs — Firestore security rules, Realtime Database rules, Cloud Storage security, App Check, Admin SDK
- stripe.com/docs/webhooks — signature verification (
constructEvent/construct_event), test-mode CLI event triggering - vercel.com/docs — environment variable scoping, deployment protection, source maps settings
- netlify.com/docs — access control for deploys, headers and redirects configuration
- developer.wordpress.org —
wp-config.phpconstants, application passwords, roles and capabilities - neon.tech/docs — connection strings and TLS
- docs.railway.app and render.com/docs — database provisioning and networking
- docs.upstash.com — REST token authentication for Redis
- mongodb.com/docs/atlas — network access lists and connection security
OWASP references:
- owasp.org/Top10/A01_2021-Broken_Access_Control/
- cheatsheetseries.owasp.org (Authorization, Authentication, and Transaction Authorization cheat sheets)