---
title: 'Server-Side Request Forgery'
source: 'https://academia.sh/en/courses/secure-coding/server-side-request-forgery'
course: 'Secure Coding'
language: en
updated: '2026-09-26T03:02:39+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Classify outbound request targets against private address blocks using address arithmetic'
    - 'Compare the number of targets reaching the internal network and the legitimate targets blocked across four constraint forms'
    - 'Measure the effect of the point where the constraint is applied against redirection and re-resolution'
---

# Server-Side Request Forgery

Passing outbound request targets through a set of constraints: twelve targets are passed through four constraint forms and how many can reach the internal network is counted with real address arithmetic, how many requests a redirect chain delivers to an internal address when the constraint is applied only at the first step is measured, and the point that repeating name resolution between the check and the connection opens is shown.

The previous lesson counted the bytes the server received in the upload flow; there, the
server only stored them. In some flows the server goes further and **itself** makes a
request to an address that comes from outside: the report generator fetches a document from
the link the subscriber portal provides, and the field application gets a map tile from an
external endpoint.

In the earlier measured form of request forgery, the request was sent by the user's browser
and carried the user's identity. Here, the server itself sends the request, and the
privilege it carries is not identity but **its place on the network**: the server is inside
the internal network, the client is not. This lesson measures that difference in terms of
target count.

## Address Arithmetic

Whether a target reaches the internal network is not determined by the name, it is
determined by the address. The address is a thirty-two-bit number; whether it falls into a
block is an AND operation with a mask. The block below writes this arithmetic and builds a
fictional name resolver — the network is never touched.

**IO13 (assumption):** name resolution is a model; no real query is made, and the names in
the table resolve to ranges reserved for documentation. What is measured is not the accuracy
of the resolution, but **which value the constraint is applied to**.

```js
// address.mjs — address arithmetic and a fictional name resolver. No network access, all model.
export const parse = (a) => {
  const p = a.split(".");
  if (p.length !== 4) return null;
  const s = p.map((x) => (/^\d{1,3}$/.test(x) ? Number(x) : -1));
  return s.every((n) => n >= 0 && n <= 255)
    ? (((s[0] << 24) >>> 0) + (s[1] << 16) + (s[2] << 8) + s[3]) >>> 0 : null;
};
export const block = (c) => {
  const [base, length] = c.split("/");
  const m = length === "0" ? 0 : (0xffffffff << (32 - Number(length))) >>> 0;
  return [(parse(base) & m) >>> 0, m, c];
};
export const inRange = (addr, [base, m]) => ((addr & m) >>> 0) === base;

// Address blocks reserved for local and private use.
export const PRIVATE = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
  "169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16"].map(block);
export const isPrivate = (addr) => addr !== null && PRIVATE.some((b) => inRange(addr, b));

// Fictional name resolver: name -> address. No real resolution is performed.
export const RESOLVE = {
  "document.ext-1.example": "203.0.113.24", "tile.ext-2.example": "198.51.100.9",
  "archive.ext-1.example": "203.0.113.40", "archive-2.ext-1.example": "203.0.113.41",
  "gateway.ext-3.example": "192.168.30.5",
  "report.internal": "10.12.4.7", "measurement-store.internal": "10.12.4.20", "local.internal": "127.0.0.1",
  "edge.internal": "169.254.10.5", "meter.internal": "172.20.8.3",
};
export const resolve = (a) => parse(RESOLVE[a] ?? a);

// Outbound request targets (fictional): the addresses fetched by the report generator and the field application.
export const TARGET = [
  ["document.ext-1.example", "declared"], ["tile.ext-2.example", "declared"],
  ["archive.ext-1.example", "user"], ["gateway.ext-3.example", "user"],
  ["report.internal", "user"], ["measurement-store.internal", "user"], ["local.internal", "user"],
  ["edge.internal", "user"], ["meter.internal", "user"], ["203.0.113.99", "user"],
  ["10.12.4.9", "user"], ["127.0.0.1", "user"],
];
export const DECLARED = ["document.ext-1.example", "tile.ext-2.example", "archive.ext-1.example"];
```

Three of the targets are declared external hosts; nine come from user data, and one is both.
This split is
the starting point of the coverage: the set the constraint has to close is nine items, three
items are already inside the contract.

## Four Constraint Forms

The same target set is passed through four constraints. With no constraint, with a denylist
that looks at the name, with a denylist that looks at the resolved address, and with an
allowlist that counts declared hosts.

