Production AIGuide

SSRF Protection for AI Tools: Allowlist URL Fetches

Protect an AI URL-fetching tool with a destination allowlist, blocked redirects and repeatable tests that expose the limits of application validation.

Published: · Reviewed:

Glass spheres follow a cyan track through a rectangular gate toward a round platform, while an amber branch ends at a solid barrier.
Make the permitted destination explicit, and verify where rejected requests stop.

1. Define the destination before exposing the tool

A support assistant receives a document containing a URL and asks your server to retrieve it. If that URL becomes an unrestricted network request, the assistant has acquired authority the document should never provide. MDN describes server-side request forgery (SSRF) as requests originating from a server, potentially reaching resources unavailable to the external requester.

This guide builds a narrower capability: retrieve one plain-text document from one approved documentation service. The model proposes an absolute URL; application code decides whether to use it. The example policy permits HTTPS to docs.example.com on the default port, with a path shaped like /docs/refunds.txt. That hostname is a placeholder: replace it with a service you control and have approved before deployment. The tests do not contact it.

Prefer a document ID over a URL if your product can construct the entire destination itself. When an integration genuinely needs URLs, keep the method, headers, destination policy and limits in trusted server configuration. Do not accept them as model arguments. Authenticate the user and authorize the requested document before this handler; a matching host does not grant access to every document on that host. The broader AI Guardrails guide places this check alongside tool permissions and resource authorization.

2. Run the synthetic example

Download the complete example, extract it into an empty directory, and run the following commands. The archive contains url-tool.mjs, test.mjs and a README. No packages, API credentials or model calls are required.

node --version
node test.mjs

Verification used Windows and Node.js 24.13.0 on November 8, 2026. Node's release table lists the v24 major line as LTS. Use a maintained, patched LTS runtime in your application; the exact installed patch here records the experiment rather than a permanent version recommendation. On the same date, the newest v24 release in that table was 24.22.2, released October 27, 2026.

The harness starts two temporary HTTP servers bound to IPv4 loopback on operating-system-assigned ports. One is the approved fixture origin; the other is a prohibited redirect target. Do not copy its HTTP or loopback allowance into the production configuration.

3. Parse once and compare exact fields

The complete boundary module follows. It uses the Node 24.13.0 WHATWG URL API and sends the same parsed URL object to the client. Scheme, hostname and port must match the configured values. Credentials, queries and fragments are rejected; the path must fit the document contract. Raw whitespace and backslashes are rejected before parsing to keep the accepted input format small.

Normalization is deliberate. https://DOCS.example.com:443/docs/ok.txt is accepted because the parser normalizes the hostname and default HTTPS port. A trailing-dot hostname and docs.example.com.evil.invalid fail the exact comparison. Do not replace that comparison with a substring or suffix check. The path check is a product restriction, not an authorization system: the application still needs to decide which approved documents this user may read.

import http from 'node:http';
import https from 'node:https';

// Trusted application configuration; never accept policy from tool arguments.
export const POLICY = Object.freeze({
  protocol: 'https:', hostname: 'docs.example.com', port: '',
  maxBytes: 65_536, deadlineMs: 2_000,
});

export function validateDestination(input, policy = POLICY) {
  if (typeof input !== 'string' || input.length > 2_048 ||
      /[\s\\]/u.test(input)) throw new Error('invalid URL input');
  const url = new URL(input); // Absolute URL only; no implicit base.
  if (url.protocol !== policy.protocol || url.hostname !== policy.hostname ||
      url.port !== policy.port || url.username || url.password ||
      url.hash || url.search || !/^\/docs\/[a-z0-9-]+\.txt$/.test(url.pathname)) {
    throw new Error('destination rejected');
  }
  return url;
}

// Exported for local fixtures; configure this only from trusted server code.
export function createDocumentFetcher(configuration) {
  const policy = Object.freeze({ ...configuration });
  return async function fetchDocument(input) {
    const url = validateDestination(input, policy);
    const transport = url.protocol === 'https:' ? https : http;
    return new Promise((resolve, reject) => {
      let response;
      let timer;
      let settled = false;
      const finish = (error, value) => {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        if (error) {
          response?.destroy();
          request.destroy();
          reject(error);
        } else resolve(value);
      };
      const request = transport.request(url, {
        method: 'GET', agent: false, maxHeaderSize: 8_192,
        headers: { Accept: 'text/plain', 'Accept-Encoding': 'identity' },
      }, (res) => {
        response = res;
        res.on('error', (error) => finish(error));
        if (res.statusCode >= 300 && res.statusCode < 400) {
          return finish(new Error('redirect rejected'));
        }
        if (res.statusCode !== 200) return finish(new Error('status rejected'));
        if (!/^text\/plain(?:\s*;|$)/i.test(res.headers['content-type'] ?? '') ||
            !['identity', undefined].includes(res.headers['content-encoding'])) {
          return finish(new Error('representation rejected'));
        }
        const chunks = [];
        let bytes = 0;
        res.on('data', (chunk) => {
          bytes += chunk.length;
          if (bytes > policy.maxBytes) return finish(new Error('body too large'));
          chunks.push(chunk);
        });
        res.on('end', () => finish(null, Buffer.concat(chunks).toString('utf8')));
      });
      request.on('error', (error) => finish(error));
      request.on('upgrade', (res, socket) => {
        socket.destroy();
        finish(new Error('upgrade rejected'));
      });
      timer = setTimeout(() => finish(new Error('deadline exceeded')), policy.deadlineMs);
      request.end();
    });
  };
}

