Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 23 min read

SSRF protection for webhooks: call user URLs safely

SSRF protection only becomes a topic once your application reaches out to the network on behalf of users. With webhooks, that is the normal case: an organizer enters a URL in your ticket shop, and your system sends a POS

SSRF protection for webhooks: call user URLs safely

SSRF protection only becomes a topic once your application reaches out to the network on behalf of users. With webhooks, that is the normal case: an organizer enters a URL in your ticket shop, and your system sends a POST request there every time a ticket is sold. But what if the address does not point to the internet at all, and instead targets the metadata of your own infrastructure, an admin interface in a private network, or a service that should only be reachable internally?

I care about this topic because in many webhook implementations it ends up as a footnote. My own article on webhook retries with SQS gave SSRF just one short paragraph. Today it gets the space it deserves. You will learn how the attack works, why a simple check at registration is not enough, and how to build SSRF protection in four layers with Lambda, Node.js and AWS CDK that also survives DNS tricks.

In short

When users register their own webhook URLs, someone can trick your system into calling internal addresses. Effective protection validates the URL at registration, checks every resolved IP address again right at connect time, and runs delivery in a Lambda function without a VPC. The most common mistake is checking once and then blindly following redirects.

What you will learn in this article

  • What SSRF is and what an attacker can reach with it in AWS
  • Why DNS rebinding and redirects defeat a one-time check
  • How to check every IP address at connect time in Node.js
  • What complete SSRF protection with Lambda, SQS and AWS CDK looks like

What is SSRF?

SSRF stands for server-side request forgery. An attacker gets your server to send a request to a target the attacker cannot reach directly. Your server becomes the proxy. It sits inside your network, can reach internal addresses and often enjoys a level of trust that a stranger from the internet would never get.

A classic example is an β€œimport image from URL” feature. A user enters an address, the server downloads the image. If the user enters the address of an internal service instead, the server downloads that. If the response makes it back to the user, they can read internal data. If it does not, this is called β€œblind SSRF”. That is still dangerous, because even the answer to β€œdid it work or not?” reveals which internal addresses and ports exist.

Why webhooks are a textbook case

Webhooks tick every box for SSRF. The target URL comes from the user. Calls happen automatically and repeatedly, often with retries. On top of that, many systems show in their UI whether a delivery worked and which status code the receiver returned. For an attacker, that is a convenient tool for probing your internal network from the outside.

An allowlist, meaning a fixed list of permitted targets, rarely helps with SSRF protection for webhooks. OWASP generally prefers allowlists, but describes exactly this case as the exception: with webhooks you do not know the targets in advance. So you need a good blocklist plus additional protective layers.

What an attacker can reach in AWS

How much damage SSRF can do depends on where your delivery code runs. This is the biggest lever for your SSRF protection.

EC2, ECS and containers

On EC2 instances, the Instance Metadata Service is reachable at 169.254.169.254 and, for IPv6, at fd00:ec2::254. Among other things, it hands out temporary credentials for the instance role. Containers on ECS get their credentials from a separate endpoint at 169.254.170.2. IMDSv2 makes the attack much harder, because a token has to be fetched with a PUT request first, and a webhook sender usually only sends POST. OWASP still sees IMDSv2 as an additional layer that weakens some attacks, not as a fix.

Lambda

Lambda has no metadata service. The credentials of the execution role live in environment variables inside the function, so an HTTP request cannot fetch them. What is reachable is the Runtime API, which Lambda uses to hand out events and accept responses. It runs locally in the execution environment, and its host and port are stored in AWS_LAMBDA_RUNTIME_API. A targeted request there can disrupt the invocation.

The network question matters far more. A Lambda function without a VPC attachment can reach the internet but nothing private. Once it is attached to a VPC, it can reach everything that routes and security groups allow: internal load balancers, databases, admin interfaces and, via VPN or Direct Connect, possibly even your data center. That is where SSRF does the most damage.

SSRF protection in four layers

A single check can almost always be bypassed. That is why I build SSRF protection in four layers that back each other up.

SSRF protection in four layers: URL shape, DNS check, check at connect time, network and permissions

The four layers of SSRF protection for webhooks

