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).