Scroll to top

SanDssrf — SSRF Containment for Node.js

SanDssrf — the new era of SSRF defense. SSRF attempts against 169.254.169.254, 10.0.0.1, 127.0.0.1 and [::1] are contained inside a simulated internal network in a Linux namespace, while public internet traffic is unaffected.
Secure development

Project Overview

Contain. Don’t just block. The new era of SSRF defense.

SanDssrf is my answer to a question I kept running into while breaking SSRF defenses — including my own. Libraries such as DSSRF validate a URL and then let the request go; every one of them can be beaten by a parser differential, a DNS rebind or an IPv6 form nobody listed. SanDssrf stops validating and starts containing. User-supplied URLs are connected from inside a Linux network namespace that holds a simulated internal network: if an address is internal, the Linux kernel routes it to a harmless decoy, and if it is public, the request goes out as normal. There is no range check to get wrong and no race to win, so whole bypass classes simply stop existing — while your own database, cache and internal APIs keep working untouched. It is pure Node.js with zero dependencies, released under MIT and published on npm as @insitetechjp/sandssrf.

0dependencies · pure Node.js
16.7Maddresses mocked for 10.0.0.0/8 alone
Kerneldecides what is internal
MITfree & open source

Challenges

  1. A validator and the HTTP client parse the same URL differently — the parser differential class that defeats most SSRF libraries, including my own DSSRF.
  2. DNS rebinding: the answer can change between the check and the connection, and any check-then-connect design has a race to lose.
  3. Every userspace range check is one missed IPv6 form or “unrecognised, therefore allowed” branch away from a bypass.
  4. SSRF is not only HTTP — raw TCP protocols such as Redis or PostgreSQL need the same protection.
  5. Contain untrusted fetches without breaking the application’s own database, cache and internal APIs.

Approach

  1. A helper process starts in its own user + network namespace (unshare -Ur -n), where every internal range is installed as a local route with Linux AnyIP.
  2. The parent resolves the hostname exactly once; only the resulting address crosses into the sandbox, so there is no second lookup to poison.
  3. The kernel’s longest-prefix route match decides what is internal — no userspace parser, no range list to get wrong, no race to win.
  4. The connected socket is handed back over SCM_RIGHTS; a socket’s namespace travels with its file descriptor, so it can only ever reach the sandbox.
  5. Internal targets hit an inert decoy tagged x-sandssrf: mock and fire a blocked event with host, method and path; public destinations connect for real.

Drop-In in a Few Lines

Hand the sandbox Agent only to the code that fetches untrusted URLs. Alongside the http/https Agents there is sandbox.connect() for non-HTTP protocols, and TLS certificates are still verified against the hostname.

const { createSSRFSandbox } = require('@insitetechjp/sandssrf');

const sandbox = createSSRFSandbox();
const agent   = sandbox.Agent();

sandbox.on('blocked', (e) => log.warn('SSRF attempt', e));

// user-supplied URL — contained
await fetchWith(agent, userUrl);

// everything else is untouched
await pgcon.query('select 1');
npm install @insitetechjp/sandssrf

Limitations, Stated Plainly

“A security library that hides its limitations is worse than none.”

  • Linux only — it needs unshare (util-linux) and ip (iproute2), and unprivileged user namespaces.
  • Docker’s default seccomp profile may block it; if so, ready() rejects loudly with SANDSSRF_UNAVAILABLE instead of failing open.
  • Only sockets made through a sandbox Agent are contained — a direct fetch(), a subprocess or a native addon is not.
  • Public hosts are not restricted — that calls for a destination allowlist, a separate control.

SanDssrf is the design that follows DSSRF: it turns the lessons from the advisories DSSRF received — and the bypasses I found and published myself — into containment instead of validation.