Layer 1: check the shape of the URL

The first layer of SSRF protection costs nothing and stops many clumsy attempts. Only https is allowed, with no credentials in the URL, no custom port and no IP literal, meaning no bare IP address instead of a hostname. On top of that, names like localhost or suffixes like .internal that never point to the public internet are rejected.

It is important to use the WHATWG URL parser for this check, which in Node.js means new URL(). It normalizes notations like https://2130706433/ or https://0x7f.1/ to 127.0.0.1. A home-grown regular expression will reliably miss such variants.

Layer 2: check DNS at registration

A hostname can point to any IP address, including 127.0.0.1. So the second layer resolves the name at registration and checks every A and AAAA record. If even one address is internal, the URL is rejected. The user gets a clear error message right away, instead of deliveries silently failing later.

Layer 3: check again at connect time

Now comes the part many people skip in their SSRF protection. Days can pass between registration and delivery. The owner of the domain can change the DNS record at any time. DNS rebinding is even faster: the name points to a harmless address on the first lookup and, thanks to a very short TTL, to an internal one on the second. If you validate with one lookup and connect using the answer of another, you have lost.

The fix is to check exactly where the connection is opened. Node.js lets you pass your own lookup function to https.request(). It resolves the name, checks every address and only hands checked addresses to the socket. The hostname is kept for SNI and certificate validation, so TLS works as usual. In addition, the sender does not follow redirects, because a 302 response pointing to http://169.254.169.254/ would otherwise be a convenient detour.

Layer 4: network and permissions

The last layer still works when there is a bug in the code. The delivery function runs without a VPC, so it cannot see any private resources. It is also only allowed to read from its queue. No database access, no other permissions. Even a successful attack would find almost nothing worth having in this function.

If you must deliver from inside a VPC, for example because partners allowlist fixed outbound IP addresses, give the sender its own subnet and its own security group. Make sure internal services never accept connections from that security group, and check whether AWS Network Firewall makes sense for outbound traffic.

The example: webhooks in a ticket shop

For the walkthrough I am once again using a made-up example. A small ticket shop sells tickets for concerts and readings. Organizers can register a webhook URL through an API and get notified about every sale. The architecture is deliberately simple:

  • POST /webhooks accepts the URL, checks shape and DNS and stores it in DynamoDB.
  • Sales events end up as delivery jobs in an SQS queue.
  • A delivery Lambda without a VPC reads the jobs, checks again at connect time and reports failures via batch item failures.
  • Anything that fails five times lands in a dead-letter queue.

🎬 Flow: register a webhook and deliver it safely – this animated diagram only runs on my site: watch it on sascha-ms.de

How to make deliveries robust with growing back-off and signatures is covered in the article on webhook retries. Today I focus entirely on protection against SSRF and keep the rest short.

SSRF protection step by step with AWS CDK

The previous part of the series used the Serverless Framework, so this time I build the infrastructure with AWS CDK. The actual SSRF protection lives in three small TypeScript files that you can just as well use with the Serverless Framework or in a container.

Prerequisites

  • Node.js 22 or newer and npm
  • An AWS account with credentials in your terminal and a CDK environment bootstrapped once (npx cdk bootstrap)
  • Basic knowledge of TypeScript and Lambda

Step 1: create the project

Create an empty project and install the dependencies. The built-in Node.js test runner is enough for the tests, and tsx runs TypeScript directly.

mkdir ticketshop-webhooks && cd ticketshop-webhooks
npm init -y
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm install -D typescript tsx esbuild @types/node @types/aws-lambda aws-cdk aws-cdk-lib constructs
mkdir -p bin lib src/ssrf src/handlers test

Add a tsconfig.json with "strict": true and a cdk.json with the entry "app": "npx tsx bin/app.ts". The project needs no further configuration.

Step 2: the blocklist

The blocklist uses net.BlockList, which has been built into Node.js since version 15. It works with real address ranges instead of string comparisons and also recognizes IPv4-mapped IPv6 addresses such as ::ffff:127.0.0.1. Everything that is not public internet is blocked. The list follows the ranges OWASP names as a minimum and adds a few special ranges such as carrier-grade NAT and NAT64.

