---
title: 'Reverse Proxy Configuration'
source: 'https://academia.sh/en/courses/servers-and-hardening/reverse-proxy-configuration'
course: 'Servers, Networking and Hardening'
language: en
updated: '2026-09-13T05:08:31+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Counts, one by one, the four pieces of information lost while passing through the proxy chain, on a real request/response'
    - 'Shows the wrong decision each loss produces in the application, through the audit log and the generated link'
    - 'Measures how the second hop erases the address the first hop forwarded'
    - 'Measures the surface opened by trusting a forwarded header using spoofed-header requests, and names the single point that closes it'
---

# Reverse Proxy Configuration

Counting the four pieces of information lost by passing a real request through a two-hop proxy chain: client address, protocol, server name, and the request's original path; the wrong decision each loss produces in the application (three members recorded under a single identity, an absolute link pointing at an internal address, a link that returns 404 once the prefix is stripped), the second hop erasing the first hop's forwarding, and the claimed-identity surface opened by trusting the forwarded header.

The previous lesson counted the surface from outside the server: how many ports are listening,
how many paths are served, how many endpoints are open. The whole count was about the request
**arriving**. This lesson measures what happens after the request arrives.

The reverse proxy takes the request and forwards it. The word "forwards" hides what actually
happens: the proxy does not carry the request, it **rebuilds** it. The connection the client
opened ends at the proxy; the proxy opens a new connection of its own to the back end. At the
other end of the new connection stands the proxy, not the client. Whatever traveled in the
connection itself is erased at that point. What a reverse proxy is and how it is configured when
taking an application to production were measured before; what is measured here is the **item of
lost information** and the surface that loss opens.

The fictional regional measurement network has two proxies in front of it: an outward-facing edge
proxy and an internal proxy that selects the environment. Field handheld terminals connect to the
edge proxy with the `/api` prefix, the edge proxy connects to the internal proxy, and the internal
proxy connects to the measurement app. Two hops, three connections.

- **WS7.** The app reads **the leftmost entry** in the relay header; when the relay chain grows
  longer with commas, the value read does not change. This is the common way a forwarded identity
  is read.
- **WS8.** On a single machine, every client shares the same loopback address. The fictional
  member address is derived at the edge proxy from the source-port block; in a real deployment
  this value comes straight from the connection.
- **WS9.** The edge proxy and the internal proxy are **the same file**; every difference between
  them lives in the config string.
- **WS10.** The app's internal summary endpoint can only be called from `10.9.`-prefixed
  addresses; the access decision rests on **the resolved address**.
- **WS11.** The measurement does not measure time; the request counts, source-port blocks, and
  expected values are constants independent of the run.

```js
// chain/app.mjs — the measurement app. Resolves the request's four pieces of info first from
// the "x-relay-*" headers, else from the connection itself, and produces a decision from each.
import { createServer } from "node:http";

const port = Number(process.argv[2]);
const INTERNAL_NET = "10.9.";                           // internal-network prefix
const log = { address: new Set(), allowed: 0, rejected: 0 };

const resolve = (i) => ({
  // The relay chain grows with commas; the app reads the leftmost entry.
  address: String(i.headers["x-relay-address"] ?? i.socket.remoteAddress).split(",")[0].trim(),
  scheme: i.headers["x-relay-scheme"] ?? "plaintext",
  name: i.headers["x-relay-name"] ?? i.headers.host,
  prefix: i.headers["x-relay-prefix"] ?? "",
});

if (Number.isInteger(port) === false) console.log("usage: node chain/app.mjs <port>");
else createServer((req, res) => {
  res.sendDate = false;
  const path = req.url;
  const c = resolve(req);
  if (path === "/dump") {
    res.writeHead(200).end(JSON.stringify({ ...log, identities: log.address.size, address: [...log.address] }));
    return;
  }
  if (path === "/internal/summary") {                    // endpoint meant to be called only from the internal network
    if (c.address.startsWith(INTERNAL_NET)) { log.allowed += 1; res.writeHead(200).end("internal summary"); }
    else { log.rejected += 1; res.writeHead(403).end("from outside"); }
    return;
  }
  const parts = path.split("/");
  if (parts[1] !== "measurement" || parts.length > 4) { res.writeHead(404).end("no path"); return; }
  if (parts.length === 4 && parts[3] !== "detail") { res.writeHead(404).end("no path"); return; }
  log.address.add(c.address);                            // identity written to the audit log
  res.writeHead(200, {
    "x-address": c.address, "x-scheme": c.scheme, "x-name": c.name, "x-prefix": c.prefix || "none",
    "x-decision": c.scheme === "encrypted" ? "serve" : "forward",
    "x-link": `${c.scheme}://${c.name}${c.prefix}${path}/detail`,
  }).end("measurement");
}).listen(port, "127.0.0.1");
```

The proxy takes six settings from a single string. In a product, each of these would be a
configuration line; here it is one string field.

```js
// chain/proxy.mjs — the reverse proxy. The same file runs as both the edge and internal proxy;
// the config string decides behavior. Config: address,scheme,name,prefixhdr = on|off; prefix=<path>; clear=on;
// chain=write|append|keep; map=on (edge proxy only: connection -> fictional client address).
import { createServer, request } from "node:http";

