Skip to content

Commit be44d90

Browse files
Copilotkitsonk
andauthored
Support Forwarded header (RFC 7239) alongside X-Forwarded-* (#709)
Fixes #658 Co-authored-by: kitsonk <1282577+kitsonk@users.noreply.github.com>
1 parent a766bcf commit be44d90

2 files changed

Lines changed: 181 additions & 21 deletions

File tree

request.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,3 +367,93 @@ Deno.test({
367367
assertEquals(request.url.protocol, "http:");
368368
},
369369
});
370+
371+
Deno.test({
372+
name: "request with Forwarded header - for, proto, host",
373+
fn() {
374+
const request = new Request(
375+
createMockNativeRequest("http://internal/index.html", {
376+
headers: {
377+
"forwarded":
378+
"for=10.10.10.10;proto=https;host=example.com, for=192.168.1.1",
379+
},
380+
}),
381+
{ proxy: true },
382+
);
383+
assertEquals(request.ips, ["10.10.10.10", "192.168.1.1"]);
384+
assertEquals(request.ip, "10.10.10.10");
385+
assertEquals(request.url.protocol, "https:");
386+
assertEquals(request.url.hostname, "example.com");
387+
},
388+
});
389+
390+
Deno.test({
391+
name: "request.Forwarded - IPv6 address (quoted brackets)",
392+
fn() {
393+
const request = new Request(
394+
createMockNativeRequest("http://internal/index.html", {
395+
headers: {
396+
"forwarded": `for="[::1]";proto=http;host=example.com`,
397+
},
398+
}),
399+
{ proxy: true },
400+
);
401+
assertEquals(request.ips, ["[::1]"]);
402+
assertEquals(request.ip, "[::1]");
403+
},
404+
});
405+
406+
Deno.test({
407+
name: "request.Forwarded - takes precedence over X-Forwarded-*",
408+
fn() {
409+
const request = new Request(
410+
createMockNativeRequest("http://internal/index.html", {
411+
headers: {
412+
"forwarded": "for=10.10.10.10;proto=https;host=example.com",
413+
"x-forwarded-for": "1.2.3.4",
414+
"x-forwarded-proto": "http",
415+
"x-forwarded-host": "other.example.com",
416+
},
417+
}),
418+
{ proxy: true },
419+
);
420+
// Forwarded header wins
421+
assertEquals(request.ips, ["10.10.10.10"]);
422+
assertEquals(request.url.protocol, "https:");
423+
assertEquals(request.url.hostname, "example.com");
424+
},
425+
});
426+
427+
Deno.test({
428+
name: "request.Forwarded - invalid proto falls back to http",
429+
fn() {
430+
const request = new Request(
431+
createMockNativeRequest("http://internal/index.html", {
432+
headers: {
433+
"forwarded": "for=10.10.10.10;proto=javascript;host=example.com",
434+
},
435+
}),
436+
{ proxy: true },
437+
);
438+
assertEquals(request.url.protocol, "http:");
439+
},
440+
});
441+
442+
Deno.test({
443+
name: "request.Forwarded - falls back to X-Forwarded-* when absent",
444+
fn() {
445+
const request = new Request(
446+
createMockNativeRequest("http://internal/index.html", {
447+
headers: {
448+
"x-forwarded-for": "10.10.10.10, 192.168.1.1",
449+
"x-forwarded-proto": "https",
450+
"x-forwarded-host": "example.com",
451+
},
452+
}),
453+
{ proxy: true },
454+
);
455+
assertEquals(request.ips, ["10.10.10.10", "192.168.1.1"]);
456+
assertEquals(request.url.protocol, "https:");
457+
assertEquals(request.url.hostname, "example.com");
458+
},
459+
});

request.ts

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,47 @@ interface OakRequestOptions {
2727
secure?: boolean;
2828
}
2929

30+
/** A parsed entry from the RFC 7239 `Forwarded` header. */
31+
interface ForwardedEntry {
32+
for?: string;
33+
proto?: string;
34+
host?: string;
35+
by?: string;
36+
}
37+
38+
/**
39+
* Parse the value of a `Forwarded` header per RFC 7239.
40+
*
41+
* Each forwarded-element is comma-separated; within each element,
42+
* forwarded-pairs are semicolon-separated as `key=value`. Values may be
43+
* optionally quoted.
44+
*/
45+
function parseForwarded(value: string): ForwardedEntry[] {
46+
const bounded = value.length > 4096 ? value.slice(0, 4096) : value;
47+
const result: ForwardedEntry[] = [];
48+
for (const element of bounded.split(",")) {
49+
const entry: ForwardedEntry = {};
50+
for (const pair of element.split(";")) {
51+
const eqIdx = pair.indexOf("=");
52+
if (eqIdx < 0) continue;
53+
const key = pair.slice(0, eqIdx).trim().toLowerCase();
54+
let val = pair.slice(eqIdx + 1).trim();
55+
if (val.length >= 2 && val[0] === '"' && val[val.length - 1] === '"') {
56+
// RFC 7230 §3.2.6 quoted-string unescaping: remove the surrounding
57+
// quotes and replace any backslash-escaped character with the character
58+
// itself (e.g. `\"` → `"`, `\\` → `\`).
59+
val = val.slice(1, -1).replace(/\\(.)/g, "$1");
60+
}
61+
if (key === "for" || key === "proto" || key === "host" || key === "by") {
62+
entry[key as keyof ForwardedEntry] = val;
63+
}
64+
}
65+
result.push(entry);
66+
if (result.length >= 100) break;
67+
}
68+
return result;
69+
}
70+
3071
/** An interface which provides information about the current request. The
3172
* instance related to the current request is available on the
3273
* {@linkcode Context}'s `.request` property.
@@ -37,6 +78,7 @@ interface OakRequestOptions {
3778
*/
3879
export class Request {
3980
#body: Body;
81+
#forwarded?: ForwardedEntry[] | null;
4082
#proxy: boolean;
4183
#secure: boolean;
4284
#serverRequest: ServerRequest;
@@ -47,6 +89,14 @@ export class Request {
4789
return this.#serverRequest.remoteAddr ?? "";
4890
}
4991

92+
#getForwarded(): ForwardedEntry[] | null {
93+
if (this.#forwarded === undefined) {
94+
const value = this.#serverRequest.headers.get("forwarded");
95+
this.#forwarded = value ? parseForwarded(value) : null;
96+
}
97+
return this.#forwarded;
98+
}
99+
50100
/** An interface to access the body of the request. This provides an API that
51101
* aligned to the **Fetch Request** API, but in a dedicated API.
52102
*/
@@ -72,27 +122,34 @@ export class Request {
72122
}
73123