// src/ssrf/blocked-ranges.ts
import { BlockList, isIP } from 'node:net';

// Address ranges a webhook must never reach.
// Everything that is not public internet is blocked.
const IPV4_RANGES: Array<[string, number]> = [
  ['0.0.0.0', 8], // "this network"
  ['10.0.0.0', 8], // private network (RFC 1918)
  ['100.64.0.0', 10], // carrier-grade NAT
  ['127.0.0.0', 8], // loopback, including the Lambda Runtime API
  ['169.254.0.0', 16], // link-local, including instance and task metadata
  ['172.16.0.0', 12], // private network (RFC 1918)
  ['192.0.0.0', 24], // IETF protocol assignments
  ['192.0.2.0', 24], // documentation
  ['192.168.0.0', 16], // private network (RFC 1918)
  ['198.18.0.0', 15], // benchmarking
  ['198.51.100.0', 24], // documentation
  ['203.0.113.0', 24], // documentation
  ['224.0.0.0', 4], // multicast
  ['240.0.0.0', 4], // reserved, including broadcast
];

const IPV6_RANGES: Array<[string, number]> = [
  ['::', 128], // unspecified
  ['::1', 128], // loopback
  ['64:ff9b::', 96], // NAT64, can point to internal IPv4 addresses
  ['64:ff9b:1::', 48], // local NAT64
  ['100::', 64], // discard
  ['2001::', 32], // Teredo
  ['2001:db8::', 32], // documentation
  ['2002::', 16], // 6to4
  ['fc00::', 7], // unique local addresses, including fd00:ec2::254
  ['fe80::', 10], // link-local
  ['ff00::', 8], // multicast
];

const blockList = new BlockList();
for (const [net, prefix] of IPV4_RANGES) blockList.addSubnet(net, prefix, 'ipv4');
for (const [net, prefix] of IPV6_RANGES) blockList.addSubnet(net, prefix, 'ipv6');

/** true if the address must not be contacted. Unknown formats are blocked too. */
export function isBlockedAddress(address: string): boolean {
  const version = isIP(address);
  if (version === 0) return true;
  // IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) are checked against the IPv4 rules.
  return blockList.check(address, version === 4 ? 'ipv4' : 'ipv6');
}

Note the last line of the function: anything that is not a valid IP format counts as blocked. When in doubt, reject.

Step 3: validate the URL

The second file contains layers 1 and 2. parseWebhookUrl() only checks the shape and needs no network. assertPublicHost() resolves the name and rejects as soon as a single address is internal. The resolver can be swapped out, which makes the function easy to test.

// src/ssrf/validate-url.ts
import { isIP } from 'node:net';
import { lookup, type LookupAddress } from 'node:dns';
import { isBlockedAddress } from './blocked-ranges';

export class UnsafeUrlError extends Error {
  constructor(public readonly reason: string) {
    super(`Webhook URL rejected: ${reason}`);
    this.name = 'UnsafeUrlError';
  }
}

export type Resolver = (hostname: string) => Promise<LookupAddress[]>;

const MAX_URL_LENGTH = 2048;
const INTERNAL_SUFFIXES = ['.internal', '.local', '.localhost', '.lan', '.home.arpa', '.corp'];

const resolveAll: Resolver = (hostname) =>
  new Promise((resolve, reject) => {
    lookup(hostname, { all: true }, (err, addresses) => (err ? reject(err) : resolve(addresses)));
  });

/** Checks the form of the URL. Does not do any network access. */
export function parseWebhookUrl(raw: string): URL {
  if (typeof raw !== 'string' || raw.length > MAX_URL_LENGTH) {
    throw new UnsafeUrlError('URL is missing or too long');
  }
  let url: URL;
  try {
    url = new URL(raw.trim());
  } catch {
    throw new UnsafeUrlError('not a valid URL');
  }
  if (url.protocol !== 'https:') throw new UnsafeUrlError('only https is allowed');
  if (url.username || url.password) throw new UnsafeUrlError('credentials in the URL are not allowed');
  if (url.port !== '') throw new UnsafeUrlError('only the default port 443 is allowed');

  // The URL parser normalizes 0x7f.1 or 2130706433 to 127.0.0.1 - so this check catches them too.
  const host = url.hostname.toLowerCase().replace(/\.$/, '');
  if (host.startsWith('[') || isIP(host) !== 0) {
    throw new UnsafeUrlError('IP addresses are not allowed, please use a hostname');
  }
  if (!host.includes('.') || host === 'localhost' || INTERNAL_SUFFIXES.some((s) => host.endsWith(s))) {
    throw new UnsafeUrlError('internal hostnames are not allowed');
  }
  url.hash = '';
  return url;
}