```js
// constraint.mjs — the same target set passed through four constraint forms
import { TARGET, DECLARED, PRIVATE, resolve, isPrivate, block, inRange, parse } from "./address.mjs";

const CONSTRAINT = [
  ["no constraint", () => true],
  ["name-based denylist", (h) => !h.endsWith(".internal")],
  ["address-based denylist", (h) => !isPrivate(resolve(h))],
  ["allowlist (declared host)", (h) => DECLARED.includes(h)],
];

console.log(`${TARGET.length} outbound request targets, ${PRIVATE.length} private address blocks`);
console.log(`declared external hosts: ${DECLARED.length}, targets from user data: ` +
  `${TARGET.filter(([, k]) => k === "user").length}\n`);
console.log(`${"constraint form".padEnd(27)}${"passed".padStart(8)}${"reaching internal network".padStart(27)}${"declared blocked".padStart(18)}`);
for (const [name, f] of CONSTRAINT) {
  const passed = TARGET.filter(([h]) => f(h));
  const internal = passed.filter(([h]) => isPrivate(resolve(h))).length;
  const blocked = DECLARED.filter((h) => !f(h)).length;
  console.log(name.padEnd(27) + String(passed.length).padStart(8) + String(internal).padStart(27) +
    String(blocked).padStart(18));
}

// The address arithmetic really runs: which target falls into which block.
console.log("\ntargets falling into a private block:");
for (const [h] of TARGET) {
  const a = resolve(h);
  const b = PRIVATE.find((x) => a !== null && inRange(a, x));
  if (b) console.log(`  ${h.padEnd(28)} -> ${String(a >>> 0).padStart(10)} block ${b[2]}`);
}
console.log(`\n10.12.4.7 -> ${parse("10.12.4.7")}, 10.0.0.0/8 base ${block("10.0.0.0/8")[0]}, ` +
  `mask ${block("10.0.0.0/8")[1] >>> 0}`);
```

```
12 outbound request targets, 7 private address blocks
declared external hosts: 3, targets from user data: 10

constraint form              passed  reaching internal network  declared blocked
no constraint                    12                          8                 0
name-based denylist               7                          3                 0
address-based denylist            4                          0                 0
allowlist (declared host)         3                          0                 0

targets falling into a private block:
  gateway.ext-3.example        -> 3232243205 block 192.168.0.0/16
  report.internal              ->  168559623 block 10.0.0.0/8
  measurement-store.internal   ->  168559636 block 10.0.0.0/8
  local.internal               -> 2130706433 block 127.0.0.0/8
  edge.internal                -> 2851998213 block 169.254.0.0/16
  meter.internal               -> 2886993923 block 172.16.0.0/12
  10.12.4.9                    ->  168559625 block 10.0.0.0/8
  127.0.0.1                    -> 2130706433 block 127.0.0.0/8

10.12.4.7 -> 168559623, 10.0.0.0/8 base 167772160, mask 4278190080
```

With no constraint, eight of the twelve targets reach the internal network. The name-based
denylist brings this down to three and **still lets three targets through** — because it
looks at the name, not the address. One of the ones that passes carries an externally
looking name and resolves to an internal address; two are already written as numbers as
internal addresses and are not caught by the denylist because their names do not end in
`.internal`.

The address-based denylist zeroes out the target reaching the internal network. The
sub-listing shows how: each target is converted to a thirty-two-bit number, and which of the
seven blocks it falls into is computed. Eight of the eight targets fall into a block, and
none of them is found by looking at the name.

The allowlist also gives zero, but by a different route: it rejects nine of the ten
user-sourced targets. The coverage of the two constraints is the same, the form of the
coverage is not. A denylist stays open when a block that is not listed shows up; an
allowlist also stops a legitimate new external host when one is added. The cost of both
appears as zero in this table because the three declared hosts pass through both.

## The Point Where the Constraint Is Applied

**When** the constraint is applied determines the coverage as much as which value it is
applied to. Two situations separate this: whether the request follows a redirect chain, and
whether name resolution is repeated between the check and the connection.

**IO14 (assumption):** the chains and the second resolution are a model. What is measured is
not the probability of an event happening, but **whether the constraint sees that step**.