74124
/** Request remote address. When the application's `.proxy` is true, the
75-
* `X-Forwarded-For` will be used to determine the requesting remote address.
125+
* `Forwarded` header (RFC 7239) will be checked first, falling back to
126+
* `X-Forwarded-For`, to determine the requesting remote address.
76127
*/
77128
get ip(): string {
78129
return (this.#proxy ? this.ips[0] : this.#getRemoteAddr()) ?? "";
79130
}
80131

81132
/** When the application's `.proxy` is `true`, this will be set to an array of
82133
* IPs, ordered from upstream to downstream, based on the value of the header
83-
* `X-Forwarded-For`. When `false` an empty array is returned. */
134+
* `Forwarded` (RFC 7239) if present, otherwise `X-Forwarded-For`. When
135+
* `false` an empty array is returned. */
84136
get ips(): string[] {
85-
return this.#proxy
86-
? (() => {
87-
const raw = this.#serverRequest.headers.get("x-forwarded-for") ??
88-
this.#getRemoteAddr();
89-
const bounded = raw.length > 4096 ? raw.slice(0, 4096) : raw;
90-
return bounded
91-
.split(",", 100)
92-
.map((part) => part.trim())
93-
.filter((part) => part.length > 0);
94-
})()
95-
: [];
137+
if (!this.#proxy) {
138+
return [];
139+
}
140+
const forwarded = this.#getForwarded();
141+
if (forwarded) {
142+
return forwarded
143+
.map((e) => e.for)
144+
.filter((f): f is string => f !== undefined && f.length > 0);
145+
}
146+
const raw = this.#serverRequest.headers.get("x-forwarded-for") ??
147+
this.#getRemoteAddr();
148+
const bounded = raw.length > 4096 ? raw.slice(0, 4096) : raw;
149+
return bounded
150+
.split(",", 100)
151+
.map((part) => part.trim())
152+
.filter((part) => part.length > 0);
96153
}
97154

98155
/** The HTTP Method used by the request. */
@@ -125,7 +182,8 @@ export class Request {
125182

126183
/** A parsed URL for the request which complies with the browser standards.
127184
* When the application's `.proxy` is `true`, this value will be based off of
128-
* the `X-Forwarded-Proto` and `X-Forwarded-Host` header values if present in
185+
* the `Forwarded` header (RFC 7239) if present, otherwise the
186+
* `X-Forwarded-Proto` and `X-Forwarded-Host` header values if present in
129187
* the request. */
130188
get url(): URL {
131189
if (!this.#url) {
@@ -145,17 +203,29 @@ export class Request {
145203
let proto: string;
146204
let host: string;
147205
if (this.#proxy) {
148-
const xForwardedProto = serverRequest.headers.get(
149-
"x-forwarded-proto",
150-
);
151-
let maybeProto = xForwardedProto
152-
? xForwardedProto.split(",", 1)[0].trim().toLowerCase()
153-
: undefined;
206+
const forwarded = this.#getForwarded();
207+
const firstForwarded = forwarded?.[0];
208+
let maybeProto: string | undefined;
209+
if (firstForwarded?.proto) {
210+
maybeProto = firstForwarded.proto.toLowerCase();
211+
} else {
212+
const xForwardedProto = serverRequest.headers.get(
213+
"x-forwarded-proto",
214+
);
215+
maybeProto = xForwardedProto
216+
? xForwardedProto.split(",", 1)[0].trim().toLowerCase()
217+
: undefined;
218+
}
154219
if (maybeProto !== "http" && maybeProto !== "https") {
155220
maybeProto = undefined;
156221
}
157222
proto = maybeProto ?? "http";
158-
host = serverRequest.headers.get("x-forwarded-host") ??
223+
// The `host` value from the `Forwarded` header is used as-is, just
224+
// like the legacy `X-Forwarded-Host`. Both require `proxy: true`,
225+
// meaning the operator has declared that the upstream proxy is
226+
// trusted to set these headers correctly.
227+
host = firstForwarded?.host ??
228+
serverRequest.headers.get("x-forwarded-host") ??
159229
this.#url?.hostname ??
160230
serverRequest.headers.get("host") ??
161231
serverRequest.headers.get(":authority") ?? "";

0 commit comments

Comments
 (0)