/** Resolves all A and AAAA records. A single internal address is enough to reject. */
export async function assertPublicHost(hostname: string, resolve: Resolver = resolveAll): Promise<string[]> {
  let addresses: LookupAddress[];
  try {
    addresses = await resolve(hostname);
  } catch {
    throw new UnsafeUrlError('hostname cannot be resolved');
  }
  if (addresses.length === 0) throw new UnsafeUrlError('hostname cannot be resolved');
  if (addresses.some((a) => isBlockedAddress(a.address))) {
    throw new UnsafeUrlError('hostname points to an internal address');
  }
  return addresses.map((a) => a.address);
}

/** Full check at registration: form plus DNS. */
export async function validateWebhookUrl(raw: string, resolve?: Resolver): Promise<URL> {
  const url = parseWebhookUrl(raw);
  await assertPublicHost(url.hostname, resolve);
  return url;
}

The error messages name the reason but never the resolved IP address. Otherwise an attacker could use your registration endpoint as a DNS oracle for internal names.

Step 4: secure the connection

Now for the heart of the SSRF protection. guardedLookup() replaces the normal DNS resolution. Node.js calls the function right before opening the connection, and the socket only receives addresses that were checked a moment ago. So there is no second, unchecked lookup that an attacker could bend with DNS rebinding.

// src/ssrf/safe-post.ts
import { request as httpsRequest, type RequestOptions } from 'node:https';
import type { ClientRequest, IncomingMessage } from 'node:http';
import { lookup as dnsLookup, type LookupAddress } from 'node:dns';
import { isIP, type LookupFunction } from 'node:net';
import { isBlockedAddress } from './blocked-ranges';
import { UnsafeUrlError } from './validate-url';

type RequestFn = (options: RequestOptions, callback: (res: IncomingMessage) => void) => ClientRequest;

export interface SafePostOptions {
  timeoutMs?: number;
  maxResponseBytes?: number;
  headers?: Record<string, string>;
  /** Only for tests: replace the blocking rule or the transport. */
  isBlocked?: (address: string) => boolean;
  requestFn?: RequestFn;
}

export interface SafePostResult {
  statusCode: number;
  delivered: boolean;
  redirected: boolean;
}

/**
 * DNS lookup that checks every address right before the connection is opened.
 * The socket connects only to addresses that passed the check.
 * That closes the gap DNS rebinding relies on.
 */
export function guardedLookup(isBlocked: (address: string) => boolean): LookupFunction {
  return (hostname, options, callback) => {
    dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
      if (err) return callback(err, '', 0);
      const list = addresses as unknown as LookupAddress[];
      if (list.length === 0 || list.some((a) => isBlocked(a.address))) {
        const error = new UnsafeUrlError('hostname points to an internal address') as NodeJS.ErrnoException;
        error.code = 'EBLOCKEDADDRESS';
        return callback(error, '', 0);
      }
      if (options.all) {
        // Node with autoSelectFamily asks for all addresses.
        return (callback as unknown as (e: null, a: LookupAddress[]) => void)(null, list);
      }
      return callback(null, list[0].address, list[0].family);
    });
  };
}

