Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Web Security (OWASP Top 10:2025)  Module 3 – A02:2025 Security Misconfiguration

Request handling: Host, headers and smuggling

Build links from configuration, not Host; trust X-Forwarded-For only from your proxies; normalise paths once; and refuse ambiguous HTTP framing early.

  • Advanced
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Build absolute URLs from configuration, and refuse requests for hosts outside an allowlist
  • Configure trusted proxies so that X-Forwarded-For counts only from known hops
  • Normalise paths, query parameters and header names once, and decide access on the canonical form
  • Explain how Content-Length and Transfer-Encoding let two parsers disagree, and the rules that stop it
  • Test a server with raw requests, and refuse ambiguous or malformed ones at the first hop

Before you start

What you’ll learn · Before you start

What you will learn

  • Build absolute URLs from configuration, and refuse requests for hosts outside an allowlist
  • Configure trusted proxies so that X-Forwarded-For counts only from known hops
  • Normalise paths, query parameters and header names once, and decide access on the canonical form
  • Explain how Content-Length and Transfer-Encoding let two parsers disagree, and the rules that stop it
  • Test a server with raw requests, and refuse ambiguous or malformed ones at the first hop

Before you start

On this page

A password-reset email is only as trustworthy as the host name in its link, and a server that builds the link from the request’s Host header puts whatever host the request named into a message the real user receives. When this lesson sent 15 raw requests to Node’s own HTTP server, one ordinary and 14 malformed or ambiguous, 9 were refused before any application code ran; 6 reached the handler, and 4 of those needed a check that only the application can make.

You will build absolute URLs from configuration, decide which forwarded headers to believe, normalise paths and parameters once, see why two parsers that disagree on where a message ends are a security problem, and test a server with raw requests.

In HTTP/1.1 the Host header names the site a request is for. A browser never lets a page’s script set it, as the lesson on origins showed, but any other client writes whatever it likes. Servers need the header to route requests to the right site; the trouble starts when code copies it into a link, a redirect or a cache key, because the client then chooses part of what the server says. Django’s security guide lists the results of a fake Host: cross-site request forgery, cache poisoning and poisoned links in emails. Node’s documentation adds that code which reads req.headers.host must validate it, because clients may send a custom value. Here is a reset link built both ways:

A reset link built from Host, and one built from configuration JavaScript · host/reset_link.mjs
// Two ways to build the link in a password-reset email. The weak one copies the request's Host header, which any client
// can set to any text; the fixed one takes the link from configuration and answers 400 to requests for hosts the shop
// does not serve. The token is a placeholder, and every host name is reserved for examples.

const PUBLIC_ORIGIN = 'https://shop.example'; // configuration: one value per environment
const ALLOWED_HOSTS = new Set(['shop.example', 'www.shop.example']);

function weakResetLink(headers, token) {
  return `https://${headers.host ?? ''}/reset?token=${token}`;
}

/** The host name of a Host value, in lower case without the port; null when it is not a plain host[:port]. */
function hostName(value) {
  const m = /^([a-z0-9.-]+)(?::\d{1,5})?$/i.exec(value ?? '');
  return m ? m[1].toLowerCase() : null;
}

function fixedResetLink(headers, token) {
  const host = hostName(headers.host);
  if (host === null || !ALLOWED_HOSTS.has(host)) return '400 Bad Request: not a host this site serves';
  return `${PUBLIC_ORIGIN}/reset?token=${token}`;
}

const HOSTS = ['shop.example', 'SHOP.EXAMPLE:443', 'other.example', 'shop.example.other.example', 'shop.example/other', ''];

for (const value of HOSTS) {
  const headers = { host: value };
  console.log(`Host: ${value === '' ? '(empty)' : value}`);
  console.log(`  weak   ${weakResetLink(headers, 'T1')}`);
  console.log(`  fixed  ${fixedResetLink(headers, 'T1')}`);
}

Output

Host: shop.example
  weak   https://shop.example/reset?token=T1
  fixed  https://shop.example/reset?token=T1
Host: SHOP.EXAMPLE:443
  weak   https://SHOP.EXAMPLE:443/reset?token=T1
  fixed  https://shop.example/reset?token=T1
Host: other.example
  weak   https://other.example/reset?token=T1
  fixed  400 Bad Request: not a host this site serves
Host: shop.example.other.example
  weak   https://shop.example.other.example/reset?token=T1
  fixed  400 Bad Request: not a host this site serves
Host: shop.example/other
  weak   https://shop.example/other/reset?token=T1
  fixed  400 Bad Request: not a host this site serves
Host: (empty)
  weak   https:///reset?token=T1
  fixed  400 Bad Request: not a host this site serves

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node reset_link.mjs

The weak builder put other.example into the link, and anyone who can ask for a reset of someone else’s account would choose the host that person’s email points to, token included. It also produced a link to shop.example.other.example, a host the shop does not own, and broken links for a path inside Host and for an empty one. The fixed builder took the link from PUBLIC_ORIGIN, accepted the two spellings of the shop’s own name and refused the other four with 400 before doing any work. That is Django’s ALLOWED_HOSTS in a few lines; RFC 9112 section 3.2 also asks for a 400 when a request has no Host, more than one Host line or an invalid value, and the last section shows which of those a server’s parser already handles.

Key idea

Host is a value the client chose: route on it if you must, refuse hosts you do not serve, and build every link, redirect and email from the origin your configuration names.

Forwarded headers are true only from the hops you trust

Behind a CDN and a load balancer, the app’s connection comes from the balancer, so the client’s address arrives in a header: X-Forwarded-For, or the standard Forwarded of RFC 7239. Each proxy appends the address it saw, and nothing stops the client from writing the first entries itself. Step through one request on its way in:

Interactive · message by message How X-Forwarded-For grows, and which entry to believe Step through one request on its way to the app, then the same request sent around the CDN.
How X-Forwarded-For grows, and which entry to believe, step 7 of 7. Messages so far: Client to Your proxies: XFF: 192.0.2.66; Your proxies to App: 192.0.2.66, 198.51.100.7, …; Client to Your proxies: XFF: 192.0.2.66, direct.Client198.51.100.9Your proxies+ 198.51.100.7Appranges: 198.51.100.9XFF: 192.0.2.66192.0.2.66, 198.51.100.7, …XFF: 192.0.2.66, direct

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The client writes first. The client may send any X-Forwarded-For it likes. This one starts the list with 192.0.2.66, an address that has nothing to do with it.
  2. The edge appends. The CDN edge sees the connection come from 198.51.100.7 and appends that address. It does not remove what the client wrote.
  3. The balancer appends. The load balancer appends the edge's address, 203.0.113.10, and opens its own connection to the app. The app's peer is the balancer, 10.0.1.5.
  4. Read from the right. The app starts at its peer and walks the list from the right, skipping the two hops it trusts. The first address it does not trust is 198.51.100.7, the real client.
  5. The leftmost lies. The leftmost entry, 192.0.2.66, is whatever the client wrote. A rate limit or an allowlist that reads it can be steered by every client.
  6. Around the CDN. If the load balancer can be reached from the internet, a client at 198.51.100.9 can skip the edge. The list is then one entry shorter, and counting two hops lands on 192.0.2.66.
  7. Trust ranges, close paths. Trusting the proxies' own address ranges finds 198.51.100.9, the real client, either way. Better still, the balancer accepts connections only from the CDN.

