From af11bee5c1ccc24f8e096ac40d551ae14c1b4f90 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sat, 15 Aug 2026 15:44:34 -0400 Subject: [PATCH 1/3] test: expose spoofed client address trust --- tests/node.test.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/node.test.ts b/tests/node.test.ts index f1f3742..d575020 100644 --- a/tests/node.test.ts +++ b/tests/node.test.ts @@ -26,6 +26,37 @@ async function withServer( } describe("Node adapter", () => { + it("should overwrite spoofed client addresses with the TCP peer", async () => { + const observed: Array<{ address: string | null; forwarded: string | null }> = []; + await withServer( + { + async fetch(request) { + observed.push({ + address: request.headers.get("x-askr-client-address"), + forwarded: request.headers.get("x-forwarded-for"), + }); + return new Response(); + }, + }, + async (origin) => { + for (const spoofed of ["198.51.100.1", "203.0.113.200"]) { + const response = await fetch(origin, { + headers: { + "x-askr-client-address": spoofed, + "x-forwarded-for": spoofed, + }, + }); + expect(response.status).toBe(200); + } + }, + ); + + expect(observed).toEqual([ + { address: "127.0.0.1", forwarded: "198.51.100.1" }, + { address: "127.0.0.1", forwarded: "203.0.113.200" }, + ]); + }); + it("should bind to loopback by default and require public bind opt-in", async () => { const app = { fetch: async () => new Response() }; const local = await listen(app); From 7cc10e1ad6f9d1cad9a9eb2aa81993ef30e3ca54 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sat, 15 Aug 2026 15:45:52 -0400 Subject: [PATCH 2/3] fix: authenticate Node client addresses --- README.md | 7 +++++++ src/client-address.ts | 17 +++++++++++++++++ src/index.ts | 1 + src/request.ts | 2 ++ tests/node.test.ts | 26 ++++++++++++++++++++++++-- 5 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 src/client-address.ts diff --git a/README.md b/README.md index e02ecee..0adbc30 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,13 @@ callback. It preserves streaming bodies, repeated `Set-Cookie` headers, aborts, status text, and HEAD responses. `next` receives adapter failures; application responses, including `404`, remain owned by the `ServerApp` and do not fall through. +Every dispatched request includes `CLIENT_ADDRESS_HEADER` (`x-askr-client-address`) set from the +TCP socket peer. The adapter overwrites a client-supplied value and does not interpret +`X-Forwarded-For`, so applications can use this header for direct-listener IP controls without +trusting attacker-controlled forwarding metadata. Deployments behind a reverse proxy see the +proxy peer by default. Supporting original client addresses requires an explicit trusted-proxy +boundary; do not read `X-Forwarded-For` directly in application code. + Every handler must have a trusted URL boundary. Pass `baseUrl` when the external origin is fixed, or `allowedHosts` when the request `Host` determines the origin. Host names are canonicalized and compared case-insensitively; entries without a port allow that host on any port, while entries with diff --git a/src/client-address.ts b/src/client-address.ts new file mode 100644 index 0000000..f95d9d3 --- /dev/null +++ b/src/client-address.ts @@ -0,0 +1,17 @@ +import { isIP } from "node:net"; + +/** + * Reserved request header containing the TCP peer address authenticated by the Node adapter. + * Any value supplied by the HTTP client is overwritten before application dispatch. + */ +export const CLIENT_ADDRESS_HEADER = "x-askr-client-address"; + +/** Normalizes the socket peer address used for the adapter-authenticated request header. */ +export function normalizeClientAddress(address: string | undefined): string { + if (!address) return "unknown"; + if (address.toLowerCase().startsWith("::ffff:")) { + const mapped = address.slice(7); + if (isIP(mapped) === 4) return mapped; + } + return address; +} diff --git a/src/index.ts b/src/index.ts index b7263df..186ccfc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from "./client-address.js"; export * from "./contracts.js"; export * from "./handler.js"; export * from "./listen.js"; diff --git a/src/request.ts b/src/request.ts index 208bc68..8d2489a 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,5 +1,6 @@ import type { IncomingMessage } from "node:http"; import { isIP } from "node:net"; +import { CLIENT_ADDRESS_HEADER, normalizeClientAddress } from "./client-address.js"; import type { NodeHandlerOptions } from "./contracts.js"; export class NodeRequestError extends TypeError {} @@ -116,6 +117,7 @@ function requestHeaders(request: IncomingMessage): Headers { headers.set(key, value); } } + headers.set(CLIENT_ADDRESS_HEADER, normalizeClientAddress(request.socket.remoteAddress)); return headers; } diff --git a/tests/node.test.ts b/tests/node.test.ts index d575020..c0192d6 100644 --- a/tests/node.test.ts +++ b/tests/node.test.ts @@ -8,7 +8,8 @@ import { createRouter, createServerApp } from "@askrjs/server"; import { describe, expect, it } from "vitest"; import WebSocket from "ws"; import { formatHostForUrl } from "../src/bind.js"; -import { createNodeHandler, listen, serve } from "../src/index.js"; +import { normalizeClientAddress } from "../src/client-address.js"; +import { CLIENT_ADDRESS_HEADER, createNodeHandler, listen, serve } from "../src/index.js"; import { writeNodeResponse } from "../src/response.js"; async function withServer( @@ -26,13 +27,34 @@ async function withServer( } describe("Node adapter", () => { + it("should normalize client addresses without conflating distinct peers", () => { + expect(CLIENT_ADDRESS_HEADER).toBe("x-askr-client-address"); + expect([ + normalizeClientAddress(undefined), + normalizeClientAddress(""), + normalizeClientAddress("127.0.0.1"), + normalizeClientAddress("::1"), + normalizeClientAddress("2001:db8::10"), + normalizeClientAddress("::ffff:192.0.2.4"), + normalizeClientAddress("::FFFF:198.51.100.9"), + ]).toEqual([ + "unknown", + "unknown", + "127.0.0.1", + "::1", + "2001:db8::10", + "192.0.2.4", + "198.51.100.9", + ]); + }); + it("should overwrite spoofed client addresses with the TCP peer", async () => { const observed: Array<{ address: string | null; forwarded: string | null }> = []; await withServer( { async fetch(request) { observed.push({ - address: request.headers.get("x-askr-client-address"), + address: request.headers.get(CLIENT_ADDRESS_HEADER), forwarded: request.headers.get("x-forwarded-for"), }); return new Response(); From d39f9aa4f5312fde13944f3db2c649bb51570582 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sat, 15 Aug 2026 15:46:04 -0400 Subject: [PATCH 3/3] chore: prepare node 0.0.11 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 16d382c..94f65a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@askrjs/node", - "version": "0.0.10", + "version": "0.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@askrjs/node", - "version": "0.0.10", + "version": "0.0.11", "license": "Apache-2.0", "dependencies": { "@askrjs/auth": ">=0.0.8 <0.1.0", diff --git a/package.json b/package.json index a6ab2e6..46729e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@askrjs/node", - "version": "0.0.10", + "version": "0.0.11", "description": "Node http adapter for @askrjs/server", "keywords": [ "askr",