/** Sends JSON without following redirects, with a hard time and size limit. */
export function safePostJson(url: URL, body: unknown, options: SafePostOptions = {}): Promise<SafePostResult> {
  const {
    timeoutMs = 5000,
    maxResponseBytes = 64 * 1024,
    headers = {},
    isBlocked = isBlockedAddress,
    requestFn = httpsRequest,
  } = options;

  const hostname = url.hostname.replace(/^\[|\]$/g, '');
  // With an IP literal Node skips the lookup - so we check it here.
  if (isIP(hostname) !== 0 && isBlocked(hostname)) {
    return Promise.reject(new UnsafeUrlError('IP address is internal'));
  }

  const payload = JSON.stringify(body);
  return new Promise((resolve, reject) => {
    const req = requestFn(
      {
        method: 'POST',
        hostname,
        port: url.port || undefined,
        path: `${url.pathname}${url.search}`,
        headers: {
          'content-type': 'application/json',
          'content-length': Buffer.byteLength(payload).toString(),
          'user-agent': 'ticketshop-webhooks/1.0',
          ...headers,
        },
        lookup: guardedLookup(isBlocked),
        signal: AbortSignal.timeout(timeoutMs),
        agent: false,
      },
      (res) => {
        const statusCode = res.statusCode ?? 0;
        let received = 0;
        res.on('data', (chunk: Buffer) => {
          received += chunk.length;
          // The response is discarded anyway - we only read as much as necessary.
          if (received > maxResponseBytes) res.destroy();
        });
        const done = () =>
          resolve({
            statusCode,
            delivered: statusCode >= 200 && statusCode < 300,
            redirected: statusCode >= 300 && statusCode < 400,
          });
        res.on('end', done);
        res.on('close', done);
        res.on('error', done);
      },
    );
    req.on('error', reject);
    req.end(payload);
  });
}

A few details matter here. Since version 20, Node.js tries IPv4 and IPv6 in parallel by default and requests all addresses at once for that, which is why the function handles the options.all case. With agent: false you prevent an open connection from an earlier call from being reused. AbortSignal.timeout() limits the total duration, not just the idle time of the socket, so a deliberately slow server cannot keep your function busy.

Why not just use fetch()? The global fetch() follows redirects by default. You can switch that off with redirect: 'manual', but a DNS check at connect time requires a custom Undici dispatcher. https.request() has everything built in and does not follow redirects on its own.

Step 5: the registration Lambda

Registration accepts the organizer ID and URL, calls validateWebhookUrl() and stores only the normalized URL.

// src/handlers/register-webhook.ts
import { randomUUID } from 'node:crypto';
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
import { UnsafeUrlError, validateWebhookUrl } from '../ssrf/validate-url';

const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.WEBHOOK_TABLE!;

const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({
  statusCode,
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify(body),
});

export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {
  let input: { organizerId?: string; url?: string };
  try {
    input = JSON.parse(event.body ?? '{}');
  } catch {
    return json(400, { message: 'Body must be JSON' });
  }
  if (!input.organizerId || !input.url) {
    return json(400, { message: 'organizerId and url are required' });
  }

  try {
    const url = await validateWebhookUrl(input.url);
    const id = randomUUID();
    await db.send(
      new PutCommand({
        TableName: TABLE,
        Item: { pk: input.organizerId, sk: `WEBHOOK#${id}`, url: url.toString(), createdAt: new Date().toISOString() },
      }),
    );
    return json(201, { id, url: url.toString() });
  } catch (err) {
    if (err instanceof UnsafeUrlError) {
      // Name the reason, but never reveal resolved IP addresses.
      return json(400, { message: err.message });
    }
    throw err;
  }
};

Step 6: the delivery Lambda

The delivery checks the shape of the URL once more, because the table might contain entries from a time when the rules were looser. A blocked target is a security event and is not retried. Normal errors go back to the queue via batch item failures.

// src/handlers/deliver-webhook.ts
import type { SQSBatchResponse, SQSEvent } from 'aws-lambda';
import { parseWebhookUrl, UnsafeUrlError } from '../ssrf/validate-url';
import { safePostJson } from '../ssrf/safe-post';

interface DeliveryJob {
  webhookId: string;
  url: string;
  event: { type: string; data: unknown };
}