The way to read the list is to start from the connection and walk right to left past your own trusted proxy: A proxy of your own, such as a load balancer or a CDN edge, whose added headers the app believes. The app trusts forwarded headers only on connections from the proxies' known addresses, never what a client wrote., by count or by their address ranges, as MDN describes. This program compares three readers on four requests, with the shop’s two proxies:

Three ways to find the client in X-Forwarded-For JavaScript · proxy/trust_proxy.mjs
// Which address is the client's? The app sees the address of the connection's other end and an X-Forwarded-For list
// that each proxy appends to, and that a client may start with any text. The shop's path: client -> CDN edge
// (203.0.113.0/24) -> load balancer (10.0.1.5) -> app. Every address is a documentation or a private one.

/** IPv4 address as a number, and an a.b.c.d/n range test. */
const ip4 = (a) => a.split('.').reduce((n, part) => n * 256 + Number(part), 0);
const inRange = (addr, cidr) => {
  const [base, bits] = cidr.split('/');
  const size = 2 ** (32 - Number(bits));
  return Math.floor(ip4(addr) / size) === Math.floor(ip4(base) / size);
};

/** Every X-Forwarded-For line as one list, left to right (several lines are one list, in order). */
const forwardedList = (lines) => lines.flatMap((line) => line.split(',').map((s) => s.trim()).filter(Boolean));

// Three ways to choose. Each walks from the right: the connection first, then the list from its last entry.
const leftmost = (peer, lines) => forwardedList(lines)[0] ?? peer;
function byHops(peer, lines, hops) {
  const chain = [peer, ...forwardedList(lines).reverse()];
  return chain[Math.min(hops, chain.length - 1)];
}
function byTrustedRanges(peer, lines, ranges) {
  const chain = [peer, ...forwardedList(lines).reverse()];
  return chain.find((addr) => !ranges.some((r) => inRange(addr, r))) ?? chain[chain.length - 1];
}

const TRUSTED = ['10.0.1.5/32', '203.0.113.0/24']; // the load balancer and the CDN's edge addresses
const CASES = [
  ['an ordinary visit', '198.51.100.7', '10.0.1.5', ['198.51.100.7, 203.0.113.10']],
  ['the client wrote its own entry', '198.51.100.7', '10.0.1.5', ['192.0.2.66, 198.51.100.7, 203.0.113.10']],
  ['two lines, read as one list', '198.51.100.7', '10.0.1.5', ['192.0.2.66', '198.51.100.7, 203.0.113.10']],
  ['the CDN bypassed, LB reached', '198.51.100.9', '10.0.1.5', ['192.0.2.66, 198.51.100.9']],
];

console.log(`${'Request'.padEnd(32)} ${'client'.padEnd(14)} ${'leftmost'.padEnd(16)} ${'2 hops'.padEnd(16)} trusted ranges`);
for (const [label, client, peer, lines] of CASES) {
  const mark = (addr) => `${addr} ${addr === client ? 'ok' : 'NO'}`;
  const row = [mark(leftmost(peer, lines)).padEnd(16), mark(byHops(peer, lines, 2)).padEnd(16), mark(byTrustedRanges(peer, lines, TRUSTED))];
  console.log(`${label.padEnd(32)} ${client.padEnd(14)} ${row.join(' ')}`);
}

Output

Request                          client         leftmost         2 hops           trusted ranges
an ordinary visit                198.51.100.7   198.51.100.7 ok  198.51.100.7 ok  198.51.100.7 ok
the client wrote its own entry   198.51.100.7   192.0.2.66 NO    198.51.100.7 ok  198.51.100.7 ok
two lines, read as one list      198.51.100.7   192.0.2.66 NO    198.51.100.7 ok  198.51.100.7 ok
the CDN bypassed, LB reached     198.51.100.9   192.0.2.66 NO    192.0.2.66 NO    198.51.100.9 ok

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node trust_proxy.mjs

Taking the leftmost entry failed three times out of four, because the client wrote it. Counting two hops was right until a request skipped the CDN and reached the balancer directly: one entry fewer, and the count landed on the client’s own text. Trusting the proxies’ address ranges was right every time. Express offers the same choices for its trust proxy setting, which is off by default: true takes the leftmost entry and is safe only if your last proxy replaces the header; a number counts hops and fails, as the guide warns, when paths of different lengths reach the app; a list of addresses and ranges skips exactly the proxies you name. Read every X-Forwarded-For line as one list, in order, and treat X-Forwarded-Host and X-Forwarded-Proto the same way: believed only on connections from your proxies. ASVS 5.0.0 asks for both halves, headers that users cannot override (4.1.3) and a client address taken only from such trusted fields (15.3.4).

A hop count with a second way in

Setting a hop count and leaving the balancer reachable from the internet gives clients a path one hop shorter, on which their own entry is counted as the client. Trust address ranges, and let the balancer accept connections only from the CDN.

Checkpoint

Check: Host as input, links from configuration · Trusted proxies and X-Forwarded-For

  1. Question 1 of 3 Your reset email builds its link as https:// plus req.headers.host plus /reset?token=…. A request for a reset of someone else's account arrives with Host other.example. What does that person receive?
    Show the answer: Question 1 of 3

    Answer: An email whose link, token included, points to other.example

    Build links from configuration, PUBLIC_ORIGIN in the program, and refuse requests for hosts the site does not serve. A value the client chose never decides where a user's token goes.

  2. Question 2 of 3 The app sees X-Forwarded-For 192.0.2.66, 198.51.100.7, 203.0.113.10 from its load balancer. Which entry could the client have written itself?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: 192.0.2.66, the leftmost

    Read from the right, past your own proxies, never from the left. In the program the leftmost reader was wrong three times out of four.

  3. Question 3 of 3 You count two trusted hops, the CDN and the load balancer. A client that reaches the load balancer directly sends X-Forwarded-For 192.0.2.66. What address does the app take, and what do you change?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: 192.0.2.66, the client's own text; trust the proxies' address ranges and let the balancer accept only the CDN

    A hop count assumes every request takes the same path. Trust by address ranges, and remove the second way in.

Normalise once, then decide on the canonical form

An access rule at the edge and a router in the app often read the same request target in two different ways, and a path that one of them reads as public and the other as an admin page is a hole between them. The program below puts a raw string rule at the edge, refusing anything that starts with /admin, in front of an app that parses the target with Node’s URL class and routes without regard to case, which is what Express’s router does by default:

Seven targets, an edge rule and two apps JavaScript · normalise/edge_vs_app.mjs
// One access rule at the edge, one router in the app, seven request targets, then a query with a repeated key. The
// edge refuses a raw path that starts with /admin. The weak app parses the target with Node's URL class (the WHATWG URL
// Standard, as browsers parse) and routes without regard to case, as Express's router does by default. The fixed app
// accepts only targets that are already in canonical form and checks access itself, on the same path it routes on.

const BASE = 'https://shop.example';

const edgeAllows = (target) => !target.startsWith('/admin');

function weakApp(target) {
  const url = new URL(target, BASE); // a relative reference: "//x/y" names another host
  const route = url.pathname.toLowerCase().startsWith('/admin') ? 'admin handler' : `page ${url.pathname}`;
  return url.host === 'shop.example' ? route : `${route} on host ${url.host}`;
}

/** The canonical path, or null when the target is not origin-form or the parser would change it. */
function canonical(target) {
  if (!target.startsWith('/') || target.startsWith('//')) return null;
  if (/%2e|%2f|%5c|\\/i.test(target)) return null; // encoded dots and slashes, and backslashes
  const path = new URL(`${BASE}${target}`).pathname; // the base and the target joined, as Node's docs show
  return path === target.split('?')[0] ? path : null;
}

function fixedApp(target, user) {
  const path = canonical(target);
  if (path === null) return '400 not a canonical path';
  if (path.toLowerCase().startsWith('/admin') && user.role !== 'admin') return '403 not an admin';
  return path.toLowerCase().startsWith('/admin') ? 'admin handler' : `page ${path}`;
}

const TARGETS = ['/admin/users', '/Admin/users', '/public/../admin/users', '/public/%2e%2e/admin/users', '/public\\..\\admin/users', '//admin/users', '/products/42'];
const visitor = { role: 'customer' };

console.log(`${'Target'.padEnd(28)} ${'Edge'.padEnd(8)} ${'Weak app'.padEnd(30)} Fixed app`);
for (const target of TARGETS) {
  const edge = edgeAllows(target) ? 'passes' : 'blocks';
  const weak = edge === 'blocks' ? '(never reached)' : weakApp(target);
  console.log(`${target.padEnd(28)} ${edge.padEnd(8)} ${weak.padEnd(30)} ${fixedApp(target, visitor)}`);
}

// Repeated query parameters: two readers of the same query, one value each.
const query = new URLSearchParams('role=customer&role=admin');
console.log(`\n?role=customer&role=admin: get() ${query.get('role')}, Object.fromEntries() ${Object.fromEntries(query).role}, getAll() ${query.getAll('role').join(' and ')}`);

Output

Target                       Edge     Weak app                       Fixed app
/admin/users                 blocks   (never reached)                403 not an admin
/Admin/users                 passes   admin handler                  403 not an admin
/public/../admin/users       passes   admin handler                  400 not a canonical path
/public/%2e%2e/admin/users   passes   admin handler                  400 not a canonical path
/public\..\admin/users       passes   admin handler                  400 not a canonical path
//admin/users                passes   page /users on host admin      400 not a canonical path
/products/42                 passes   page /products/42              page /products/42

?role=customer&role=admin: get() customer, Object.fromEntries() admin, getAll() customer and admin

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node edge_vs_app.mjs

Four targets passed the edge and reached the admin handler of the weak app. /Admin/users matched because the router ignores case. The other three are the same path to a URL parser: the URL Standard treats %2e%2e as a .. segment and, for http and https URLs, a backslash like a slash, so all three resolve to /admin/users. //admin/users did something stranger: new URL(target, base) reads a target that starts with two slashes as a reference to another host, admin. Node’s own documentation joins the configured base and the target into one string instead. And the last line shows the same split for a query string: get() returns the first role, while Object.fromEntries() keeps the last, so a check that reads one and a handler that reads the other disagree. ASVS 5.0.0 asks for defences against exactly this parameter pollution (15.3.7).

The fixed app turns each target into a canonical form: The one agreed way of writing a value, such as a path with no dot segments, no encoded slashes and one case rule. Code that checks and code that acts both use it, so they cannot read the same input differently. once, refuses any target that parsing would change with a 400, and applies the access rule itself, on the same path it routes on. The edge rule stays as a second layer, not as the only check. Apply the same rule to the rest of the request:

  • Repeated parameters: refuse a repeated key where the code expects one value, instead of letting each reader choose first or last.
  • Header names: drop or refuse names with underscores. nginx ignores them by default (underscores_in_headers off), and gateways that follow CGI turn X-Forwarded-For and X_Forwarded_For into the same variable, HTTP_X_FORWARDED_FOR, because RFC 3875 replaces each hyphen with an underscore.
  • Decoding: decode percent-escapes once, in one place, and refuse what still holds an escape for a dot, a slash or a backslash. RFC 3986 section 2.4 forbids decoding the same string twice, which is how %252e becomes a dot.

See what a path really says

Paste a suspicious path into a decoder before deciding what it means, and test the router with the decoded and the raw form: the two must reach the same handler or be refused.

URL Encoder & Decoder Decode a path or a query string you found in your logs, to see what each layer may have read.

Checkpoint

Check: Canonical paths, parameters and header names

  1. Question 1 of 2 The edge refuses paths that start with /admin. The app's router ignores case, as Express's does by default. What happens to a request for /Admin/users?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It passes the edge and reaches the admin handler

    Decide access where the routing happens, on the same canonical path. The fixed app answered 403 because it checked the role itself.

  2. Question 2 of 2 An app parses the request target with new URL(target, 'https://shop.example'). What does a target of //admin/users become?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A URL on another host, admin, with the path /users

    Join the configured base and the target into one string, or refuse targets that do not start with a single slash. The fixed app refused //admin/users with 400.

Every hop must agree where a request ends

On a connection that carries several requests one after another, each hop must find where one message ends and the next begins: its message framing: How a recipient finds where an HTTP/1.1 message ends: by Content-Length, by Transfer-Encoding chunked, or by the connection closing. Every hop must reach the same answer for the same bytes.. HTTP/1.1 offers two ways to say how long a body is, Content-Length and Transfer-Encoding: chunked, and RFC 9112 section 6.3 says that when both are present, Transfer-Encoding wins and the message ought to be handled as an error. A proxy that trusts Content-Length instead, in front of a server that follows the standard, finds a different end for the same bytes. This model runs both parsers on one message:

Two parsers, one message, two ends Python · framing/front_vs_back.py
# Two hops read the same bytes. The front proxy trusts Content-Length when a message has both length headers; the back
# server follows Transfer-Encoding, as RFC 9112 says Transfer-Encoding overrides Content-Length. A model of message
# framing only: nothing here opens a socket. Then both hops use one strict parser that refuses the ambiguous message.