```js
// chain.mjs — the point where the constraint is applied: first step, every step, and repeating resolution
import { TARGET, RESOLVE, resolve, isPrivate, parse } from "./address.mjs";

// Redirect chains (fictional): the sequence of steps the request follows.
const CHAIN = [
  ["document.ext-1.example", ["document.ext-1.example"]],
  ["tile.ext-2.example", ["tile.ext-2.example", "203.0.113.60"]],
  ["archive.ext-1.example", ["archive.ext-1.example", "archive-2.ext-1.example", "10.12.4.20"]],
  ["gateway.ext-3.example", ["gateway.ext-3.example"]],
  ["document.ext-1.example", ["document.ext-1.example", "127.0.0.1"]],
];
const steps = CHAIN.reduce((t, [, z]) => t + z.length, 0);
console.log(`${CHAIN.length} redirect chains, ${steps} steps\n`);
console.log(`${"point where the constraint is applied".padEnd(39)}${"completed".padStart(11)}${"reaching internal address".padStart(27)}`);
for (const [name, select] of [["first step only", (z) => [z[0]]], ["every step", (z) => z]]) {
  const completed = CHAIN.filter(([, z]) => select(z).every((h) => !isPrivate(resolve(h))));
  const internal = completed.filter(([, z]) => isPrivate(resolve(z[z.length - 1]))).length;
  console.log(name.padEnd(39) + String(completed.length).padStart(11) + String(internal).padStart(27));
}

// Repeating the resolution: the address at check time and the address at connect time may differ.
const SECOND = { ...RESOLVE, "document.ext-1.example": "10.12.4.31" };
const resolveSecond = (a) => parse(SECOND[a] ?? a);
const passed = TARGET.filter(([h]) => !isPrivate(resolve(h)));
console.log(`\ntargets passing the address-based constraint: ${passed.length}`);
console.log(`${"connection form".padEnd(47)}${"reaching internal address".padStart(27)}`);
console.log("check and connect resolved separately".padEnd(47) +
  String(passed.filter(([h]) => isPrivate(resolveSecond(h))).length).padStart(27));
console.log("single resolution, connect to checked address".padEnd(47) +
  String(passed.filter(([h]) => isPrivate(resolve(h))).length).padStart(27));
```

```
5 redirect chains, 9 steps

point where the constraint is applied    completed  reaching internal address
first step only                                  4                          2
every step                                       2                          0

targets passing the address-based constraint: 4
connection form                                  reaching internal address
check and connect resolved separately                                    1
single resolution, connect to checked address                            0
```

The top table measures the location of the constraint. When the same constraint is applied
only to the first step, four chains complete and two end at an internal address; when
applied to every step, two chains complete and none reaches an internal address. The
constraint is the same constraint — the only thing that changes is how many of the nine
steps are seen. **A request's target is not a single value, it is a sequence.**

The bottom table gives the second point. One of the four targets that passes the constraint
falls into an internal address when it is re-resolved at connect time. The check had worked
correctly; the checked value is no longer the value that is connected to. The second row
shows the fix: resolution is done once, the constraint is applied to that address, and the
connection is made **not to the name, but to the checked address**. In that case the target
reaching an internal address is zero.

The shared lesson of the two rows is about the definition of coverage. A constraint covers
for as long as the value it is applied to and the value that is used are the same; the
moment the two separate, the constraint stays valid but has no coverage. This is the
question to ask when a new outbound request point is added: which value and which step does
this point apply the constraint to.

## Outbound Request Point Inventory

Which value and which step the constraint is applied to has been measured. The remaining
question is how many points it is applied at. The regional measurement network has eight
outbound request points, and half of them go through a central outbound request client.