const [port, target, cfgText] = [Number(process.argv[2]), Number(process.argv[3]), process.argv[4] ?? ""];
const CFG = Object.fromEntries(cfgText.split(",").filter(Boolean).map((p) => p.split("=")));
// WS8: on a single machine every client shares the same loopback address. The fictional member
// address is derived from the source-port block; in a real deployment the value comes straight from the connection.
const ADDR_BOOK = { 512: "203.0.113.11", 513: "203.0.113.12", 514: "203.0.113.13" };
const source = (s) => (CFG.map === "on" ? ADDR_BOOK[Math.floor(s.remotePort / 100)] : undefined) ?? s.remoteAddress;

if (Number.isInteger(port) === false || Number.isInteger(target) === false) {
  console.log("usage: node chain/proxy.mjs <port> <target> <config>");
} else createServer((frontReq, frontRes) => {
  frontRes.sendDate = false;
  const hdrs = { ...frontReq.headers };
  if (CFG.clear === "on") for (const k of Object.keys(hdrs)) if (k.startsWith("x-relay-")) delete hdrs[k];
  const existing = hdrs["x-relay-address"];
  if (CFG.address === "on") {
    const own = source(frontReq.socket);
    if (CFG.chain === "keep" && existing) hdrs["x-relay-address"] = existing;
    else if (CFG.chain === "append" && existing) hdrs["x-relay-address"] = `${existing}, ${own}`;
    else hdrs["x-relay-address"] = own;
  }
  if (CFG.scheme === "on") hdrs["x-relay-scheme"] = "encrypted";
  if (CFG.name === "on") hdrs["x-relay-name"] = hdrs["x-relay-name"] ?? frontReq.headers.host;
  let path = frontReq.url;
  if (CFG.prefix) {
    if (path.startsWith(CFG.prefix) === false) { frontRes.writeHead(404).end("no path"); return; }
    path = path.slice(CFG.prefix.length) || "/";              // prefix is stripped; the back end never sees it
    if (CFG.prefixhdr === "on") hdrs["x-relay-prefix"] = CFG.prefix;
  }
  hdrs.host = `127.0.0.1:${target}`;                          // requested name is replaced with the upstream's name
  const backReq = request({ port: target, path, method: frontReq.method, headers: hdrs }, (backRes) => {
    frontRes.writeHead(backRes.statusCode, backRes.headers);
    backRes.pipe(frontRes);
  });
  backReq.on("error", () => { if (frontRes.headersSent === false) frontRes.writeHead(502).end("cut off"); });
  frontReq.pipe(backReq);
}).listen(port, "127.0.0.1");
```

The client connects three members from separate source-port blocks, compares the four resolved
values against the expected ones, follows the link the application generates, and reads the
identity count in the audit log.

```js
// chain/client.mjs — three members connect to the chain from separate source-port blocks.
// Usage: node chain/client.mjs <edge> <app> <scenario> <label> <shift>
import { request } from "node:http";

const [edge, app, scenario, label, shift] =
  [Number(process.argv[2]), Number(process.argv[3]), process.argv[4], process.argv[5] ?? "-", Number(process.argv[6] ?? 0)];
const NAME = "measurement.example.invalid";                // fictional name
const EXPECTED = { address: "203.0.113.11", scheme: "encrypted", name: NAME, prefix: "/api" };

const call = ({ path, source, port = edge, headers = {} }) => new Promise((resolve) => {
  const r = request({ port, path, agent: false, localPort: source, headers: { host: NAME, ...headers } },
    (y) => { let g = ""; y.on("data", (d) => { g += d; }); y.on("end", () => resolve({ code: y.statusCode, h: y.headers, body: g })); });
  r.on("error", (e) => resolve({ code: 0, h: {}, body: e.code }));
  r.end();
});