BODY = b"5\r\nhello\r\n0\r\n\r\n"  # "hello" in chunked coding: one chunk of 5 bytes, then the last chunk
EXTRA = b"LEFTOVER-BYTES"  # stands for anything that follows in the same stream
RAW = (
    b"POST /upload HTTP/1.1\r\n"
    b"Host: shop.example\r\n"
    + f"Content-Length: {len(BODY + EXTRA)}\r\n".encode()
    + b"Transfer-Encoding: chunked\r\n"
    b"\r\n"
    + BODY
    + EXTRA
)


def split_head(raw):
    head, _, rest = raw.partition(b"\r\n\r\n")
    fields = [line.split(b":", 1) for line in head.split(b"\r\n")[1:]]
    return {name.strip().lower(): value.strip() for name, value in fields}, rest


def front_reads(raw):
    """A lenient proxy: the body is Content-Length bytes long, whatever else the message says."""
    fields, rest = split_head(raw)
    length = int(fields[b"content-length"])
    return rest[:length], rest[length:]


def back_reads(rest):
    """A server that follows Transfer-Encoding: chunked; the body ends at the chunk of size 0."""
    body, pos = b"", 0
    while True:
        line_end = rest.index(b"\r\n", pos)
        size = int(rest[pos:line_end], 16)
        pos = line_end + 2
        if size == 0:
            return body, rest[pos + 2 :]
        body += rest[pos : pos + size]
        pos += size + 2


def strict(raw):
    """One parser for every hop: a message with both length headers is refused (RFC 9112 sections 6.1 and 6.3)."""
    fields, _ = split_head(raw)
    if b"transfer-encoding" in fields and b"content-length" in fields:
        return "400 Bad Request, connection closed: both Content-Length and Transfer-Encoding"
    return "accepted"


forwarded, after_front = front_reads(RAW)
body, after_back = back_reads(forwarded)
print(f"One message of {len(RAW)} bytes, with Content-Length: {len(BODY + EXTRA)} and Transfer-Encoding: chunked")
print(f"front proxy: one request with a {len(forwarded)}-byte body; {len(after_front)} bytes left over")
print(f"back server: one request with the body {body!r}; {len(after_back)} bytes left over: {after_back!r}")
print("  the back server now reads those bytes as the start of the next request on this connection")
print()
print("With one strict parser on both hops")
print(f"front proxy: {strict(RAW)}")
print("back server: never receives the message")

Output

One message of 122 bytes, with Content-Length: 29 and Transfer-Encoding: chunked
front proxy: one request with a 29-byte body; 0 bytes left over
back server: one request with the body b'hello'; 14 bytes left over: b'LEFTOVER-BYTES'
  the back server now reads those bytes as the start of the next request on this connection

With one strict parser on both hops
front proxy: 400 Bad Request, connection closed: both Content-Length and Transfer-Encoding
back server: never receives the message

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 front_vs_back.py

The front proxy counted 29 bytes of body and forwarded one whole request; the back server read the chunked body, hello, and found 14 bytes left over. On a connection the front shares with other users’ requests, the back reads those bytes as the beginning of the next request. That gap between two parsers is a parser differential: Two parsers reading the same input differently, such as a server's URL check and a browser disagreeing about which host a string names. A check is only as good as its agreement with the code that acts on it. More in “Open redirects and safe URL handling”, and using it to hide one request inside another is request smuggling: Hiding a request inside another one by exploiting two hops that find different ends for the same HTTP message, such as a proxy reading Content-Length and a server reading Transfer-Encoding (RFC 9112 section 11.2)., which RFC 9112 section 11.2 describes and OWASP’s Top 10:2025 lists as CWE-444 under A06. Step through it:

Interactive · message by message Two hops, two ends for one message Step through one message with both length headers, read by a lenient front and a back that follows the standard.
Two hops, two ends for one message, step 6 of 6. Messages so far: Client to Front proxy: 122 bytes, both lengths; Front proxy to Back server: one request, 29-byte body; Front proxy to Client: 400, connection closed.ClientFront proxyrefused at the first hopBack servernever sees it122 bytes, both lengthsone request, 29-byte body400, connection closed

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Two lengths. A message arrives with Content-Length 29 and Transfer-Encoding chunked. The two headers describe where the body ends in two different ways.
  2. The front counts bytes. The front proxy believes Content-Length and treats the next 29 bytes as the body. To the front, one whole request has arrived, and it forwards it on a connection it shares with other requests.
  3. The back reads chunks. RFC 9112 says Transfer-Encoding overrides Content-Length, so the back server reads chunks. Its body is the 5 bytes of hello, and it ends at the chunk of size 0.
  4. Bytes left over. Fourteen bytes remain on the connection that the front counted as body. The back reads them as the start of the next request, joined to whatever arrives next.
  5. One strict parser. With one parser that refuses a message with both headers, the front answers 400 and closes the connection, as RFC 9112 requires after such a request.
  6. Nothing to disagree on. The back server never receives the message. Every hop that parses the same rules, ideally one maintained implementation and HTTP/2 end to end, finds the same end for every message.

The defence is agreement and strictness. RFC 9112 lets a server refuse a request that has both headers, or process it by Transfer-Encoding alone, and in either case it must close the connection after responding; a proxy that forwards such a message must first remove Content-Length. Two different Content-Length values are an unrecoverable error, answered with 400 and a closed connection. HTTP/2 has its own framing, and the risk returns where an edge translates it to HTTP/1.1: RFC 9113 makes a message malformed when its content-length differs from the length of its data, when it carries connection-specific headers such as Transfer-Encoding, or when a field holds a CR, an LF or a NUL, and requires those checks before a message is translated. ASVS 5.0.0 requirements 4.2.1 to 4.2.4 ask for the same.

Key idea

Ambiguous framing is refused, never interpreted: one maintained, strict parser at the edge, the same rules on every hop behind it, and HTTP/2 end to end where you can.

Refuse ambiguous requests at the first hop

The parser in front of your code decides what reaches it, so learn what it refuses. Node’s HTTP server, with its defaults, requires Host on HTTP/1.1 requests (requireHostHeader), rejects malformed messages unless you switch on insecureHTTPParser, which its documentation says should be avoided, and limits headers to 16 KiB (maxHeaderSize). This program hands 15 raw requests to a real Node server through an in-memory socket, so the bytes reach the parser exactly as written without touching a network, then repeats them against a handler that starts with a guard:

Fifteen raw requests for Node's HTTP server, with and without a guard JavaScript · raw/raw_http.mjs
// Fifteen raw requests for Node's own HTTP server, with its default options. Each one is handed to the server through
// an in-memory socket (the 'connection' event accepts any Duplex stream), so the bytes reach Node's HTTP parser exactly
// as written, and no network is used. The handler reads the whole body, then answers with what it saw. The same bytes
// then go to a second server whose handler starts with a guard: the checks the parser leaves to the application.
import http from 'node:http';
import { Duplex } from 'node:stream';