export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
  const batchItemFailures: SQSBatchResponse['batchItemFailures'] = [];

  for (const record of event.Records) {
    const job = JSON.parse(record.body) as DeliveryJob;
    try {
      // Check the form again: the database could contain old entries.
      const url = parseWebhookUrl(job.url);
      const result = await safePostJson(url, job.event, { timeoutMs: 5000 });

      if (result.delivered) continue;
      if (result.redirected) {
        // Redirects are not followed - retrying makes no sense.
        console.warn(JSON.stringify({ msg: 'webhook redirect ignored', webhookId: job.webhookId, status: result.statusCode }));
        continue;
      }
      console.warn(JSON.stringify({ msg: 'webhook failed', webhookId: job.webhookId, status: result.statusCode }));
      batchItemFailures.push({ itemIdentifier: record.messageId });
    } catch (err) {
      if (err instanceof UnsafeUrlError) {
        // Security event: log it, do not retry.
        console.error(JSON.stringify({ msg: 'webhook blocked', webhookId: job.webhookId, reason: err.reason }));
        continue;
      }
      console.warn(JSON.stringify({ msg: 'webhook error', webhookId: job.webhookId, error: (err as Error).name }));
      batchItemFailures.push({ itemIdentifier: record.messageId });
    }
  }
  return { batchItemFailures };
};

Step 7: the stack with AWS CDK

The stack ties everything together. Pay attention to the delivery function: no VPC, no environment variables with secrets, only the permissions that SqsEventSource grants for reading the queue. Reserved concurrency of ten makes sure a batch of slow receivers cannot eat up your whole account quota.

// lib/webhook-guard-stack.ts
import { Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
import type { Construct } from 'constructs';
import { AttributeType, Billing, TableV2 } from 'aws-cdk-lib/aws-dynamodb';
import { Queue } from 'aws-cdk-lib/aws-sqs';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';
import { HttpApi, HttpMethod } from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';

export class WebhookGuardStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const table = new TableV2(this, 'Webhooks', {
      partitionKey: { name: 'pk', type: AttributeType.STRING },
      sortKey: { name: 'sk', type: AttributeType.STRING },
      billing: Billing.onDemand(),
      removalPolicy: RemovalPolicy.DESTROY, // demo only
    });

    const dlq = new Queue(this, 'DeliveryDlq', { retentionPeriod: Duration.days(14) });
    const deliveryQueue = new Queue(this, 'DeliveryQueue', {
      visibilityTimeout: Duration.seconds(60),
      deadLetterQueue: { queue: dlq, maxReceiveCount: 5 },
    });

    const common = {
      runtime: Runtime.NODEJS_22_X,
      memorySize: 256,
      bundling: { minify: true, sourceMap: true },
    };

    const register = new NodejsFunction(this, 'RegisterWebhook', {
      ...common,
      entry: 'src/handlers/register-webhook.ts',
      timeout: Duration.seconds(10),
      environment: { WEBHOOK_TABLE: table.tableName },
    });
    table.grantWriteData(register);

    // Deliberately no VPC: the delivery function cannot see any private resources.
    // It only needs to read from the queue - no database, no other permissions.
    const deliver = new NodejsFunction(this, 'DeliverWebhook', {
      ...common,
      entry: 'src/handlers/deliver-webhook.ts',
      timeout: Duration.seconds(30),
      reservedConcurrentExecutions: 10,
    });
    deliver.addEventSource(new SqsEventSource(deliveryQueue, { batchSize: 5, reportBatchItemFailures: true }));

    const api = new HttpApi(this, 'WebhookApi');
    api.addRoutes({
      path: '/webhooks',
      methods: [HttpMethod.POST],
      integration: new HttpLambdaIntegration('RegisterIntegration', register),
    });
  }
}
// bin/app.ts
import { App } from 'aws-cdk-lib';
import { WebhookGuardStack } from '../lib/webhook-guard-stack';

const app = new App();
new WebhookGuardStack(app, 'WebhookGuardStack');

Step 8: test locally

The tests run without an AWS account. A local HTTP server plays the receiver. Because localhost resolves to a loopback address, the sender must refuse the connection. For the redirect and timeout tests, the blocking rule is deliberately swapped out.