export const fetchDocument = createDocumentFetcher(POLICY);

4. Reject redirects and bound the response

For the configured HTTPS URL, the module uses https.request; its HTTP counterpart exists for the synthetic fixture. The HTTP API supplies request destruction, response status and body-stream handling. This code makes one request and does not interpret Location as another destination. Every 300–399 response produces a redirect error, including a redirect that would stay on the same host.

The handler accepts status 200 with a plain-text content type and no compression other than identity. It counts received body bytes instead of trusting Content-Length. A 65,537-byte body exceeds the 65,536-byte example cap and is rejected. A two-second application timer destroys the outstanding request, including a response that keeps arriving too slowly. The local timeout fixture uses 250 milliseconds to keep the suite quick. These are example product budgets; choose limits that fit your documents and service latency.

Do not add automatic retries, forward user cookies or copy arbitrary model-supplied headers into this request. Add separate concurrency and per-user request budgets in the surrounding application. The module limits each response, but many permitted calls can still consume resources. Returned text remains untrusted model context. If you later render generated HTML, apply the separate HTML output boundary.

5. Inspect what each test actually establishes

The first group calls the parser without making network requests. It rejects 15 inputs, including loopback IPv4 and IPv6 literals, a private-address string, a metadata-shaped string, numeric loopback, the wrong scheme or port, credentials and unexpected URL components. These strings are synthetic inputs, not probes of real internal services or cloud metadata. An accepted normalized HTTPS URL is the parser's positive control.

CheckExpected observationEvidence scope
15 rejected URL inputsParser throws before transportApplication validation
Allowed local documentExact text: synthetic documentWorking HTTP fixture path
Different local portDestination rejected; neither counter increasesPort policy on this request
302 redirect to prohibited trapRedirect rejected; zero trap requests after resetNo redirect follow in this fixture
Oversized and pending responsesBody too large; deadline exceededBody cap and application timer

The trap is first contacted directly by the harness, which asserts one hit and resets the counter. That positive control prevents a dead target from producing a reassuring zero. The tool then fetches an allowed path whose 302 points to that live target. The suite rejects the redirect and asserts that the trap remains untouched. Four requests reach the allowed origin: document, redirect, oversized body and pending response.

parser: 15 rejected; canonical HTTPS control accepted
network: positive control, denied port, redirect, byte cap, deadline passed
origin requests: 4; prohibited trap requests after reset: 0

Those lines were observed with exit code 0. If a check fails, keep the fixture expectation and inspect the actual behavior before weakening a rule. A useful failure names the boundary that changed: a legitimate document rejected, a disallowed URL accepted, a trap hit, or a response exceeding its budget. Preserve both the successful document and the live-trap control when extending the suite.

6. Enforce the actual outbound destination

A matching hostname does not establish which IP address a connection will reach. DNS can change between a separate check and the subsequent connection. A preflight lookup followed by an ordinary hostname request does not close that gap. OWASP's SSRF guidance discusses DNS pinning risks and separates application allowlisting from network controls. The parser and redirect code here implement only part of that defense.

Before deployment, require an outbound policy that permits the intended service and denies unintended destinations from the actual worker. Depending on your infrastructure, that may be an enforced egress proxy or a firewall with maintained service destinations. Cover IPv4 and IPv6 routes, the resolver path and direct connections that could bypass a proxy. Keep normal TLS certificate verification enabled for the approved hostname. If you implement connection-bound DNS validation, the checked address must be the address actually used by the socket; this download does not implement that transport.

Record deployment evidence separately: run the allowed document request from the real worker, then test an operator-owned denied destination in an isolated acceptance environment. Inspect proxy or network logs and the controlled target's request counter. Do not substitute requests to real private services or cloud metadata. This local suite did not test DNS rebinding, production TLS, IPv6 routing or network enforcement. Passing it cannot establish those properties.

Use agent sandbox isolation as another boundary when a workload also executes code. Revisit the URL policy whenever the approved service, response format, proxy routing or tool contract changes. Keep a release record containing the runtime, fixture output and separate deployment acceptance results so the next change has a concrete comparison.

FAQ

Is a hostname allowlist enough to prevent SSRF?

No. This example also rejects redirects and restricts the request contract, but deployment still needs controls over the actual outbound destination. The local tests do not verify DNS rebinding or network enforcement.

Does the redirect test contact a real internal service?

No. It uses two synthetic local HTTP servers. The prohibited target is proved live first, then its counter remains at zero during the tool checks.

Can the model change the allowed host or HTTP method?

No. The example keeps destination policy, GET method, headers and limits in trusted application code. Expose only the URL argument to the model.

What does a passing local test prove?

It records the listed parser decisions, a successful synthetic document fetch, redirect rejection, the body cap and the deadline. It does not establish production TLS, IPv6 routing or egress enforcement.

Primary sources — checked November 8, 2026