if (scenario === "lost") {
  let first = null;
  for (let i = 0; i < 3; i += 1) {
    const y = await call({ path: "/api/measurement/7", source: 51200 + i * 100 + shift * 5 });
    first = first ?? y;
  }
  const dump = JSON.parse((await call({ path: "/dump", port: app, source: 51700 + shift })).body);
  const link = first.h["x-link"] ?? "-";
  const linkPath = link.slice(link.indexOf("://") + 3).replace(/^[^/]*/, "");
  const followed = await call({ path: linkPath, source: 51800 + shift });
  const correct = ["address", "scheme", "name", "prefix"].filter((k) => first.h[`x-${k}`] === EXPECTED[k]).length;
  console.log(`${label.padEnd(18)}resolved ${correct}/4  log ${dump.identities} identities  ` +
    `decision ${String(first.h["x-decision"]).padEnd(10)}link followed ${followed.code}`);
  console.log(`${" ".repeat(18)}address ${String(first.h["x-address"]).padEnd(15)} name ${String(first.h["x-name"]).padEnd(30)} prefix ${first.h["x-prefix"]}`);
} else if (scenario === "spoofed") {
  let entered = 0;
  for (let i = 0; i < 4; i += 1) {
    const y = await call({ path: "/api/internal/summary", source: 51500 + i + shift * 10, headers: { "x-relay-address": "10.9.0.5" } });
    if (y.code === 200) entered += 1;
  }
  const dump = JSON.parse((await call({ path: "/dump", port: app, source: 51900 + shift })).body);
  console.log(`${label.padEnd(18)}entered internal endpoint ${entered}/4  app rejected ${dump.rejected}`);
} else console.log("usage: node chain/client.mjs <edge> <app> <scenario> <label> <shift>");
```

```bash
# run.sh — the same request chain with different relay configs. All three hops restart on every run.
R0="prefix=/api,map=on"                   # same on every run: prefix is stripped, source map at the edge
run() {                                   # run <edge-extra> <internal-cfg> <scenario> <label> <shift>
  node chain/app.mjs 8942 & A=$!
  node chain/proxy.mjs 8941 8942 "$2" & I=$!
  node chain/proxy.mjs 8940 8941 "$R0$1" & E=$!
  sleep 0.5
  node chain/client.mjs 8940 8942 "$3" "$4" "$5"
  kill $E $I $A 2>/dev/null; wait $E $I $A 2>/dev/null; sleep 0.2
}
D=",clear=on,address=on,scheme=on,name=on,prefixhdr=on"      # all four headers are forwarded

run ",clear=on"                     "" lost    "no forwarding"   0
run ",clear=on,address=on,scheme=on" "" lost    "address+scheme"  1
run "$D"                                "" lost    "four headers"    2
run "$D"       "address=on,chain=write"    lost    "second hop"     3
run ",address=on,scheme=on,name=on,prefixhdr=on,chain=append" "" spoofed "no clearing" 4
run "$D"                                "" spoofed "clearing"       5
```

```
no forwarding     resolved 0/4  log 1 identities  decision forward   link followed 404
                  address 127.0.0.1       name 127.0.0.1:8942                 prefix none
address+scheme    resolved 2/4  log 3 identities  decision serve     link followed 404
                  address 203.0.113.11    name 127.0.0.1:8942                 prefix none
four headers      resolved 4/4  log 3 identities  decision serve     link followed 200
                  address 203.0.113.11    name measurement.example.invalid    prefix /api
second hop        resolved 3/4  log 1 identities  decision serve     link followed 200
                  address 127.0.0.1       name measurement.example.invalid    prefix /api