// test/ssrf.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, request as httpRequest, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { isBlockedAddress } from '../src/ssrf/blocked-ranges';
import { assertPublicHost, parseWebhookUrl, UnsafeUrlError } from '../src/ssrf/validate-url';
import { safePostJson } from '../src/ssrf/safe-post';

test('blocks internal addresses', () => {
  for (const ip of ['127.0.0.1', '10.1.2.3', '172.20.0.1', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '::1', 'fd00:ec2::254', 'fe80::1', '::ffff:127.0.0.1', '::ffff:a9fe:a9fe', '64:ff9b::a9fe:a9fe']) {
    assert.equal(isBlockedAddress(ip), true, ip);
  }
});

test('allows public addresses', () => {
  for (const ip of ['1.1.1.1', '8.8.8.8', '2606:4700:4700::1111']) {
    assert.equal(isBlockedAddress(ip), false, ip);
  }
});

test('rejects dangerous URL forms', () => {
  const bad = [
    'http://tickets.example.com/hook',
    'https://user:[email protected]/hook',
    'https://tickets.example.com:8443/hook',
    'https://127.0.0.1/hook',
    'https://2130706433/hook',
    'https://0x7f.1/hook',
    'https://[::1]/hook',
    'https://localhost/hook',
    'https://localhost./hook',
    'https://metadata.google.internal/hook',
    'https://intranet/hook',
    'file:///etc/passwd',
  ];
  for (const url of bad) {
    assert.throws(() => parseWebhookUrl(url), UnsafeUrlError, url);
  }
});

test('accepts a normal https URL', () => {
  assert.equal(parseWebhookUrl('https://hooks.example.com/tickets?x=1#a').toString(), 'https://hooks.example.com/tickets?x=1');
});

test('rejects a hostname with a single internal address', async () => {
  const fakeDns = async () => [
    { address: '93.184.215.14', family: 4 },
    { address: '10.0.0.5', family: 4 },
  ];
  await assert.rejects(assertPublicHost('mixed.example.com', fakeDns), /internal address/);
});

async function startServer(handler: Parameters<typeof createServer>[1]): Promise<Server> {
  const server = createServer(handler);
  await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
  return server;
}
const urlFor = (s: Server, path = '/hook') => new URL(`http://localhost:${(s.address() as AddressInfo).port}${path}`);

test('blocks the connection when the DNS answer is internal', async () => {
  let hits = 0;
  const server = await startServer((_req, res) => {
    hits++;
    res.end('ok');
  });
  try {
    await assert.rejects(safePostJson(urlFor(server), { hello: 'world' }, { requestFn: httpRequest }), UnsafeUrlError);
    assert.equal(hits, 0);
  } finally {
    server.close();
  }
});

test('does not follow redirects', async () => {
  const server = await startServer((req, res) => {
    if (req.url === '/hook') {
      res.writeHead(302, { location: 'http://169.254.169.254/latest/meta-data/' }).end();
    } else {
      res.end('should never be reached');
    }
  });
  try {
    const result = await safePostJson(urlFor(server), {}, { requestFn: httpRequest, isBlocked: () => false });
    assert.deepEqual(result, { statusCode: 302, delivered: false, redirected: true });
  } finally {
    server.close();
  }
});

test('aborts slow servers', async () => {
  const server = await startServer(() => {
    /* never respond */
  });
  try {
    await assert.rejects(
      safePostJson(urlFor(server), {}, { requestFn: httpRequest, isBlocked: () => false, timeoutMs: 300 }),
      /aborted|timeout/i,
    );
  } finally {
    server.closeAllConnections();
    server.close();
  }
});
npx tsc --noEmit
node --import tsx --test test/ssrf.test.ts

Expected result: eight passing tests. The most telling one is β€œblocks the connection when the DNS answer is internal”. The local server counts incoming requests, and the counter stays at zero.

Step 9: deploy and try it out

npx cdk synth
npx cdk deploy

After the deployment, CDK prints the URL of the HTTP API. Try an allowed entry first and then a dangerous one:

API=https://abc123.execute-api.eu-central-1.amazonaws.com
curl -s -X POST "$API/webhooks" -d '{"organizerId":"riverside-hall","url":"https://example.com/tickets"}'
curl -s -X POST "$API/webhooks" -d '{"organizerId":"riverside-hall","url":"https://169.254.169.254/latest/meta-data/"}'