const ALLOWED_HOSTS = new Set(['shop.example']);
const CRLF = '\r\n';
const request = (...lines) => lines.join(CRLF);

const REQUESTS = [
  ['an ordinary GET', request('GET /orders HTTP/1.1', 'Host: shop.example', '', '')],
  ['no Host header', request('GET /orders HTTP/1.1', '', '')],
  ['Content-Length and Transfer-Encoding', request('POST /orders HTTP/1.1', 'Host: shop.example', 'Content-Length: 5', 'Transfer-Encoding: chunked', '', '0', '', '')],
  ['two different Content-Length values', request('POST /orders HTTP/1.1', 'Host: shop.example', 'Content-Length: 3', 'Content-Length: 5', '', 'abcde')],
  ['Transfer-Encoding without chunked', request('POST /orders HTTP/1.1', 'Host: shop.example', 'Transfer-Encoding: gzip', '', 'abc')],
  ['a space before the colon', request('GET /orders HTTP/1.1', 'Host : shop.example', '', '')],
  ['obsolete line folding', request('GET /orders HTTP/1.1', 'Host: shop.example', 'X-Note: one', ' two', '', '')],
  ['bare LF line endings', 'GET /orders HTTP/1.1\nHost: shop.example\n\n'],
  ['a NUL byte in a value', request('GET /orders HTTP/1.1', 'Host: shop.example', 'X-Note: a\0b', '', '')],
  ['a 20 KiB header', request('GET /orders HTTP/1.1', 'Host: shop.example', `X-Note: ${'a'.repeat(20 * 1024)}`, '', '')],
  ['two Host lines', request('GET /orders HTTP/1.1', 'Host: shop.example', 'Host: other.example', '', '')],
  ['absolute-form, another host', request('GET http://other.example/orders HTTP/1.1', 'Host: shop.example', '', '')],
  ['a path inside Host', request('GET /orders HTTP/1.1', 'Host: shop.example/other', '', '')],
  ['two X-Forwarded-For lines', request('GET /orders HTTP/1.1', 'Host: shop.example', 'X-Forwarded-For: 192.0.2.66', 'X-Forwarded-For: 198.51.100.7', '', '')],
  ['an underscore in a header name', request('GET /orders HTTP/1.1', 'Host: shop.example', 'X_Forwarded_For: 192.0.2.66', '', '')],
];

/** What a handler saw, in a few words. */
function seen(req) {
  const parts = [`url ${req.url}`, `host ${req.headers.host ?? '(none)'}`];
  if (req.headers['x-forwarded-for']) parts.push(`x-forwarded-for "${req.headers['x-forwarded-for']}"`);
  if (req.headers.x_forwarded_for) parts.push(`x_forwarded_for "${req.headers.x_forwarded_for}"`);
  return parts.join(', ');
}