no clearing       entered internal endpoint 4/4  app rejected 0
clearing          entered internal endpoint 0/4  app rejected 4
```

## From Zero to Four

The first row is the default state: both proxies forward the request, neither relays anything.
The app loses **all four** pieces of information and produces four separate wrong decisions.

**Client address.** Three separate members sent three separate requests; the audit log has
**one** identity. The log is not missing anything, it is full — the address it wrote is just the
internal proxy's address. When asked which handheld terminal an event came from, the log answers,
and the answer is wrong.

**Protocol.** The connection reaching the app is plaintext, because encryption ended at the edge
proxy. The app decides it sees a plaintext request and says `forward`. The decision itself is a
correctly written rule; its input is wrong.

**Server name.** The client asked for the name `measurement.example.invalid`. When the proxy
connected to the back end, it replaced the requested name with the upstream's own name — this is
not a bug, it is how the connection is set up. The app writes `127.0.0.1:8942` into the absolute
link it generates. If this link were shown on a page or put in a notification email, the internal
address would leak outward.

**Original path.** The edge proxy stripped the `/api` prefix. Since the app has no idea the
prefix existed, it does not put the prefix into the link it generates. When the link is followed,
the edge proxy does not match it against its own routing rule and returns **404**. The link in the
notification email sent to the field crew is a link that does not work the day it is opened.

The second row turns on two headers. The identity count in the audit log climbs not to one but to
**three**, and the decision becomes `serve`. Two items closed; the name and the prefix are still
lost and the link is still 404. This row shows the most misleading part of partial relaying:
because the log looks correct, the configuration is assumed to be complete, when in fact both
closed items were items visible inside the log. The two items invisible in the log cannot be found
by looking at the log.

The third row turns on all four headers: all four pieces of information resolve, the log has three
identities, and the generated link returns 200 when followed.

## What the Second Hop Erases

In the fourth row, the edge proxy's configuration is **identical** to the third row's. The only
thing that changes is that the internal proxy also turns on address relaying.

The result: resolved information drops from four to three, and the identity count in the audit
log falls from three to one. The internal proxy overwrote the header the edge proxy wrote with the
address it sees itself; the address it sees is the edge proxy's address. Even though relaying is
turned on at both hops, the value that reaches the app is not the client's address.

This is a failure that is harder to find than the first row's. In the first row there was no
relaying at all, and a configuration review catches that. In the fourth row, **relaying is turned
on at both proxies**; a review says "relaying is on" and moves past it. The difference is in a
single word — the second hop overwrites the incoming value instead of preserving it.

Chain length becomes a multiplier here: for relaying to work correctly, **every** hop in the chain
must preserve the incoming value or append to it. Two hops mean two decisions, four hops mean four
decisions, and all of them must be right; one being wrong is enough.

## Trusting the Forwarded Header

The last two rows are the other side of the measurement. The app now **bases a decision** on the
relayed address: the internal summary endpoint can only be called from internal-network addresses.
The moment the header that closes the loss closes it, that header turns into an authorization
input.

In the fifth row, the edge proxy does not clear the incoming `x-relay-*` headers and appends to
the chain. The client writes `10.9.0.5` into its own request. The edge proxy appends the address
it sees on top of that, the header ends up with two entries, and the app reads the leftmost entry:
what the client wrote. **All four** of the four requests get into the internal endpoint, the app
rejects none of them, and the four requests sit in the logs as successful internal calls.

In the sixth row, the edge proxy clears the headers. Zero of the four requests get in, and the app
rejects all four.

The name of the surface this opens is **the claimed-identity surface**: every relay header the app
trusts is an input under the client's control unless every hop in front of it strips it. This
surface opens in two ways. The first is forgetting to clear it — the fifth row. The second is
**the upstream port** the previous lesson counted: if the internal proxy's and the app's port
listens on all of the machine's interfaces, a request that never passes through the edge proxy at
all can write the header itself, and clearing never runs. The edge proxy's single-line clearing
rule only counts as a rule for as long as the chain has a single entry point.

## Four Items, Two Directions

Each of the four pieces of information produces two separate items: a wrong decision while closed,
a trust point while open.

| Information | Wrong decision produced while closed | Surface born while open |
|---|---|---|
| Client address | three members collapse into a single identity in the log | the address-based access decision falls to whatever value the client writes |
| Protocol | an encrypted request is counted as plaintext, decision becomes `forward` | the app never verifies encryption itself, it trusts the header |
| Server name | the generated link points at the internal address | a rule that distinguishes by name can be bypassed with a spoofed name |
| Original path | the prefix drops, the generated link gets 404 | a per-prefix authorization rule can be bypassed with a spoofed prefix |

The table's right column is the lesson's real result. The only way to close the loss is to carry
the information in a header, and a header is not provable the way the connection itself is. While
relaying is off, the app behaves correctly with wrong information; while it is on, it behaves with
correct information, and that information's correctness now depends on every hop in the chain
being disciplined. The four lost items were closed; four trust points that need auditing took
their place.

## Summary

- Four pieces of information get lost passing through the proxy chain: client address, protocol,
  requested server name, and the request's original path; with relaying off, the app resolved all
  four incorrectly.
- All four losses produced a visible decision: three members collapsed into a single identity in
  the log, the decision became `forward`, the generated link pointed at the internal address, and
  following it returned 404 because the prefix was missing.
- Relaying two headers fixed the log but not the link: the log showed three identities, the link
  still returned 404; the remaining two items cannot be found by looking at the log.
- Even with relaying on at both proxies, when the second hop overwrote the incoming value instead
  of preserving it, resolved information dropped from four to three and the identity count in the
  log fell from three to one.
- Once a decision was based on the relayed header, the claimed-identity surface opened: with
  clearing off, all four of the four spoofed-header requests got into the internal endpoint; with
  clearing on, zero got in.

## Next Step

Every measurement in this lesson assumed encryption **ends somewhere**: the edge proxy decrypts,
and everything behind it carries plaintext. That termination happens at the edge proxy was never
questioned, yet this is a decision, and it can be made in three places.

When the termination point changes, three numbers change at once: how many legs of the chain stay
plaintext, how many separate places the certificate sits in, and how many points get touched
during renewal. The three do not move in the same direction; the option that drops the plaintext
legs to zero increases both the number of places the certificate lives and the cost of renewing
it. The next lesson compares the three termination options with these three numbers, models the
accepted protocol versions and cipher suites as a set of items, and measures how many old options
the default leaves open and how many clients a restriction drops.