The first call returns 201 with an ID, the second 400 with the message that IP addresses are not allowed. When you are done, clean up with npx cdk destroy.

Common SSRF protection mistakes

Checking only at registration

The classic. The URL is validated carefully when it is saved, and afterwards the sender calls it unchecked for years. DNS records change, domains change owners. Only the check at connect time really protects you.

Following redirects automatically

HTTP libraries often follow redirects by default, fetch() included. The receiver simply answers with a redirect to an internal address, and your careful validation of the original URL is worthless. Treat redirects as failures for webhooks.

Comparing addresses as strings

A pattern like startsWith('10.') catches neither ::ffff:10.0.0.1 nor the octal or decimal notation. Work with real address ranges, using net.BlockList or an established library such as ipaddr.js.

Forgetting IPv6

Many blocklists only contain IPv4 ranges. Yet the EC2 metadata service also has an IPv6 address, and NAT64 addresses can point to internal IPv4 targets. The more of your environment speaks IPv6, the more this matters.

Revealing too much

If you show organizers the exact error text, the response time or the response body in a UI, you are handing an attacker a port scanner. For the UI, β€œdelivered” or β€œfailed” with the HTTP status code is enough. Details belong in your logs.

Costs and limits

SSRF protection costs practically nothing. The DNS check at registration takes a few milliseconds, and the check at connect time only replaces the normal resolution and adds no extra lookup. The costs of the example come from Lambda, SQS, DynamoDB and the HTTP API, and at test volumes they are negligible.

There is a limit, though. A blocklist does not protect you from targets that are publicly reachable but belong to you or your partners and trust requests from your network in particular. You should know about such exceptions in firewalls or IP allowlists. And if your delivery function ever has to move into a VPC, layer 4 becomes the most important one of all.

My take

I consider SSRF protection for webhooks mandatory. The effort is manageable: three small files, a few tests and a conscious decision against the VPC. What bothers me about many implementations is the false sense of security from a one-time check. It feels thorough and protects against exactly those attackers who were never a threat anyway.

To be honest, layer 4 is my favorite part of SSRF protection. Code has bugs, mine included. A function that can reach nothing and is allowed nothing forgives those bugs. For me, that is the core of Serverless thinking in security: small functions with exactly the permissions they need, and not a bit more.

FAQ

What does SSRF mean?

SSRF stands for server-side request forgery. An attacker gets a server to send requests to targets the attacker cannot reach, for example internal services or cloud metadata endpoints.

Does IMDSv2 protect against SSRF?

Partly. IMDSv2 requires a token obtained with a PUT request, which a typical SSRF attack cannot fetch. It does not protect other internal targets, so it is only an additional layer.

Is an allowlist enough for webhooks?

Only if you know all receivers in advance, for example with a few fixed partners. For freely entered URLs you need a blocklist for internal ranges plus a check at connect time.

Is a Lambda function without a VPC safe from SSRF?

It cannot reach private resources in your VPCs, which lowers the risk a lot. Local targets such as the Runtime API and public services with IP allowlists remain reachable, though. You still need the check in your code.

Can I use fetch() in Node.js for webhooks?

Yes, with redirect: 'manual' and a custom Undici dispatcher that checks addresses at connect time. With https.request() and your own lookup function, you get there without an extra dependency.

Your next step

For good SSRF protection, start by searching your code for every place where a URL from user input gets called. Webhooks are the obvious case, but image imports, link previews, PDF generators and AI agents with web tools belong on the list too. For each place, check whether the address is validated at connect time and whether the code runs inside a VPC. Which of these places were not on your radar so far? Let me know in the comments.

If you want to secure the other side as well, read receiving webhooks with Lambda. You can find more articles about Lambda and friends on the blog.

Continue the series: Go to the β€œServerless patterns in practice” overview Β· Previous part: Claim check pattern: large messages with S3 and SQS Β· Next part: DynamoDB optimistic locking: version numbers with Lambda

Sources

This post was created with AI assistance.

πŸ‘‰ Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.

πŸ“¬ Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.