```js
// point.mjs — outbound request point inventory and the coverage of the central outbound request client
import { resolve, isPrivate } from "./address.mjs";

// [service, point, targets, target source, client]
const POINT = [
  ["report", "fetchDocument", ["document.ext-1.example", "archive.ext-1.example"], "user", "central"],
  ["report", "fetchArchive", ["archive.ext-1.example", "10.12.4.9"], "user", "direct"],
  ["field", "fetchTile", ["tile.ext-2.example"], "declared", "central"],
  ["field", "preview", ["gateway.ext-3.example", "local.internal"], "user", "direct"],
  ["subscriber portal", "fetchLogo", ["document.ext-1.example", "edge.internal"], "user", "direct"],
  ["invoice", "fetchRate", ["tile.ext-2.example"], "declared", "central"],
  ["notification", "webhook", ["203.0.113.99", "report.internal"], "user", "direct"],
  ["measurement", "fetchCollector", ["meter.internal"], "declared", "central"],
];
const targetCount = POINT.reduce((t, n) => t + n[2].length, 0);

// Whether a target passes in a setup: the constraint is either not applied at all or applied to the address.
const passes = (n, h, setup) => {
  const constrained = setup === "all" || (setup === "exception" ? n[3] === "user" : n[4] === "central");
  return constrained ? !isPrivate(resolve(h)) : true;
};

console.log(`${POINT.length} outbound request points, ${targetCount} targets, ` +
  `${POINT.filter((n) => n[4] === "central").length} points go through the central client\n`);
console.log(`${"setup".padEnd(34)}${"targets passed".padStart(16)}${"reaching internal network".padStart(27)}${"legitimate internal call blocked".padStart(34)}`);
for (const [name, setup] of [["current: direct calls exist", "current"],
    ["all through central client", "all"], ["central + declared internal call", "exception"]]) {
  let passed = 0, internal = 0, blocked = 0;
  for (const n of POINT) for (const h of n[2]) {
    const g = passes(n, h, setup);
    if (g) { passed++; if (isPrivate(resolve(h))) internal++; }
    else if (n[3] === "declared" && isPrivate(resolve(h))) blocked++;
  }
  console.log(name.padEnd(34) + String(passed).padStart(16) + String(internal).padStart(27) + String(blocked).padStart(34));
}
console.log(`\nwhen a new outbound request point is added: constraint to write in central client 0, ` +
  `direct call ${1} constraint`);
```

```
8 outbound request points, 13 targets, 4 points go through the central client

setup                               targets passed  reaching internal network  legitimate internal call blocked
current: direct calls exist                     12                          5                                 1
all through central client                       7                          0                                 1
central + declared internal call                 8                          1                                 0

when a new outbound request point is added: constraint to write in central client 0, direct call 1 constraint
```

The first row gives the current state: five of the thirteen targets reach the internal
network, and all five come from the four direct calls that do not go through the central
client. The constraint exists in the code base and is written correctly; **it is just only
invoked at four points.**

The second row shows all points being brought into the central client. The reach into the
internal network drops to zero, but the last column stays at one: the call to the
measurement collector is a legitimate internal call, and the constraint stops it too. For a
point whose target does not come from user data, the constraint is in the wrong place.

The third row measures the fix: when the constraint is applied only to points whose target
comes from user data, the legitimate call blocked drops to zero and the reach into the
internal network rises to one — that one is the declared internal service call. **The
measure of coverage is not the number of targets reaching the internal network, but the
number of targets that come from user data and reach the internal network**; if this
distinction is not written down, the constraint either stops legitimate work or gets removed.

## Summary

- Whether an outbound request target reaches the internal network is determined not by the
  name but by thirty-two-bit address and mask arithmetic. Seven private blocks caught eight
  targets.
- 8 of the twelve targets reach the internal network with no constraint. The name-based
  denylist brings this to 3, and of the three it lets through, one is an externally looking
  name and two are internal addresses written as numbers. The address-based denylist and the
  allowlist give 0.
- The two zeros are not the same: the denylist stays open when a block that is not listed
  shows up, the allowlist rejects nine of the ten targets coming from user data and also
  blocks a new legitimate external host.
- When the constraint is applied only to the first step, 4 of 5 chains completed and 2 ended
  at an internal address; when applied to every step, 2 chains completed and reaching an
  internal address was 0. A request's target is not a single value, it is a sequence.
- 4 of the eight outbound request points go through the central client; because of the
  remaining four, 5 of 13 targets reach the internal network. When all are brought into the
  central client this becomes 0, but 1 legitimate internal call is blocked; when the
  constraint is applied only to user-sourced targets, the legitimate call blocked is 0.
- When the name is re-resolved between the check and the connection, 1 of the 4 targets
  passing the constraint fell into an internal address. When a single resolution is done and
  the connection is made to the checked address, this number is 0. The constraint loses its
  coverage the moment the value it is applied to and the value that is used separate.

## Next Step

The six lessons so far drew the boundary of data coming from outside and counted the
coverage at each boundary: the entry point, the output context, the query location, the
template body, the upload layer, and the outbound request target. Their shared assumption
was that what comes in is a **value** — a string, a number, a byte sequence. In some flows,
what comes in is not a value but an object definition: the data itself writes its fields,
its type, and how it is built. The next lesson measures deserialization in terms of a
trusted type set and closes the topic: how many types are accepted, how many of them an
allowlist closes off, and how many points choosing a data format removes.
