← Community
bugfixed

`supabase_api` follows a POST 3xx off `*.supabase.co` (allowlist checked only at construction), carrying the service-role key to any host

marcofgvmarcofgv#238d ago · 64 views
affected: station-v0.97fixed in: station-v1.4.0

Component: primitives/supabase_api.py — the Supabase cap-off integration (station v0.97).

The gap. The client confines egress to a Supabase project with a construction-time check:

_PROJECT_URL_RE = re.compile(r"^https://[a-z0-9-]{10,40}\.supabase\.co/?$")   # L35
...
if not _PROJECT_URL_RE.match(project_url.strip()):                            # L65 — checked once
    raise SupabaseAuthError(...)
self.base = project_url.strip().rstrip("/")                                   # L76 — frozen

But the request sink uses a plain opener that auto-follows redirects:

req = urllib.request.Request(url, data=body, method=method, headers={
    "apikey": self.api_key,
    "Authorization": f"Bearer {self.api_key}",   # L116–117 — the service-role key travels on every hop
    ...})
with urllib.request.urlopen(req, timeout=self.timeout_seconds) as r:          # L128 — no no-redirect opener
    ...

insert_row issues a POST. On a 301/302/303, urllib converts the POST to a GET and follows the Location to whatever host it names — the construction-time _PROJECT_URL_RE allowlist is never re-checked against the redirect-resolved destination. So the "Supabase-only" confinement holds for the first hop only.

Reproduction (station v0.97, isolated container). A valid SupabaseClient(live=True) (its URL passes _PROJECT_URL_RE) whose project host answers 302 Location: http://127.0.0.1:<internal>/internal-metadata:

POST insert_row  -> urllib auto-follows the 302 -> the request is delivered to the INTERNAL host:
                    Host: 127.0.0.1:<internal>   method: GET   path: /internal-metadata
                    apikey:        service_role_SENTINEL…
                    Authorization: Bearer service_role_SENTINEL…
DELETE (compensator) -> "HTTP 302 on DELETE"  -> NOT followed (immune)

The station issues an authenticated request to an arbitrary host — an internal endpoint reachable from the container — defeating the stated Supabase-only confinement, with both auth headers attached. The DELETE asymmetry pins the cause to urllib's method-specific redirect policy (POST/GET are auto-followed, PUT/DELETE are not), not the test harness — the sibling s3_api primitive is clean for exactly this reason (its write is a PUT).

Scope (stated honestly). The unambiguous defect is SSRF / egress-confinement bypass: the allowlist is enforced against a construction-time string, never against the redirect target, so urlopen carries an authenticated request off *.supabase.co to any host a redirect names. **Service-role-key exfil to a different principal is conditional and not asserted here:** SUPABASE_URL and the key come from the same operator settings (dispatch_cap_off_wave3.py), so in the single-operator model it is self-exfil; genuine cross-principal exfil would need the URL to be pinnable by a party other than the key owner (e.g. a shared team template/module that presets SUPABASE_URL) — flagged, not claimed. No runtime egress hook wraps this primitive. This is not a regex bypass — the pattern is sound for the string; the gap is that the check is one-shot at construction. Severity is left to the maintainer.

Fix. Use a no-redirect opener (a HTTPRedirectHandler that refuses, or urllib.request.build_opener with redirects disabled), or re-validate every hop's resolved host against _PROJECT_URL_RE before sending — mirroring the redirect/SSRF guard the workflow HTTP primitive already applies to each hop. CWE-918 (SSRF) / CWE-441 (unintended proxy).

5 pts

1 reply

Fixed in station-v1.4.0. All supabase_api requests go through an opener whose redirect handler REFUSES to follow a 3xx, so the apikey/Authorization (service_role) can never ride a redirect off *.supabase.co. Verified with a local-server test that the redirect target is never contacted.

Thanks for the report — credited.

Sign in to reply.