/** The checks the parser leaves to the application: one Host, a host this site serves, no names with underscores. */
function guard(req) {
  const names = req.rawHeaders.filter((_, i) => i % 2 === 0).map((n) => n.toLowerCase());
  if (names.filter((n) => n === 'host').length !== 1) return 'exactly one Host header';
  const authority = /^https?:\/\/([^/?#]*)/i.exec(req.url)?.[1] ?? req.headers.host; // absolute-form: its own host
  if (!ALLOWED_HOSTS.has(authority.toLowerCase())) return `not a host this site serves: ${authority}`;
  if (names.some((n) => n.includes('_'))) return 'a header name with an underscore';
  return null;
}

function server(withGuard) {
  return http.createServer((req, res) => {
    req.resume(); // read the whole body before answering, as a handler that needs it would
    req.on('end', () => {
      const refused = withGuard ? guard(req) : null;
      const body = refused ? `refused: ${refused}` : seen(req);
      res.writeHead(refused ? 400 : 200, { connection: 'close', 'content-length': Buffer.byteLength(body) });
      res.end(body);
    });
  });
}

/** Sends raw bytes to a server through an in-memory socket and returns its first status line and body. */
function exchange(srv, raw) {
  return new Promise((resolve) => {
    let out = '';
    let sent = false;
    const socket = new Duplex({
      read() {
        if (!sent) this.push(Buffer.from(raw, 'latin1'));
        sent = true;
      },
      write(chunk, _encoding, done) {
        out += chunk.toString('latin1');
        done();
      },
    });
    const finish = () => resolve({ status: out.split(CRLF)[0].replace('HTTP/1.1 ', ''), body: out.split(CRLF + CRLF)[1] ?? '' });
    socket.on('finish', finish);
    socket.on('close', finish);
    srv.emit('connection', socket);
  });
}

const plain = server(false);
const guarded = server(true);
for (const [label, raw] of REQUESTS) {
  const a = await exchange(plain, raw);
  const b = await exchange(guarded, raw);
  console.log(`${label}`);
  console.log(`  Node: ${a.status}${a.body && a.status.startsWith('200') ? `; the handler saw ${a.body}` : ''}`);
  if (a.status.startsWith('200')) console.log(`  with the guard: ${b.status}${b.body.startsWith('refused') ? `, ${b.body}` : ''}`);
}
plain.close();
guarded.close();

Output

an ordinary GET
  Node: 200 OK; the handler saw url /orders, host shop.example
  with the guard: 200 OK
no Host header
  Node: 400 Bad Request
Content-Length and Transfer-Encoding
  Node: 400 Bad Request
two different Content-Length values
  Node: 400 Bad Request
Transfer-Encoding without chunked
  Node: 400 Bad Request
a space before the colon
  Node: 400 Bad Request
obsolete line folding
  Node: 400 Bad Request
bare LF line endings
  Node: 400 Bad Request
a NUL byte in a value
  Node: 400 Bad Request
a 20 KiB header
  Node: 431 Request Header Fields Too Large
two Host lines
  Node: 200 OK; the handler saw url /orders, host shop.example
  with the guard: 400 Bad Request, refused: exactly one Host header
absolute-form, another host
  Node: 200 OK; the handler saw url http://other.example/orders, host shop.example
  with the guard: 400 Bad Request, refused: not a host this site serves: other.example
a path inside Host
  Node: 200 OK; the handler saw url /orders, host shop.example/other
  with the guard: 400 Bad Request, refused: not a host this site serves: shop.example/other
two X-Forwarded-For lines
  Node: 200 OK; the handler saw url /orders, host shop.example, x-forwarded-for "192.0.2.66, 198.51.100.7"
  with the guard: 200 OK
an underscore in a header name
  Node: 200 OK; the handler saw url /orders, host shop.example, x_forwarded_for "192.0.2.66"
  with the guard: 400 Bad Request, refused: a header name with an underscore

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node raw_http.mjs

The parser refused nine requests: eight with 400, from the missing Host to the NUL byte, and the 20 KiB header with 431. The other six reached the handler, and the run shows why the application still has work to do. Two Host lines reached it as one, the first, because host is on the list of headers whose duplicates Node discards while joinDuplicateHeaders is off; RFC 9112 asks for a 400 instead. An absolute-form target arrived with url http://other.example/orders next to Host: shop.example, and RFC 9112 section 3.2.2 says the target’s host wins. A path inside Host passed untouched, the two X-Forwarded-For lines arrived joined into one list, and X_Forwarded_For stayed a header of its own. The guard refused four of the six and let the ordinary request and the joined list through.

A lenient parser for one broken client

Switching on insecureHTTPParser, or a proxy’s lenient mode, to accept one client that sends malformed requests makes every request that hop accepts a candidate for disagreement with the next one. Fix or refuse the client; keep the parser strict.

Checkpoint

Check: Message framing and request smuggling · What the parser refuses and what it leaves

  1. Question 1 of 3 A message has Content-Length 29 and Transfer-Encoding chunked. The front proxy reads Content-Length, the back server Transfer-Encoding. What did the program's two parsers find?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: The front forwarded one request with a 29-byte body; the back read the body hello and found 14 bytes left over

    Two parsers, two ends: the leftover bytes are where request smuggling starts. One strict parser that refuses the message, and closes the connection, leaves nothing to disagree on.

  2. Question 2 of 3 Node's server, with default options, receives a request with two Host lines, shop.example and other.example. What does the handler see?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: One host, shop.example, because Node discards duplicates of Host; the application must refuse such a request itself

    Know what your parser lets through. Count the Host lines in rawHeaders and refuse anything but exactly one.

  3. Question 3 of 3 A partner's old client sends folded header lines, and Node refuses its requests. What is the right fix?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: Have the client fixed, or refuse it, and keep the parser strict

    Strict parsing on every hop is the defence; one client's convenience is not worth a gap between parsers.

Practise: validate a request before anything else reads it

Write the check that the first hop runs on every request: one Host, a host the site serves, no folded lines, bare line feeds or NUL bytes, a request line of the form METHOD target HTTP/1.1, and no message with two ways of stating its length. The sample tests feed it raw requests like the ones above:

Exercise · Medium · JavaScript, Python

Refuse ambiguous requests at the first hop

Write validateRequest(raw, allowedHosts) in validate.mjs (Python: validate_request(raw, allowed_hosts) in validate.py). raw is the text of one HTTP/1.1 request, its head and maybe a body; allowedHosts is a list of the host names the site serves, in lower case. Return { ok: true, method, path, host } (Python: a dict with the same keys) for a request that every hop would read the same way, and { ok: false, status: 400, reason } for any other:

  • The head ends with an empty line, and every line ends with CR LF: a bare LF or a bare CR is refused.
  • The request line is METHOD target HTTP/1.1.
  • No header line starts with a space or a tab (obsolete folding), no header name has a space before its colon, and no value holds a NUL, CR or LF.
  • There is exactly one Host header.
  • A request with both Content-Length and Transfer-Encoding is refused, and so are two different Content-Length values.
  • An origin-form target (/orders) takes its host from Host; an absolute-form target (https://shop.example/orders) names its own host, which wins over Host, as RFC 9112 says. Either way the host is compared in lower case without its port, and must be one of allowedHosts. path is the target's path and query.

reason is any text you find useful; the tests read only ok and status of a refusal.

JavaScript · Starter code · validate.mjs

/**
 * Reads one HTTP/1.1 request. Returns { ok: true, method, path, host } or { ok: false, status: 400, reason }.
 * Starter: it reads what it can and accepts everything, the way a lenient parser does.
 */
export function validateRequest(raw, allowedHosts) {
  const [head] = raw.split(/\r?\n\r?\n/);
  const [requestLine, ...lines] = head.split(/\r?\n/);
  const [method, path] = requestLine.split(' ');
  const headers = {};
  for (const line of lines) {
    const [name, ...rest] = line.split(':');
    headers[name.trim().toLowerCase()] = rest.join(':').trim();
  }
  const host = (headers.host ?? '').toLowerCase().replace(/:\d+$/, '');
  return { ok: true, method, path, host };
}
The sample tests · validate.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { validateRequest } from './validate.mjs';

const HOSTS = ['shop.example'];
const request = (...lines) => lines.join('\r\n');
function refused(raw, why) {
  const r = validateRequest(raw, HOSTS);
  assert.equal(r.ok, false, why);
  assert.equal(r.status, 400, why);
}

test('an ordinary request is accepted with its method, path and host', () => {
  const r = validateRequest(request('GET /orders?page=2 HTTP/1.1', 'Host: SHOP.example:443', 'Accept: */*', '', ''), HOSTS);
  assert.deepEqual(r, { ok: true, method: 'GET', path: '/orders?page=2', host: 'shop.example' });
});

test('both Content-Length and Transfer-Encoding are refused', () => {
  refused(request('POST /orders HTTP/1.1', 'Host: shop.example', 'Content-Length: 5', 'Transfer-Encoding: chunked', '', '0', '', ''), 'both length headers');
});

test('two different Content-Length values are refused', () => {
  refused(request('POST /orders HTTP/1.1', 'Host: shop.example', 'Content-Length: 3', 'Content-Length: 5', '', 'abcde'), 'two lengths');
});

test('a request needs exactly one Host header', () => {
  refused(request('GET /orders HTTP/1.1', 'Accept: */*', '', ''), 'no Host');
  refused(request('GET /orders HTTP/1.1', 'Host: shop.example', 'Host: other.example', '', ''), 'two Host lines');
});

test('folded lines, a space before the colon and bare LF are refused', () => {
  refused(request('GET /orders HTTP/1.1', 'Host: shop.example', 'X-Note: one', ' two', '', ''), 'obsolete line folding');
  refused(request('GET /orders HTTP/1.1', 'Host : shop.example', '', ''), 'a space before the colon');
  refused('GET /orders HTTP/1.1\nHost: shop.example\n\n', 'bare LF line endings');
});

test('a Host that the site does not serve is refused, even a look-alike', () => {
  refused(request('GET /orders HTTP/1.1', 'Host: other.example', '', ''), 'a foreign Host');
  refused(request('GET /orders HTTP/1.1', 'Host: shop.example.other.example', '', ''), 'a Host that only starts like ours');
});

test('a NUL in a value, a bare CR and a head with no end are refused', () => {
  refused(request('GET /orders HTTP/1.1', 'Host: shop.example', 'X-Note: a\0b', '', ''), 'a NUL in a value');
  refused('GET /orders HTTP/1.1\r\nHost: shop.example\rX-Note: a\r\n\r\n', 'a bare CR');
  refused('GET /orders HTTP/1.1\r\nHost: shop.example\r\n', 'a head that never ends');
});

test('the request line must be METHOD target HTTP/1.1', () => {
  refused(request('GET /orders HTTP/1.0', 'Host: shop.example', '', ''), 'HTTP/1.0');
  refused(request('GET  /orders HTTP/1.1', 'Host: shop.example', '', ''), 'two spaces in the request line');
});

test('an absolute-form target names the host, and it must be one the site serves', () => {
  const r = validateRequest(request('GET https://shop.example/orders HTTP/1.1', 'Host: shop.example', '', ''), HOSTS);
  assert.deepEqual(r, { ok: true, method: 'GET', path: '/orders', host: 'shop.example' });
  refused(request('GET http://other.example/orders HTTP/1.1', 'Host: shop.example', '', ''), 'the target names another host');
});

Python · Starter code · validate.py

import re


def validate_request(raw, allowed_hosts):
    """Reads one HTTP/1.1 request. Returns {"ok": True, "method", "path", "host"} or {"ok": False, "status": 400,
    "reason"}. Starter: it reads what it can and accepts everything, the way a lenient parser does."""
    head = re.split(r"\r?\n\r?\n", raw)[0]
    request_line, *lines = re.split(r"\r?\n", head)
    method, path = request_line.split(" ")[:2]
    headers = {}
    for line in lines:
        name, _, value = line.partition(":")
        headers[name.strip().lower()] = value.strip()
    host = re.sub(r":\d+$", "", headers.get("host", "").lower())
    return {"ok": True, "method": method, "path": path, "host": host}
The sample tests · test_validate.py
from validate import validate_request

HOSTS = ["shop.example"]


def request(*lines):
    return "\r\n".join(lines)


def refused(raw, why):
    r = validate_request(raw, HOSTS)
    assert r["ok"] is False, why
    assert r["status"] == 400, why


def test_an_ordinary_request_is_accepted_with_its_method_path_and_host():
    r = validate_request(request("GET /orders?page=2 HTTP/1.1", "Host: SHOP.example:443", "Accept: */*", "", ""), HOSTS)
    assert r == {"ok": True, "method": "GET", "path": "/orders?page=2", "host": "shop.example"}


def test_both_content_length_and_transfer_encoding_are_refused():
    refused(request("POST /orders HTTP/1.1", "Host: shop.example", "Content-Length: 5", "Transfer-Encoding: chunked", "", "0", "", ""), "both length headers")


def test_two_different_content_length_values_are_refused():
    refused(request("POST /orders HTTP/1.1", "Host: shop.example", "Content-Length: 3", "Content-Length: 5", "", "abcde"), "two lengths")


def test_a_request_needs_exactly_one_host_header():
    refused(request("GET /orders HTTP/1.1", "Accept: */*", "", ""), "no Host")
    refused(request("GET /orders HTTP/1.1", "Host: shop.example", "Host: other.example", "", ""), "two Host lines")


def test_folded_lines_a_space_before_the_colon_and_bare_lf_are_refused():
    refused(request("GET /orders HTTP/1.1", "Host: shop.example", "X-Note: one", " two", "", ""), "obsolete line folding")
    refused(request("GET /orders HTTP/1.1", "Host : shop.example", "", ""), "a space before the colon")
    refused("GET /orders HTTP/1.1\nHost: shop.example\n\n", "bare LF line endings")


def test_a_host_the_site_does_not_serve_is_refused_even_a_look_alike():
    refused(request("GET /orders HTTP/1.1", "Host: other.example", "", ""), "a foreign Host")
    refused(request("GET /orders HTTP/1.1", "Host: shop.example.other.example", "", ""), "a Host that only starts like ours")


def test_a_nul_in_a_value_a_bare_cr_and_a_head_with_no_end_are_refused():
    refused(request("GET /orders HTTP/1.1", "Host: shop.example", "X-Note: a\0b", "", ""), "a NUL in a value")
    refused("GET /orders HTTP/1.1\r\nHost: shop.example\rX-Note: a\r\n\r\n", "a bare CR")
    refused("GET /orders HTTP/1.1\r\nHost: shop.example\r\n", "a head that never ends")


def test_the_request_line_must_be_method_target_http_1_1():
    refused(request("GET /orders HTTP/1.0", "Host: shop.example", "", ""), "HTTP/1.0")
    refused(request("GET  /orders HTTP/1.1", "Host: shop.example", "", ""), "two spaces in the request line")


def test_an_absolute_form_target_names_the_host_and_it_must_be_one_the_site_serves():
    r = validate_request(request("GET https://shop.example/orders HTTP/1.1", "Host: shop.example", "", ""), HOSTS)
    assert r == {"ok": True, "method": "GET", "path": "/orders", "host": "shop.example"}
    refused(request("GET http://other.example/orders HTTP/1.1", "Host: shop.example", "", ""), "the target names another host")
A hint

Find the end of the head with the first "\r\n\r\n", refuse the request when the head still holds a "\n" that is not part of "\r\n", then split the head on "\r\n". Keep every header as a (name, value) pair in a list instead of a dictionary, so that you can count how many Host and Content-Length lines there are.

The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

HTTP Header Checker Request a page of your own and read the headers your proxies add or keep on the way back.

Then try the questions on hosts, forwarded headers, paths and framing, each one explained as soon as you answer:

Practice that adapts to you

Practise this lesson

11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 11 Which clients can send any value they like in the Host header?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: Every client outside a browser, such as a script or a command-line tool

    Host is input. Refuse values you do not serve, and never let it choose a link, a redirect or a cache key.

  2. Question 2 of 11 A nightly job emails each user a digest with a link to their dashboard. No request is running, so there is no Host header. Where does the link's address come from?
    Show the answer: Question 2 of 11

    Answer: From the configured public origin that the web app uses too, one value for each environment

    A link is built from the origin that configuration names, in a job as in a request handler. Where a request is involved, its Host is only checked against the hosts you serve, never copied into a link.

  3. Question 3 of 11 In Express, what address does req.ip use when trust proxy is set to true?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: The leftmost entry of X-Forwarded-For

    true is safe only behind a proxy that overwrites X-Forwarded-For. Address ranges or a count are safer.

  4. Question 4 of 11 A login rate limit counts attempts per client address, taken from the leftmost X-Forwarded-For entry. What can one client do, and what do you change?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: Write a new leftmost address on every attempt and never hit the limit; take the address from the connection leftwards past your trusted proxies

    Security decisions use addresses that a trusted proxy wrote, as ASVS 5.0.0 requirement 15.3.4 asks.

  5. Question 5 of 11 Under the URL Standard, which path segments count as a double-dot segment? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 5 of 11

    Answer:

    • %2e%2e
    • .%2E
    • ..

    The URL parser removes a segment for each of these, so a raw string check that looks for .. misses most of them. Refuse targets that parsing would change.

  6. Question 6 of 11 A middleware checks params.get('role') and allows the request; the handler builds its options with Object.fromEntries(params). A request carries role=customer&role=admin. What does the handler use, and what is the fix?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: admin, the last value, while the check saw customer; refuse a repeated key where one value is expected

    Two readers of one input must agree. Refusing repeated keys removes the question, as ASVS 5.0.0 requirement 15.3.7 asks for parameter pollution.

  7. Question 7 of 11 Your proxy sets X-Forwarded-For. A gateway behind it turns request headers into CGI-style variables, and a client also sends a header named X_Forwarded_For. What can go wrong, and what is the fix?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: Both names become HTTP_X_FORWARDED_FOR, so the client's header can replace or join the proxy's; refuse or drop header names with underscores

    Two spellings that one layer treats as different and another as the same are the same gap as /Admin and /admin. Refuse names with underscores, or drop them before the app reads its headers.

  8. Question 8 of 11 Under RFC 9112, which header decides the length of a message that has both Content-Length and Transfer-Encoding?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: Transfer-Encoding, and the message ought to be handled as an error

    Knowing the rule is what lets you see the danger: any hop that follows the other rule finds another end. Refuse such messages, or remove Content-Length and close the connection.

  9. Question 9 of 11 Your edge accepts HTTP/2 and forwards HTTP/1.1 to the app. Which HTTP/2 requests must it treat as malformed instead of translating? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 9 of 11

    Answer:

    • A request with a CR or LF inside a header value
    • A request that carries Transfer-Encoding
    • A request whose content-length differs from the length of its data

    Translation is where HTTP/2's framing meets HTTP/1.1's delimiters. RFC 9113 section 10.3 requires these checks before an intermediary translates a message, and ASVS 5.0.0 requirements 4.2.1 to 4.2.4 ask for the same.

  10. Question 10 of 11 Of the 15 raw requests in the program, how many reached the handler of the server without a guard?

    Type a number.

    Show the answer: Question 10 of 11

    Answer: 6 requests

    Six: the ordinary GET, two Host lines, the absolute-form target, a path inside Host, two X-Forwarded-For lines and an underscore in a header name. The parser refused the other nine, eight with 400 and one with 431.

  11. Question 11 of 11 A request arrives as GET http://other.example/orders with Host shop.example, and your app checks only req.headers.host against its allowlist. What is wrong?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: RFC 9112 makes the target's host win over Host, so the check looked at the wrong value; take the host from an absolute-form target first

    Decide the host once, by the standard's rule, and check that one value. The exercise asks for exactly this.

Interview questions

Warm-up: what is HTTP request smuggling, and what stops it?

It is hiding one request inside another by exploiting two hops, usually a proxy and the server behind it, that find different ends for the same HTTP/1.1 message. The classic cause is a message with both Content-Length and Transfer-Encoding: chunked: one hop counts bytes, the other reads chunks, and the bytes in between are treated by the second hop as the start of the next request on a shared connection. It is stopped by agreement and strictness: refuse messages with both headers or two different lengths and close the connection, use one maintained parser with no lenient flags at the edge, apply the same rules on every hop, and validate HTTP/2 fields before translating them to HTTP/1.1.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to senior

    A user forwards a password-reset email from your shop whose link points to a domain you do not own. Your logs show the reset was requested for their address by an unknown client. What happened, and what do you change?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    Your sign-in endpoint limits attempts per client address. Since you moved behind a CDN and a load balancer, the limit either blocks everyone at once or never stops one abusive client. Explain both symptoms and design the fix.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Why must the link in a password-reset email not be built from the Host header?

    Host is a value the client chose, so a request naming other.example puts that host, with the reset token, into the email the real user receives. Build every link, redirect and email from the origin in configuration, accept only the hosts you serve (the idea of Django's ALLOWED_HOSTS) and answer others with 400.

  2. How do you find the real client address behind proxies?

    Begin at the connection and read X-Forwarded-For (or Forwarded, RFC 7239) from right to left, skipping your own trusted proxies (known by their address ranges) and reading all lines as one list. The leftmost entry is the client's own text, and a hop count fails when a request reaches the balancer directly. Believe X-Forwarded-Host and X-Forwarded-Proto the same way (ASVS 5.0.0 4.1.3 and 15.3.4).

  3. What goes wrong when an edge rule and the app read a request target differently?

    A path the edge reads as public the app reads as admin: /Admin/users (the router ignores case), %2e%2e segments and backslashes (the URL standard resolves them), //admin/users (read as a host by new URL(target, base)). Turn each target into one canonical form once, refuse any that parsing would change with 400, and decide access in the app on the path it routes on.

  4. How should repeated parameters, header names with underscores and percent-escapes be handled?

    Refuse a repeated key where one value is expected, since get() returns the first and Object.fromEntries() the last. Drop or refuse header names with underscores, because CGI turns X-Forwarded-For and X_Forwarded_For into the same variable. Decode percent-escapes once, in one place, and refuse what still holds an escape for a dot, slash or backslash (%252e).

  5. What is request smuggling, and what stops it?

    Hiding one request inside another because two hops find different ends for the same HTTP/1.1 message, for example one trusts Content-Length and the next Transfer-Encoding: chunked (RFC 9112 section 6.3 says Transfer-Encoding wins). Refuse a message with both headers or two different lengths and close the connection, use one strict maintained parser at the edge with the same rules on every hop, and validate HTTP/2 fields before translating them. It is CWE-444.

  6. What does Node's HTTP server refuse by default, and what is left to the application?

    It refuses malformed messages (400, while insecureHTTPParser is off), a missing Host, and headers over 16 KiB (431): 9 of the lesson's 15 raw requests. Of the 6 that reached the handler the app had to refuse 4, such as two Host lines (Node keeps the first; RFC 9112 asks for 400) and a path inside Host. Do not switch on a lenient parser for one broken client.

Key takeaways

  • Host is chosen by the client: refuse hosts you do not serve, and build every link, redirect and email from the origin in your configuration.
  • Read X-Forwarded-For from the connection leftwards past your own proxies, by their address ranges, as one list; the leftmost entry is the client’s own text, and a hop count fails when a shorter path exists.
  • Turn every target into one canonical form, refuse what parsing would change, and decide access in the app on the same path it routes on; refuse repeated parameters and header names with underscores.
  • Two hops that frame one message differently open the way to request smuggling, so a message with both Content-Length and Transfer-Encoding is refused and its connection closed.
  • Know what your parser refuses and test it with raw requests: Node refused 9 of 15 and passed 6, of which the application had to refuse 4 itself.

Terms in this lesson

canonical form
The one agreed way of writing a value, such as a path with no dot segments, no encoded slashes and one case rule. Code that checks and code that acts both use it, so they cannot read the same input differently.
message framing
How a recipient finds where an HTTP/1.1 message ends: by Content-Length, by Transfer-Encoding chunked, or by the connection closing. Every hop must reach the same answer for the same bytes.
request smuggling
Hiding a request inside another one by exploiting two hops that find different ends for the same HTTP message, such as a proxy reading Content-Length and a server reading Transfer-Encoding (RFC 9112 section 11.2).
trusted proxy
A proxy of your own, such as a load balancer or a CDN edge, whose added headers the app believes. The app trusts forwarded headers only on connections from the proxies' known addresses, never what a client wrote.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.