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/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", 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 f1f3742..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,6 +27,58 @@ 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(CLIENT_ADDRESS_HEADER), + 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);