diff --git a/library/agent/Context.ts b/library/agent/Context.ts index 405cacb43..c76ab4e74 100644 --- a/library/agent/Context.ts +++ b/library/agent/Context.ts @@ -8,7 +8,8 @@ import type { Endpoint } from "./Config"; export type User = { id: string; name?: string }; export type Context = { - url: string | undefined; + url: string | undefined; // Full URL including protocol and host, if available + urlPath?: string | undefined; // The path part of the URL (e.g. /api/user) method: string | undefined; query: ParsedQs; headers: Record; @@ -75,6 +76,7 @@ export function runWithContext(context: Context, fn: () => T) { // In this way we don't lose the `attackDetected` flag if (current) { current.url = context.url; + current.urlPath = context.urlPath; current.method = context.method; current.query = context.query; current.headers = context.headers; diff --git a/library/agent/Source.ts b/library/agent/Source.ts index 2c0148864..99da01bbb 100644 --- a/library/agent/Source.ts +++ b/library/agent/Source.ts @@ -8,7 +8,7 @@ export const SOURCES = [ "xml", "subdomains", "markUnsafe", - "url", + "urlPath", ] as const; export type Source = (typeof SOURCES)[number]; diff --git a/library/helpers/getRawRequestPath.test.ts b/library/helpers/getRawRequestPath.test.ts new file mode 100644 index 000000000..cf5ebeab1 --- /dev/null +++ b/library/helpers/getRawRequestPath.test.ts @@ -0,0 +1,49 @@ +import * as t from "tap"; +import { getRawRequestPath } from "./getRawRequestPath"; + +t.test("it returns the raw URL path", async (t) => { + t.equal(getRawRequestPath(""), "/"); + t.equal(getRawRequestPath("/"), "/"); + t.equal(getRawRequestPath("/?test=abc"), "/"); + t.equal(getRawRequestPath("#"), "/"); + t.equal(getRawRequestPath("https://example.com"), "/"); + + t.equal( + getRawRequestPath("https://example.com/path/to/resource"), + "/path/to/resource" + ); + t.equal( + getRawRequestPath("http://example.com/path/to/resource/"), + "/path/to/resource/" + ); + t.equal( + getRawRequestPath("https://example.com/path/to/resource/123"), + "/path/to/resource/123" + ); + t.equal( + getRawRequestPath("https://example.com/path/to/resource/123/456"), + "/path/to/resource/123/456" + ); + t.equal( + getRawRequestPath("https://example.com/path/to/resource/123/456/789"), + "/path/to/resource/123/456/789" + ); + t.equal( + getRawRequestPath( + "https://example.com/path/to/resource/123/456/789?query=string" + ), + "/path/to/resource/123/456/789" + ); + t.equal( + getRawRequestPath( + "https://example.com/path/to/resource/123/456/789#fragment" + ), + "/path/to/resource/123/456/789" + ); + t.equal( + getRawRequestPath( + "https://example.com/path/to/resource/123/456/789?query=string#fragment" + ), + "/path/to/resource/123/456/789" + ); +}); diff --git a/library/helpers/getRawRequestPath.ts b/library/helpers/getRawRequestPath.ts new file mode 100644 index 000000000..123170051 --- /dev/null +++ b/library/helpers/getRawRequestPath.ts @@ -0,0 +1,22 @@ +export function getRawRequestPath(url: string): string { + let partialUrl = url; + + // Remove protocol (http://, https://, etc.) + const pathStart = partialUrl.indexOf("://"); + if (pathStart !== -1) partialUrl = partialUrl.slice(pathStart + 3); + + // Remove hostname and port + const slashIndex = partialUrl.indexOf("/"); + if (slashIndex === -1) return "/"; // only hostname given + partialUrl = partialUrl.slice(slashIndex); + + // Remove query and fragment + const queryIndex = partialUrl.indexOf("?"); + const hashIndex = partialUrl.indexOf("#"); + + let endIndex = partialUrl.length; + if (queryIndex !== -1) endIndex = Math.min(endIndex, queryIndex); + if (hashIndex !== -1) endIndex = Math.min(endIndex, hashIndex); + + return partialUrl.slice(0, endIndex) || "/"; +} diff --git a/library/helpers/getRequestUrl.test.ts b/library/helpers/getRequestUrl.test.ts new file mode 100644 index 000000000..e7f304fc9 --- /dev/null +++ b/library/helpers/getRequestUrl.test.ts @@ -0,0 +1,200 @@ +import * as t from "tap"; +import { getRequestUrl } from "./getRequestUrl"; + +import { get as httpGet, createServer, IncomingMessage } from "node:http"; + +t.beforeEach(() => { + delete process.env.AIKIDO_TRUST_PROXY; +}); + +async function getRealRequest(): Promise { + return new Promise((resolve) => { + const server = createServer((req, res) => { + res.statusCode = 200; + res.end(); + server.close(); // stop server once we have a request + resolve(req); + }); + server.listen(0, () => { + const { port } = server.address() as any; + // Send a real request to trigger IncomingMessage creation + httpGet({ port, path: "/", headers: {} }, (res) => { + while (res.read()) { + // consume body to prevent test from not exiting + } + + t.same(res.statusCode, 200); + }).end(); + }); + }); +} + +let baseMockRequest: IncomingMessage | null = null; + +async function createMockRequest( + overrides: Partial = {} +): Promise { + if (!baseMockRequest) { + baseMockRequest = await getRealRequest(); + } + return Object.assign( + Object.create(Object.getPrototypeOf(baseMockRequest)), + baseMockRequest, + overrides + ); +} + +t.test("already absolute URL", async (t) => { + t.equal( + getRequestUrl(await createMockRequest({ url: "http://example.com/path" })), + "http://example.com/path" + ); + + t.equal( + getRequestUrl( + await createMockRequest({ url: "https://example.com/path?test=123" }) + ), + "https://example.com/path?test=123" + ); +}); + +t.test("no url set", async (t) => { + const mockReq = await createMockRequest({ url: undefined }); + t.equal(getRequestUrl(mockReq), `http://${mockReq.headers.host}`); +}); + +t.test("no url and no host set", async (t) => { + t.equal( + getRequestUrl( + await createMockRequest({ + url: undefined, + headers: {}, + }) + ), + "" + ); +}); + +t.test("no host header", async (t) => { + t.equal( + getRequestUrl( + await createMockRequest({ + url: "/some/path?query=1", + headers: {}, + }) + ), + "/some/path?query=1" + ); +}); + +t.test("relative URL with host header", async (t) => { + t.equal( + getRequestUrl( + await createMockRequest({ + url: "/some/path?query=1", + headers: { host: "example.com" }, + }) + ), + "http://example.com/some/path?query=1" + ); +}); + +t.test("With X-Forwarded-Host header and trust proxy disabled", async (t) => { + process.env.AIKIDO_TRUST_PROXY = "0"; + + t.equal( + getRequestUrl( + await createMockRequest({ + url: "/forwarded/path", + headers: { + host: "original.com", + "x-forwarded-host": "forwarded.com", + }, + }) + ), + "http://original.com/forwarded/path" + ); +}); + +t.test("With X-Forwarded-Host header and trust proxy enabled", async (t) => { + t.equal( + getRequestUrl( + await createMockRequest({ + url: "/forwarded/path", + headers: { + host: "original.com", + "x-forwarded-host": "forwarded.com", + }, + }) + ), + "http://forwarded.com/forwarded/path" + ); +}); + +t.test("With X-Forwarded-Proto header and trust proxy enabled", async (t) => { + t.equal( + getRequestUrl( + await createMockRequest({ + url: "/secure/path", + headers: { + host: "example.com", + "x-forwarded-proto": "https", + }, + }) + ), + "https://example.com/secure/path" + ); +}); + +t.test("With X-Forwarded-Proto header set to invalid value", async (t) => { + t.equal( + getRequestUrl( + await createMockRequest({ + url: "/weird/path", + headers: { + host: "example.com", + "x-forwarded-proto": "abc", + }, + }) + ), + "http://example.com/weird/path" + ); +}); + +t.test("With X-Forwarded-Proto header and trust proxy disabled", async (t) => { + process.env.AIKIDO_TRUST_PROXY = "0"; + + t.equal( + getRequestUrl( + await createMockRequest({ + url: "/notrust/path", + headers: { + host: "example.com", + "x-forwarded-proto": "https", + }, + }) + ), + "http://example.com/notrust/path" + ); +}); + +t.test("url does not start with slash and is not absolute", async (t) => { + t.equal( + getRequestUrl( + await createMockRequest({ + url: "noslash/path", + headers: { host: "example.com" }, + }) + ), + "http://example.com/noslash/path" + ); +}); + +t.test("url does not start with http/https but is absolute", async (t) => { + t.match( + getRequestUrl( + await createMockRequest({ url: "ftp://example.com/resource" }) + ), + /http:\/\/localhost:\d+\/ftp:\/\/example.com\/resource/ + ); +}); diff --git a/library/helpers/getRequestUrl.ts b/library/helpers/getRequestUrl.ts new file mode 100644 index 000000000..bbbf02354 --- /dev/null +++ b/library/helpers/getRequestUrl.ts @@ -0,0 +1,81 @@ +import type { IncomingMessage } from "http"; +import { Http2ServerRequest } from "http2"; +import type { TLSSocket } from "tls"; +import { trustProxy } from "./trustProxy"; + +/** + * Get the full request URL including protocol and host. + * Falls back to relative URL if host is not available. + * Also respects forwarded headers if proxies are trusted. + */ +export function getRequestUrl( + req: IncomingMessage | Http2ServerRequest +): string { + const reqUrl = req.url || ""; + + // Already absolute URL + if ( + reqUrl[0] !== "/" && // performance improvement + (reqUrl.startsWith("http://") || reqUrl.startsWith("https://")) + ) { + return reqUrl; + } + + // Relative URL + const host = getHost(req); + if (!host) { + // Fallback to relative URL if host is not available + return reqUrl; + } + + // Determine protocol, fallback to http if not detectable + const protocol = getProtocol(req); + + if (reqUrl.length && !reqUrl.startsWith("/")) { + // Ensure there's a slash between host and path + return `${protocol}://${host}/${reqUrl}`; + } + + return `${protocol}://${host}${reqUrl}`; +} + +function getHost( + req: IncomingMessage | Http2ServerRequest +): string | undefined { + const forwardedHost = req.headers?.["x-forwarded-host"]; + + if (typeof forwardedHost === "string" && trustProxy()) { + return forwardedHost; + } + + const host = + req instanceof Http2ServerRequest ? req.authority : req.headers?.host; + if (typeof host === "string") { + return host; + } + + return undefined; +} + +function getProtocol( + req: IncomingMessage | Http2ServerRequest +): "http" | "https" { + const forwarded = + req.headers?.["x-forwarded-proto"] || req.headers?.["x-forwarded-protocol"]; + if (typeof forwarded === "string" && trustProxy()) { + const normalized = forwarded.toLowerCase(); + if (normalized === "https" || normalized === "http") { + return normalized; + } + } + + if (req instanceof Http2ServerRequest && req.scheme === "https") { + return "https"; + } + + if (req.socket && (req.socket as TLSSocket).encrypted) { + return "https"; + } + + return "http"; +} diff --git a/library/helpers/getRequestUrlFromStream.ts b/library/helpers/getRequestUrlFromStream.ts new file mode 100644 index 000000000..4a9042aaa --- /dev/null +++ b/library/helpers/getRequestUrlFromStream.ts @@ -0,0 +1,58 @@ +import type { IncomingHttpHeaders } from "http2"; +import { trustProxy } from "./trustProxy"; + +/** + * Get the full request URL including protocol and host for HTTP/2 streams. + * Falls back to relative URL if host is not available. + * Also respects forwarded headers if proxies are trusted. + */ +export function getRequestUrlFromStream( + headers: IncomingHttpHeaders | undefined +): string | undefined { + const path = headers?.[":path"] || ""; + + const host = getHost(headers); + if (!host) { + // Fallback to relative URL if host is not available + return path || undefined; + } + + // Determine protocol, fallback to http if not detectable + const protocol = getProtocol(headers); + + return `${protocol}://${host}${path}`; +} + +function getHost(headers: IncomingHttpHeaders | undefined): string | undefined { + const forwardedHost = headers?.["x-forwarded-host"]; + + if (typeof forwardedHost === "string" && trustProxy()) { + return forwardedHost; + } + + const host = headers?.[":authority"]; + if (typeof host === "string") { + return host; + } + + return undefined; +} + +function getProtocol( + headers: IncomingHttpHeaders | undefined +): "http" | "https" { + const forwarded = + headers?.["x-forwarded-proto"] || headers?.["x-forwarded-protocol"]; + if (typeof forwarded === "string" && trustProxy()) { + const normalized = forwarded.toLowerCase(); + if (normalized === "https" || normalized === "http") { + return normalized; + } + } + + if (headers?.[":scheme"] === "https") { + return "https"; + } + + return "http"; +} diff --git a/library/sources/Express.tests.ts b/library/sources/Express.tests.ts index 2e31ea1bf..ccf59f8b3 100644 --- a/library/sources/Express.tests.ts +++ b/library/sources/Express.tests.ts @@ -294,6 +294,9 @@ export function createExpressTests(expressPackageName: string) { source: "express", route: "/", }); + + // Url is absolute and includes query parameters + t.match(response.body.url, /^http:\/\/.*\/\?title=test&x=5$/); }); t.test("it adds context from request for POST", async (t) => { @@ -837,4 +840,31 @@ export function createExpressTests(expressPackageName: string) { }, }); }); + + t.test( + "it respects host forwarded header for url construction", + async (t) => { + const app = getApp(); + app.set("trust proxy", ["loopback", "linklocal", "uniquelocal"]); + + const response = await request(app) + .get("/?title=test&x=5") + .set("Cookie", "session=123") + .set("Accept", "application/json") + .set("X-Forwarded-Host", "example.com") + .set("X-Forwarded-Proto", "https") + .set("X-Forwarded-For", "1.2.3.4"); + + t.match(response.body, { + url: "https://example.com/?title=test&x=5", + method: "GET", + query: { title: "test", x: "5" }, + cookies: { session: "123" }, + headers: { accept: "application/json", cookie: "session=123" }, + remoteAddress: "1.2.3.4", + source: "express", + route: "/", + }); + } + ); } diff --git a/library/sources/Fastify.test.ts b/library/sources/Fastify.test.ts index 38eef70c7..35f3c1e42 100644 --- a/library/sources/Fastify.test.ts +++ b/library/sources/Fastify.test.ts @@ -176,7 +176,6 @@ t.test("it adds context from request for all", opts, async (t) => { const json = await response.json(); t.match(json, { - url: "/?title[$ne]=null", remoteAddress: "127.0.0.1", method: "GET", query: { "title[$ne]": "null" }, @@ -194,6 +193,9 @@ t.test("it adds context from request for all", opts, async (t) => { }, executedMiddleware: true, }); + + // Url is absolute and includes query parameters + t.match(json.url, /^http:\/\/.*\/\?title\[\$ne\]=null$/); }); t.test( @@ -542,3 +544,42 @@ t.test("it works with addHttpMethod", opts, async (t) => { cookies: {}, }); }); + +t.test("it adds context from request for all", opts, async (t) => { + const app = getApp(); + + const response = await app.inject({ + method: "GET", + url: "/?title[$ne]=null", + headers: { + accept: "application/json", + cookie: "session=123", + "X-Forwarded-Host": "example.com", + "X-Forwarded-Proto": "http", + }, + }); + + t.same(response.statusCode, 200); + + const json = await response.json(); + t.match(json, { + url: "http://example.com/?title[$ne]=null", + urlPath: "/", + remoteAddress: "127.0.0.1", + method: "GET", + query: { "title[$ne]": "null" }, + headers: { + accept: "application/json", + cookie: "session=123", + "user-agent": "lightMyRequest", + host: "localhost:80", + }, + routeParams: {}, + source: "fastify", + route: "/", + cookies: { + session: "123", + }, + executedMiddleware: true, + }); +}); diff --git a/library/sources/HTTP2Server.test.ts b/library/sources/HTTP2Server.test.ts index b050a5797..11c917191 100644 --- a/library/sources/HTTP2Server.test.ts +++ b/library/sources/HTTP2Server.test.ts @@ -130,7 +130,8 @@ t.test("it wraps the createServer function of http2 module", async () => { ({ body }) => { const context = JSON.parse(body); t.match(context, { - url: "/", + url: "http://localhost:3415/", + urlPath: "/", method: "GET", headers: { ":path": "/", @@ -732,3 +733,114 @@ t.test("it reports attack waves", async (t) => { }); }); }); + +t.test( + "it respects X-Forwarded-Host and X-Forwarded-Proto headers", + async () => { + const server = createMinimalTestServer(); + + await new Promise((resolve) => { + server.listen(3435, () => { + http2Request(new URL("http://localhost:3435/test"), "GET", { + "X-Forwarded-Host": "example.com", + "X-Forwarded-Proto": "https", + }).then(({ body }) => { + const context = JSON.parse(body); + t.match(context, { + url: "https://example.com/test", + urlPath: "/test", + method: "GET", + headers: { + ":path": "/", + ":method": "GET", + ":authority": "localhost:3435", + ":scheme": "http", + }, + query: {}, + route: "/", + source: "http2.createServer", + routeParams: {}, + cookies: {}, + }); + t.ok(isLocalhostIP(context.remoteAddress)); + server.close(); + resolve(); + }); + }); + }); + } +); + +t.test( + "it respects X-Forwarded-Host and X-Forwarded-Proto headers (stream)", + async () => { + const server = createMinimalTestServerWithStream(); + + await new Promise((resolve) => { + server.listen(3436, () => { + http2Request(new URL("http://localhost:3436/test"), "GET", { + "X-Forwarded-Host": "example.com", + "X-Forwarded-Proto": "https", + }).then(({ body }) => { + const context = JSON.parse(body); + t.match(context, { + url: "https://example.com/test", + urlPath: "/test", + method: "GET", + headers: { + ":path": "/", + ":method": "GET", + ":authority": "localhost:3436", + ":scheme": "http", + }, + query: {}, + route: "/", + source: "http2.createServer", + routeParams: {}, + cookies: {}, + }); + t.ok(isLocalhostIP(context.remoteAddress)); + server.close(); + resolve(); + }); + }); + }); + } +); + +t.test( + "it respects X-Forwarded-Host and X-Forwarded-Protocol headers (stream)", + async () => { + const server = createMinimalTestServerWithStream(); + + await new Promise((resolve) => { + server.listen(3436, () => { + http2Request(new URL("http://localhost:3436/test"), "GET", { + "X-Forwarded-Host": "example.com", + "X-Forwarded-Protocol": "http", + }).then(({ body }) => { + const context = JSON.parse(body); + t.match(context, { + url: "http://example.com/test", + urlPath: "/test", + method: "GET", + headers: { + ":path": "/", + ":method": "GET", + ":authority": "localhost:3436", + ":scheme": "http", + }, + query: {}, + route: "/", + source: "http2.createServer", + routeParams: {}, + cookies: {}, + }); + t.ok(isLocalhostIP(context.remoteAddress)); + server.close(); + resolve(); + }); + }); + }); + } +); diff --git a/library/sources/HTTPServer.test.ts b/library/sources/HTTPServer.test.ts index 5dff9a351..70b179ab0 100644 --- a/library/sources/HTTPServer.test.ts +++ b/library/sources/HTTPServer.test.ts @@ -111,7 +111,8 @@ t.test("it wraps the createServer function of http module", async () => { }).then(({ body }) => { const context = JSON.parse(body); t.same(context, { - url: "/", + url: "http://localhost:3314/", + urlPath: "/", method: "GET", headers: { host: "localhost:3314", connection: "close" }, query: {}, @@ -159,7 +160,8 @@ t.test("it wraps the createServer function of https module", async () => { }).then(({ body }) => { const context = JSON.parse(body); t.same(context, { - url: "/", + url: "https://localhost:3315/", + urlPath: "/", method: "GET", headers: { host: "localhost:3315", connection: "close" }, query: {}, @@ -193,6 +195,7 @@ t.test("it parses query parameters", async () => { }).then(({ body }) => { const context = JSON.parse(body); t.same(context.query, { foo: "bar", baz: "qux" }); + t.same(context.url, "http://localhost:3317/?foo=bar&baz=qux"); server.close(); resolve(); }); @@ -546,7 +549,8 @@ t.test("it wraps on request event of http", async () => { }).then(({ body }) => { const context = JSON.parse(body); t.same(context, { - url: "/", + url: "http://localhost:3367/", + urlPath: "/", method: "GET", headers: { host: "localhost:3367", connection: "close" }, query: {}, @@ -590,7 +594,8 @@ t.test("it wraps on request event of https", async () => { }).then(({ body }) => { const context = JSON.parse(body); t.same(context, { - url: "/", + url: "https://localhost:3361/", + urlPath: "/", method: "GET", headers: { host: "localhost:3361", connection: "close" }, query: {}, @@ -792,7 +797,7 @@ t.test("it blocks path traversal in path", async (t) => { t.equal( response, - "Zen has blocked a path traversal attack: path.normalize(...) originating from url." + "Zen has blocked a path traversal attack: path.normalize(...) originating from urlPath." ); server.close(); resolve(); diff --git a/library/sources/Hapi.test.ts b/library/sources/Hapi.test.ts index 309ff508f..99cdc2791 100644 --- a/library/sources/Hapi.test.ts +++ b/library/sources/Hapi.test.ts @@ -143,6 +143,8 @@ t.test("it adds context from request for GET", async (t) => { source: "hapi", route: "/", }); + + t.match(response.body.url, /^http:\/\/.*\/\?title=test$/); }); t.test("it adds context from POST with JSON body", async (t) => { @@ -263,3 +265,25 @@ t.test("toolkit decorator success works", async (t) => { const response = await request(getServer().listener).get("/success"); t.match(response.body, { status: "ok" }); }); + +t.test("it respects forwarded host header", async (t) => { + const response = await request(getServer().listener) + .get("/?title=test") + .set("Accept", "application/json") + .set("Cookie", "session=123") + .set("X-Forwarded-Host", "example.com") + .set("X-Forwarded-Protocol", "https") + .set("X-Forwarded-For", "1.2.3.4"); + + t.match(response.body, { + method: "GET", + url: "https://example.com/?title=test", + urlPath: "/", + query: { title: "test" }, + cookies: { session: "123" }, + headers: { accept: "application/json", cookie: "session=123" }, + remoteAddress: "1.2.3.4", + source: "hapi", + route: "/", + }); +}); diff --git a/library/sources/Hono.test.ts b/library/sources/Hono.test.ts index 509b8e8da..4266c1cd9 100644 --- a/library/sources/Hono.test.ts +++ b/library/sources/Hono.test.ts @@ -174,6 +174,8 @@ t.test("it adds context from request for GET", opts, async (t) => { source: "hono", route: "/", }); + + t.match(body.url, /^http:\/\/.*\/\?title=test$/); }); t.test("it adds JSON body to context", opts, async (t) => { @@ -258,6 +260,7 @@ t.test("it sets the user in the context", opts, async (t) => { method: "GET", source: "hono", route: "/", + urlPath: "/user", user: { id: "123" }, }); }); @@ -357,6 +360,7 @@ t.test("works using @hono/node-server (real socket ip)", opts, async (t) => { query: { abc: "test" }, source: "hono", route: "/", + urlPath: "/", }); t.ok(isLocalhostIP(body.remoteAddress)); server.close(); @@ -628,3 +632,37 @@ t.test("it rate limits based on group", opts, async (t) => { t.match(response3.status, 429); t.match(await response3.text(), "You are rate limited by Zen."); }); + +t.test("it respects forwarded host header", opts, async (t) => { + const { serve } = + require("@hono/node-server") as typeof import("@hono/node-server"); + + const server = serve({ + fetch: getApp().fetch, + port: 8770, + }); + + const response = await fetch.fetch({ + url: new URL("http://127.0.0.1:8770/?abc=test"), + method: "GET", + headers: { + accept: "application/json", + "X-Forwarded-Host": "example.com", + "X-Forwarded-Proto": "https", + }, + timeoutInMS: 500, + }); + + t.match(JSON.parse(response.body), { + url: "https://example.com/?abc=test", + urlPath: "/", + method: "GET", + query: { abc: "test" }, + cookies: {}, + headers: { accept: "application/json" }, + source: "hono", + route: "/", + }); + + server.close(); +}); diff --git a/library/sources/Koa.tests.ts b/library/sources/Koa.tests.ts index 2a27e20c1..af0f94ded 100644 --- a/library/sources/Koa.tests.ts +++ b/library/sources/Koa.tests.ts @@ -132,6 +132,9 @@ export function createKoaTests(koaPackageName: string) { t.match(response.headers, { "x-powered-by": "aikido", }); + + // Url is absolute and includes query parameters + t.match(response.body.url, /^http:\/\/.*\/context\?title=test&a=1$/); }); t.test("it sets the user", async (t) => { @@ -217,4 +220,30 @@ export function createKoaTests(koaPackageName: string) { subdomains: [], }); }); + + t.test("it respects forwarded host header", async (t) => { + const app = getApp(); + const response = await request(app.callback()) + .get("/context?title=test&a=1") + .set("Cookie", "session=123") + .set("Accept", "application/json") + .set("X-Forwarded-Host", "example.com") + .set("X-Forwarded-Proto", "https"); + + t.equal(response.status, 200); + t.match(response.body, { + url: "https://example.com/context?title=test&a=1", + method: "GET", + query: { title: "test", a: "1" }, + cookies: { session: "123" }, + headers: { accept: "application/json", cookie: "session=123" }, + source: "koa", + route: "/context", + subdomains: [], + }); + t.ok(isLocalhostIP(response.body.remoteAddress)); + t.match(response.headers, { + "x-powered-by": "aikido", + }); + }); } diff --git a/library/sources/Restify.tests.ts b/library/sources/Restify.tests.ts index 14d487582..3570de47b 100644 --- a/library/sources/Restify.tests.ts +++ b/library/sources/Restify.tests.ts @@ -119,6 +119,8 @@ export function createRestifyTests(restifyPackageName: string) { source: "restify", route: "/", }); + + t.match(response.body.context.url, /^http:\/\/.*\/$/); }); t.test("it adds context from request for route with params", async (t) => { @@ -210,4 +212,22 @@ export function createRestifyTests(restifyPackageName: string) { .set("x-forwarded-for", "1.2.3.4"); t.same(res3.statusCode, 200); }); + + t.test("it respects forwarded host header", async (t) => { + const response = await request(getApp()) + .get("/") + .set("x-forwarded-host", "example.com"); + + t.match(response.body.context, { + url: "http://example.com/", + urlPath: "/", + method: "GET", + query: {}, + headers: { + "x-forwarded-host": "example.com", + }, + source: "restify", + route: "/", + }); + }); } diff --git a/library/sources/express/contextFromRequest.ts b/library/sources/express/contextFromRequest.ts index 2a89ccb79..1c1ca5e49 100644 --- a/library/sources/express/contextFromRequest.ts +++ b/library/sources/express/contextFromRequest.ts @@ -2,10 +2,10 @@ import type { Request } from "express"; import { Context } from "../../agent/Context"; import { buildRouteFromURL } from "../../helpers/buildRouteFromURL"; import { getIPAddressFromRequest } from "../../helpers/getIPAddressFromRequest"; +import { getRawRequestPath } from "../../helpers/getRawRequestPath"; +import { getRequestUrl } from "../../helpers/getRequestUrl"; export function contextFromRequest(req: Request): Context { - const url = req.protocol + "://" + req.get("host") + req.originalUrl; - return { method: req.method, remoteAddress: getIPAddressFromRequest({ @@ -13,14 +13,15 @@ export function contextFromRequest(req: Request): Context { remoteAddress: req.socket?.remoteAddress, }), body: req.body ? req.body : undefined, - url: url, + url: getRequestUrl(req), + urlPath: getRawRequestPath(req.originalUrl), headers: req.headers, routeParams: req.params, query: req.query, /* c8 ignore next */ cookies: req.cookies ? req.cookies : {}, source: "express", - route: buildRouteFromURL(url), + route: buildRouteFromURL(req.originalUrl), subdomains: req.subdomains, }; } diff --git a/library/sources/fastify/contextFromRequest.ts b/library/sources/fastify/contextFromRequest.ts index ea63fbbeb..5ebeb3afe 100644 --- a/library/sources/fastify/contextFromRequest.ts +++ b/library/sources/fastify/contextFromRequest.ts @@ -2,6 +2,8 @@ import type { FastifyRequest } from "fastify"; import { Context } from "../../agent/Context"; import { buildRouteFromURL } from "../../helpers/buildRouteFromURL"; import { getIPAddressFromRequest } from "../../helpers/getIPAddressFromRequest"; +import { getRawRequestPath } from "../../helpers/getRawRequestPath"; +import { getRequestUrl } from "../../helpers/getRequestUrl"; export function contextFromRequest(req: FastifyRequest): Context { return { @@ -11,7 +13,8 @@ export function contextFromRequest(req: FastifyRequest): Context { remoteAddress: req.socket?.remoteAddress, }), body: req.body ? req.body : undefined, - url: req.url, + url: getRequestUrl(req.raw), + urlPath: getRawRequestPath(req.originalUrl), headers: req.headers, // @ts-expect-error not typed routeParams: req.params, diff --git a/library/sources/hapi/contextFromRequest.ts b/library/sources/hapi/contextFromRequest.ts index 4bfabae8c..61966ae1c 100644 --- a/library/sources/hapi/contextFromRequest.ts +++ b/library/sources/hapi/contextFromRequest.ts @@ -2,8 +2,12 @@ import type { Request } from "@hapi/hapi"; import { Context } from "../../agent/Context"; import { buildRouteFromURL } from "../../helpers/buildRouteFromURL"; import { getIPAddressFromRequest } from "../../helpers/getIPAddressFromRequest"; +import { getRequestUrl } from "../../helpers/getRequestUrl"; +import { getRawRequestPath } from "../../helpers/getRawRequestPath"; export function contextFromRequest(req: Request): Context { + const partialUrl = req.url.toString(); + return { method: req.method.toUpperCase(), remoteAddress: getIPAddressFromRequest({ @@ -11,13 +15,14 @@ export function contextFromRequest(req: Request): Context { remoteAddress: req.info.remoteAddress, }), body: req.payload, - url: req.url.toString(), + url: req.raw?.req ? getRequestUrl(req.raw?.req) : partialUrl, + urlPath: getRawRequestPath(partialUrl), headers: req.headers, routeParams: req.params, query: req.query, /* c8 ignore next */ cookies: req.state || {}, source: "hapi", - route: buildRouteFromURL(req.url.toString()), + route: buildRouteFromURL(partialUrl), }; } diff --git a/library/sources/hono/contextFromRequest.ts b/library/sources/hono/contextFromRequest.ts index 27dfa56c4..e977cd334 100644 --- a/library/sources/hono/contextFromRequest.ts +++ b/library/sources/hono/contextFromRequest.ts @@ -4,6 +4,8 @@ import { buildRouteFromURL } from "../../helpers/buildRouteFromURL"; import { getIPAddressFromRequest } from "../../helpers/getIPAddressFromRequest"; import { parse } from "../../helpers/parseCookies"; import { getRemoteAddress } from "./getRemoteAddress"; +import { getRequestUrl } from "../../helpers/getRequestUrl"; +import { getRawNodeRequest } from "./getRawRequest"; export function contextFromRequest(c: HonoContext): Context { const { req } = c; @@ -11,6 +13,8 @@ export function contextFromRequest(c: HonoContext): Context { const cookieHeader = req.header("cookie"); const existingContext = getContext(); + const rawReq = getRawNodeRequest(c); + return { method: c.req.method, remoteAddress: getIPAddressFromRequest({ @@ -22,7 +26,8 @@ export function contextFromRequest(c: HonoContext): Context { existingContext && existingContext.source === "hono" ? existingContext.body : undefined, - url: req.url, + url: rawReq ? getRequestUrl(rawReq) : req.url, + urlPath: req.path, headers: req.header(), routeParams: req.param(), query: req.query(), diff --git a/library/sources/hono/getRawRequest.ts b/library/sources/hono/getRawRequest.ts new file mode 100644 index 000000000..9ecde246f --- /dev/null +++ b/library/sources/hono/getRawRequest.ts @@ -0,0 +1,14 @@ +import type { Context as HonoContext } from "hono"; +import type { IncomingMessage } from "http"; + +export function getRawNodeRequest(c: HonoContext): IncomingMessage | undefined { + // Node.js server + // https://github.com/honojs/node-server/blob/fc749268c411bfdd7babd781cee5bdfed244f1c0/src/conninfo.ts#L10 + if (!c.env) { + return; + } + + const bindings = c.env.server ? c.env.server : c.env; + + return bindings?.incoming; +} diff --git a/library/sources/hono/getRemoteAddress.ts b/library/sources/hono/getRemoteAddress.ts index b4c2ac3a8..6e8eba739 100644 --- a/library/sources/hono/getRemoteAddress.ts +++ b/library/sources/hono/getRemoteAddress.ts @@ -1,23 +1,11 @@ import type { Context } from "hono"; +import { getRawNodeRequest } from "./getRawRequest"; /** * Tries to get the remote address (ip) from the context of a Hono request. */ export function getRemoteAddress(c: Context): string | undefined { - // Node.js server - // https://github.com/honojs/node-server/blob/fc749268c411bfdd7babd781cee5bdfed244f1c0/src/conninfo.ts#L10 - if (c.env) { - try { - const bindings = c.env.server ? c.env.server : c.env; - const addressInfo = bindings.incoming.socket.address(); + const rawReq = getRawNodeRequest(c); - if ("address" in addressInfo && typeof addressInfo.address === "string") { - return addressInfo.address; - } - } catch { - // Ignore - } - } - - return undefined; + return rawReq?.socket?.remoteAddress; } diff --git a/library/sources/http-server/contextFromRequest.ts b/library/sources/http-server/contextFromRequest.ts index 542b98d89..c56ec3ea0 100644 --- a/library/sources/http-server/contextFromRequest.ts +++ b/library/sources/http-server/contextFromRequest.ts @@ -4,6 +4,8 @@ import { buildRouteFromURL } from "../../helpers/buildRouteFromURL"; import { getIPAddressFromRequest } from "../../helpers/getIPAddressFromRequest"; import { parse } from "../../helpers/parseCookies"; import { tryParseURLParams } from "../../helpers/tryParseURLParams"; +import { getRequestUrl } from "../../helpers/getRequestUrl"; +import { getRawRequestPath } from "../../helpers/getRawRequestPath"; export function contextFromRequest( req: IncomingMessage, @@ -28,7 +30,8 @@ export function contextFromRequest( } return { - url: req.url, + url: getRequestUrl(req), + urlPath: getRawRequestPath(req.url || ""), method: req.method, headers: req.headers, route: req.url ? buildRouteFromURL(req.url) : undefined, diff --git a/library/sources/http-server/http2/contextFromStream.ts b/library/sources/http-server/http2/contextFromStream.ts index 29cc6572b..0cb000741 100644 --- a/library/sources/http-server/http2/contextFromStream.ts +++ b/library/sources/http-server/http2/contextFromStream.ts @@ -1,6 +1,7 @@ import { Context } from "../../../agent/Context"; import { buildRouteFromURL } from "../../../helpers/buildRouteFromURL"; import { getIPAddressFromRequest } from "../../../helpers/getIPAddressFromRequest"; +import { getRequestUrlFromStream } from "../../../helpers/getRequestUrlFromStream"; import { parse } from "../../../helpers/parseCookies"; import { tryParseURLParams } from "../../../helpers/tryParseURLParams"; import { ServerHttp2Stream, IncomingHttpHeaders } from "http2"; @@ -13,21 +14,22 @@ export function contextFromStream( headers: IncomingHttpHeaders, module: string ): Context { - const url = headers[":path"]; + const path = headers[":path"]; const queryObject: Record = {}; - if (url) { - const params = tryParseURLParams(url); + if (path) { + const params = tryParseURLParams(path); for (const [key, value] of params.entries()) { queryObject[key] = value; } } return { - url: url, + url: getRequestUrlFromStream(headers), + urlPath: path, method: headers[":method"] as string, headers: headers, - route: url ? buildRouteFromURL(url) : undefined, + route: path ? buildRouteFromURL(path) : undefined, query: queryObject, source: `${module}.createServer`, routeParams: {}, diff --git a/library/sources/koa/contextFromRequest.ts b/library/sources/koa/contextFromRequest.ts index de7c9f3df..c337efa15 100644 --- a/library/sources/koa/contextFromRequest.ts +++ b/library/sources/koa/contextFromRequest.ts @@ -3,6 +3,7 @@ import { Context } from "../../agent/Context"; import { buildRouteFromURL } from "../../helpers/buildRouteFromURL"; import { getIPAddressFromRequest } from "../../helpers/getIPAddressFromRequest"; import { parse as parseCookies } from "../../helpers/parseCookies"; +import { getRequestUrl } from "../../helpers/getRequestUrl"; export function contextFromRequest(ctx: KoaContext): Context { return { @@ -13,7 +14,8 @@ export function contextFromRequest(ctx: KoaContext): Context { }), // Body is not available by default in Koa, only if a body parser is used body: (ctx.request as any).body ? (ctx.request as any).body : undefined, - url: ctx.request.href, + url: getRequestUrl(ctx.request.req), + urlPath: ctx.request.path, headers: ctx.request.headers, // Only available if e.g. koa-router is used routeParams: ctx.params ? ctx.params : {}, diff --git a/library/sources/restify/contextFromRequest.ts b/library/sources/restify/contextFromRequest.ts index 7fa91152f..07a8aca2f 100644 --- a/library/sources/restify/contextFromRequest.ts +++ b/library/sources/restify/contextFromRequest.ts @@ -4,10 +4,12 @@ import { buildRouteFromURL } from "../../helpers/buildRouteFromURL"; import { getIPAddressFromRequest } from "../../helpers/getIPAddressFromRequest"; import { isPlainObject } from "../../helpers/isPlainObject"; import { parse } from "../../helpers/parseCookies"; +import { getRequestUrl } from "../../helpers/getRequestUrl"; // See https://github.com/restify/node-restify/blob/master/lib/request.js export type RestifyRequest = IncomingMessage & { href: () => string; + path: () => string; params?: Record; query?: Record; body?: unknown; @@ -21,12 +23,13 @@ export function contextFromRequest(req: RestifyRequest): Context { remoteAddress: req.socket?.remoteAddress, }), body: req.body ? req.body : undefined, - url: req.href(), + url: getRequestUrl(req), + urlPath: req.path(), headers: req.headers || {}, routeParams: req.params || {}, query: isPlainObject(req.query) ? req.query : {}, cookies: req.headers?.cookie ? parse(req.headers.cookie) : {}, source: "restify", - route: buildRouteFromURL(req.href()), + route: buildRouteFromURL(req.path()), }; }