Skip to content

Webhook and alert URL registration accepts internal/private network targets — server-side request forgery (SSRF) #80

Description

@prodbycorne

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

  • POST /api/v1/webhooks and POST /api/v1/alerts both reject a url/webhook_url that resolves to a private, loopback, or link-local address, with a clear VALIDATION_ERROR.
  • The cloud metadata address (169.254.169.254) is explicitly rejected.
  • A hostname that resolves to a public IP at registration time but a private IP at delivery time does not result in an outbound request reaching that private IP (delivery-time re-check).
  • axios calls used for actual webhook delivery do not blindly follow redirects to disallowed targets.
  • WEBHOOK_ALLOW_PRIVATE_TARGETS=true restores current permissive behavior for local development/testing.
  • New tests cover: private IP literal rejection, metadata-address rejection, and a mocked DNS-rebinding scenario.

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

  1. Register a webhook with url: 'http://127.0.0.1:6379' — assert VALIDATION_ERROR at POST /webhooks.
  2. Register with url: 'http://169.254.169.254/latest/meta-data/' — assert rejection.
  3. Register with a decimal/hex-encoded loopback (http://2130706433/) — assert rejection (proves the check operates on resolved/normalized IPs, not naive hostname string matching).
  4. 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.
  5. 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.
  6. Set WEBHOOK_ALLOW_PRIVATE_TARGETS=true and confirm a private-IP webhook registers successfully (dev/test escape hatch works).

Related issues in this batch

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignOfficial Campaign | FWC26Campaign: Official Campaign | FWC26securitySecurity hardening and vulnerability fixesvery hardExtremely hard — deep expertise, careful design, and significant time requiredwebhooksWebhook delivery and notification

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions