Overview
Both webhook subscription URLs and price-alert webhook URLs are validated only for well-formed http(s) syntax, never for the network location they point to:
// src/routes/webhooks.js
function isValidUrl(str) {
try {
const u = new URL(str);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
// src/routes/alerts.js — identical check, duplicated
function isValidUrl(str) {
try {
const u = new URL(str);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
Any authenticated caller (webhook registration requires no scope beyond the general rate limit; alert creation requires only a valid API key with no specific scope check) can register http://127.0.0.1:<port>/..., http://169.254.169.254/latest/meta-data/... (the cloud-provider instance metadata endpoint — a canonical SSRF target for credential theft), http://10.x.x.x/..., http://localhost:6379/ (the app's own Redis!), or any hostname that currently resolves to a public IP but is switched to an internal one after registration (classic DNS-rebinding). The system will then, on its own initiative, make outbound HTTP requests to that address:
webhookDispatcher.js's postOnce() (via axios.post) whenever a pool.* event is dispatched or /webhooks/:id/test is invoked.
services/webhook.js's sendSignedRequest() whenever a price.alert fires.
Both use axios with default redirect-following behavior and no destination restriction, from inside the backend's network — a textbook SSRF primitive that can be used to probe/attack internal infrastructure (the Redis instance, the Postgres instance referenced by DATABASE_URL, any cloud metadata service, other internal services on the Docker network) using the backend server as a relay, and to exfiltrate response data (webhook delivery logs already surface response_status and duration_ms back to the caller via GET /webhooks/:id/deliveries).
Requirements
- Implement a shared URL-safety validator used by both
routes/webhooks.js and routes/alerts.js (currently duplicated logic — consolidate as part of this fix) that:
- Resolves the hostname via DNS at validation time and rejects any resolved IP in RFC 1918 private ranges (
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), loopback (127.0.0.0/8, ::1), link-local (169.254.0.0/16, including the cloud metadata address explicitly), and any other non-globally-routable range (see IANA special-purpose registry).
- Re-validates the resolved address at delivery time, not just at registration time, to close the DNS-rebinding gap (a hostname that resolved to a public IP during registration can be repointed to an internal IP before the first delivery).
- Disables automatic redirect following in the
axios calls used for actual delivery (webhookDispatcher.js's postOnce, services/webhook.js's sendSignedRequest), or validates every redirect target against the same rules before following it.
- Add a
WEBHOOK_ALLOW_PRIVATE_TARGETS env var (default false) to allow this check to be disabled in local/dev/testing environments where webhook targets are intentionally internal (e.g. docker-compose test receivers).
Acceptance Criteria
Additional Notes
Additional edge cases / failure modes
- IPv4-mapped IPv6 addresses (
::ffff:127.0.0.1), IPv4 in decimal/octal/hex form (0x7f000001, 017700000001, 2130706433 — all equivalent to 127.0.0.1 and accepted by many URL/IP parsers including Node's net.isIP in some forms), and shortened loopback forms (http://127.1/, http://0/) are classic SSRF-filter bypasses — the "resolve hostname, check against RFC 1918/loopback/link-local ranges" approach must operate on the fully resolved and normalized IP address, not on a naive string/regex match against the hostname as typed, or many of these bypass a range-check implemented against the literal input string rather than the resolved net.isIP-normalized address.
0.0.0.0 (binds to "any interface," often reachable as loopback on the same host) and IPv6 unique local addresses (fc00::/7) should be included in the blocked-ranges list alongside the ones explicitly named in the Requirements (RFC 1918, loopback, link-local) — the Requirements list is a good starting point but not exhaustive of the IANA special-purpose registry it references; enumerate the full list explicitly in the shared validator rather than hand-picking a subset.
- The delivery-time re-check (closing the DNS-rebinding gap) has a TOCTOU problem of its own if implemented naively as "resolve, check, then let
axios resolve again and connect" — DNS could change between the validator's resolution and axios's own connection. The robust fix is to resolve the hostname once at delivery time, validate that specific IP, and then force the HTTP client to connect to that exact validated IP (e.g. via an axios lookup override or a custom https.Agent/http.Agent with a pinned resolver) rather than re-resolving via DNS a second time inside axios — otherwise the "recheck" and the "connect" are still two separate DNS lookups with a race between them, just a smaller window than not rechecking at all.
webhook.js's sendSignedRequest() (used for price.alert deliveries) and webhookDispatcher.js's postOnce() are two independent call sites making outbound requests — both need the shared validator applied at delivery time, not just one; audit for any other outbound-request call site (e.g. /webhooks/:id/test's dispatcher.sendTest() reuses deliverToWebhook, so it should already be covered if the fix is applied at the postOnce/sendSignedRequest layer rather than only at the route-registration layer — confirm this explicitly since it's easy to fix only the two route validators and miss that the actual HTTP call sites are the real enforcement point per the delivery-time requirement).
- Redirect handling: disabling
axios's automatic redirect following (maxRedirects: 0) means a 3xx response from a legitimate webhook target now needs explicit handling — decide whether a redirect response should be treated as a delivery failure (simplest, safest) or whether the dispatcher should manually follow one validated redirect at a time; the Requirements allow either "disable redirects" or "validate every redirect target," but the simpler and safer choice (treat any redirect as non-2xx, let normal retry/backoff logic in shouldRetry() decide whether it's retryable) should probably be the default given shouldRetry() already treats non-2xx/3xx status codes reasonably.
Implementation sketch
// src/utils/ssrfGuard.js (new shared module)
const dns = require('dns').promises;
const net = require('net');
const ipaddr = require('ipaddr.js'); // or hand-rolled range checks
async function resolveAndValidate(hostname) {
const addresses = await dns.lookup(hostname, { all: true });
for (const { address } of addresses) {
const addr = ipaddr.parse(address);
if (addr.range() !== 'unicast') { // ipaddr.js range(): 'private', 'loopback', 'linkLocal', 'uniqueLocal', etc.
throw new Error(`Resolved address ${address} for ${hostname} is not publicly routable (${addr.range()})`);
}
}
return addresses[0].address; // the specific IP to pin the connection to
}
async function isSafeWebhookUrl(str, { allowPrivate = config.webhooks.allowPrivateTargets } = {}) {
if (allowPrivate) return true;
try {
const u = new URL(str);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
await resolveAndValidate(u.hostname);
return true;
} catch {
return false;
}
}
module.exports = { isSafeWebhookUrl, resolveAndValidate };
Use resolveAndValidate() again inside postOnce()/sendSignedRequest() immediately before each delivery attempt, and pin the axios request to the validated IP (e.g. via a custom lookup function passed to axios's underlying http/https agent) plus maxRedirects: 0.
Test / reproduction plan
- Register a webhook with
url: 'http://127.0.0.1:6379' — assert VALIDATION_ERROR at POST /webhooks.
- Register with
url: 'http://169.254.169.254/latest/meta-data/' — assert rejection.
- Register with a decimal/hex-encoded loopback (
http://2130706433/) — assert rejection (proves the check operates on resolved/normalized IPs, not naive hostname string matching).
- Mock DNS resolution to return a public IP at registration time and a private IP at "delivery" time (two separate
dns.lookup mock calls with different results); assert the actual axios.post in postOnce/sendSignedRequest is never invoked against the private IP.
- Mock a webhook target responding with a
302 redirect to http://127.0.0.1/; assert the delivery does not follow it to the internal address.
- Set
WEBHOOK_ALLOW_PRIVATE_TARGETS=true and confirm a private-IP webhook registers successfully (dev/test escape hatch works).
Related issues in this batch
Overview
Both webhook subscription URLs and price-alert webhook URLs are validated only for well-formed
http(s)syntax, never for the network location they point to:Any authenticated caller (webhook registration requires no scope beyond the general rate limit; alert creation requires only a valid API key with no specific scope check) can register
http://127.0.0.1:<port>/...,http://169.254.169.254/latest/meta-data/...(the cloud-provider instance metadata endpoint — a canonical SSRF target for credential theft),http://10.x.x.x/...,http://localhost:6379/(the app's own Redis!), or any hostname that currently resolves to a public IP but is switched to an internal one after registration (classic DNS-rebinding). The system will then, on its own initiative, make outbound HTTP requests to that address:webhookDispatcher.js'spostOnce()(viaaxios.post) whenever apool.*event is dispatched or/webhooks/:id/testis invoked.services/webhook.js'ssendSignedRequest()whenever aprice.alertfires.Both use
axioswith default redirect-following behavior and no destination restriction, from inside the backend's network — a textbook SSRF primitive that can be used to probe/attack internal infrastructure (the Redis instance, the Postgres instance referenced byDATABASE_URL, any cloud metadata service, other internal services on the Docker network) using the backend server as a relay, and to exfiltrate response data (webhook delivery logs already surfaceresponse_statusandduration_msback to the caller viaGET /webhooks/:id/deliveries).Requirements
routes/webhooks.jsandroutes/alerts.js(currently duplicated logic — consolidate as part of this fix) that:10.0.0.0/8,172.16.0.0/12,192.168.0.0/16), loopback (127.0.0.0/8,::1), link-local (169.254.0.0/16, including the cloud metadata address explicitly), and any other non-globally-routable range (see IANA special-purpose registry).axioscalls used for actual delivery (webhookDispatcher.js'spostOnce,services/webhook.js'ssendSignedRequest), or validates every redirect target against the same rules before following it.WEBHOOK_ALLOW_PRIVATE_TARGETSenv var (defaultfalse) to allow this check to be disabled in local/dev/testing environments where webhook targets are intentionally internal (e.g.docker-composetest receivers).Acceptance Criteria
POST /api/v1/webhooksandPOST /api/v1/alertsboth reject aurl/webhook_urlthat resolves to a private, loopback, or link-local address, with a clearVALIDATION_ERROR.169.254.169.254) is explicitly rejected.axioscalls used for actual webhook delivery do not blindly follow redirects to disallowed targets.WEBHOOK_ALLOW_PRIVATE_TARGETS=truerestores current permissive behavior for local development/testing.Additional Notes
Additional edge cases / failure modes
::ffff:127.0.0.1), IPv4 in decimal/octal/hex form (0x7f000001,017700000001,2130706433— all equivalent to127.0.0.1and accepted by many URL/IP parsers including Node'snet.isIPin some forms), and shortened loopback forms (http://127.1/,http://0/) are classic SSRF-filter bypasses — the "resolve hostname, check against RFC 1918/loopback/link-local ranges" approach must operate on the fully resolved and normalized IP address, not on a naive string/regex match against the hostname as typed, or many of these bypass a range-check implemented against the literal input string rather than the resolvednet.isIP-normalized address.0.0.0.0(binds to "any interface," often reachable as loopback on the same host) and IPv6 unique local addresses (fc00::/7) should be included in the blocked-ranges list alongside the ones explicitly named in the Requirements (RFC 1918, loopback, link-local) — the Requirements list is a good starting point but not exhaustive of the IANA special-purpose registry it references; enumerate the full list explicitly in the shared validator rather than hand-picking a subset.axiosresolve again and connect" — DNS could change between the validator's resolution and axios's own connection. The robust fix is to resolve the hostname once at delivery time, validate that specific IP, and then force the HTTP client to connect to that exact validated IP (e.g. via an axioslookupoverride or a customhttps.Agent/http.Agentwith a pinned resolver) rather than re-resolving via DNS a second time inside axios — otherwise the "recheck" and the "connect" are still two separate DNS lookups with a race between them, just a smaller window than not rechecking at all.webhook.js'ssendSignedRequest()(used forprice.alertdeliveries) andwebhookDispatcher.js'spostOnce()are two independent call sites making outbound requests — both need the shared validator applied at delivery time, not just one; audit for any other outbound-request call site (e.g./webhooks/:id/test'sdispatcher.sendTest()reusesdeliverToWebhook, so it should already be covered if the fix is applied at thepostOnce/sendSignedRequestlayer rather than only at the route-registration layer — confirm this explicitly since it's easy to fix only the two route validators and miss that the actual HTTP call sites are the real enforcement point per the delivery-time requirement).axios's automatic redirect following (maxRedirects: 0) means a3xxresponse from a legitimate webhook target now needs explicit handling — decide whether a redirect response should be treated as a delivery failure (simplest, safest) or whether the dispatcher should manually follow one validated redirect at a time; the Requirements allow either "disable redirects" or "validate every redirect target," but the simpler and safer choice (treat any redirect as non-2xx, let normal retry/backoff logic inshouldRetry()decide whether it's retryable) should probably be the default givenshouldRetry()already treats non-2xx/3xx status codes reasonably.Implementation sketch
Use
resolveAndValidate()again insidepostOnce()/sendSignedRequest()immediately before each delivery attempt, and pin the axios request to the validated IP (e.g. via a customlookupfunction passed to axios's underlyinghttp/httpsagent) plusmaxRedirects: 0.Test / reproduction plan
url: 'http://127.0.0.1:6379'— assertVALIDATION_ERRORatPOST /webhooks.url: 'http://169.254.169.254/latest/meta-data/'— assert rejection.http://2130706433/) — assert rejection (proves the check operates on resolved/normalized IPs, not naive hostname string matching).dns.lookupmock calls with different results); assert the actualaxios.postinpostOnce/sendSignedRequestis never invoked against the private IP.302redirect tohttp://127.0.0.1/; assert the delivery does not follow it to the internal address.WEBHOOK_ALLOW_PRIVATE_TARGETS=trueand confirm a private-IP webhook registers successfully (dev/test escape hatch works).Related issues in this batch
postOnce()/sendSignedRequest()layer should also close the abuse vector Webhook test endpoint can be abused as a blind SSRF timing/status oracle to probe internal network services #96 describes for/webhooks/:id/test, since that endpoint reusesdispatcher.sendTest()→deliverToWebhook()→ the same delivery path. Worth cross-referencing explicitly in the PR since fixing this issue likely resolves or substantially narrows Webhook test endpoint can be abused as a blind SSRF timing/status oracle to probe internal network services #96 as a side effect.Promise.allmasking partial failures) — an SSRF-rejected delivery (at delivery-time recheck) should surface as a distinct, clearly-labeled failure reason in whatever structured per-target result webhookDispatcher.dispatch() uses Promise.all — one failing target can obscure delivery outcome for every other subscriber #77 introduces, not just a generic network error.