Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fastify-trust-proxy-hop-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@seamless-auth/fastify": patch
---

Detect Fastify proxy trust from its behaviour, not its configuration (#208).

The adapter refused to forward a client-controlled address when `trustProxy` was `true`, by reading `trustProxy` from `initialConfig`. Fastify 5 does not expose it there, so the check never fired, and with `trustProxy: true` a caller could choose the client IP the auth API rate limits and audits against. The adapter now asks Fastify's own `request.ip` about a request from documentation addresses (RFC 5737). When the application trusts every address it drops the client IP and warns, as intended.

The README recommended a hop count, which Fastify 5.12.1 and later ignore: they trust no proxy, so `request.ip` is the proxy and every user shares one address on the auth API. The README now recommends the proxy's address or subnet, or `resolveClientIp`, and the adapter warns once when requests carry `X-Forwarded-For` that nothing trusts.
15 changes: 11 additions & 4 deletions packages/fastify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,19 @@ into the dashboard work: the upstream answers them with the SPA shell.
### Client IP

The adapter forwards the end user's IP so the auth API can rate limit and audit
against the real caller. With Fastify's `trustProxy` set to blanket `true`,
`request.ip` comes from the leftmost `X-Forwarded-For` entry, which any client
can set, so the adapter drops it and warns rather than forwarding a value the
caller chose. Set `trustProxy` to an explicit hop count or subnet, or pass
against the real caller. It takes it from `request.ip`, so set Fastify's
`trustProxy` to the address or subnet of the proxy in front of the app (for example
`trustProxy: "10.0.0.0/8"`, or `"loopback"` behind a proxy on the same host), or pass
`resolveClientIp`.

- **Trusting every address** (`trustProxy: true`) makes `request.ip` the leftmost
`X-Forwarded-For` entry, which any client can set. The adapter detects it, drops
the address rather than forward one the caller chose, and warns.
- **A hop count** (`trustProxy: 1`) is ignored by Fastify 5.12.1 and later, which
then trusts no proxy: `request.ip` is the proxy, and every user would share one
rate-limit bucket and one audit address on the auth API. The adapter warns when
requests carry `X-Forwarded-For` that nothing trusts.

The browser's `User-Agent` is forwarded alongside it as
`x-seamless-client-user-agent`, capped at 512 characters, so the auth API can
record the device a person signed in from rather than this adapter. It needs no
Expand Down
85 changes: 74 additions & 11 deletions packages/fastify/src/internal/buildForwardedClientIp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,92 @@ import type { FastifyRequest } from "fastify";

export type ClientIpResolver = (req: FastifyRequest) => string | undefined;

// Documentation addresses (RFC 5737), which no real proxy has. A trust policy that
// accepts them as proxies accepts any address, so request.ip is whatever the
// client put first in X-Forwarded-For.
const PROBE_PEER = "203.0.113.9";
const PROBE_CLIENT = "198.51.100.7";
const PROBE_FORWARDED_FOR = `${PROBE_CLIENT}, 198.51.100.8`;

const trustsEveryAddress = new WeakMap<object, boolean>();

let warnedBlanketTrustProxy = false;
let warnedUntrustedProxy = false;

// With `trustProxy` set to blanket true, Fastify derives request.ip from the
// leftmost X-Forwarded-For entry, which any client can set. Forwarding that
// upstream would let a caller pick its own rate-limit and audit identity, so drop
// it instead.
function derivedFromTrustedHop(req: FastifyRequest): string | undefined {
if (
(req.server as { initialConfig?: { trustProxy?: unknown } }).initialConfig
?.trustProxy === true
function ipGetter(req: FastifyRequest): (() => unknown) | undefined {
for (
let proto: object | null = Object.getPrototypeOf(req);
proto;
proto = Object.getPrototypeOf(proto)
) {
const getter = Object.getOwnPropertyDescriptor(proto, "ip")?.get;
if (getter) return getter;
}

return undefined;
}

/**
* Whether the application's `trustProxy` trusts any address as a proxy.
*
* Fastify keeps `trustProxy` in a closure and does not expose it, so it cannot be
* read. Instead this asks Fastify's own `request.ip` about a request from a
* documentation address. Decided once per request class.
*/
function trustsAnyAddress(req: FastifyRequest): boolean {
const proto = Object.getPrototypeOf(req) as object;
const known = trustsEveryAddress.get(proto);
if (known !== undefined) return known;

let trustsAll = false;
const getter = ipGetter(req);

if (getter) {
const socket = { remoteAddress: PROBE_PEER };
const headers = { "x-forwarded-for": PROBE_FORWARDED_FOR };
const raw = { headers, socket, connection: socket };

try {
trustsAll = getter.call({ raw, headers, socket }) === PROBE_CLIENT;
} catch {
trustsAll = false;
}
}

trustsEveryAddress.set(proto, trustsAll);
return trustsAll;
}

function derivedFromTrustedHop(req: FastifyRequest): string | undefined {
if (trustsAnyAddress(req)) {
if (!warnedBlanketTrustProxy) {
warnedBlanketTrustProxy = true;
console.warn(
"[seamless-auth] Fastify 'trustProxy' is set to true, so request.ip is client-controlled. " +
"The client IP will not be forwarded to the auth API. Set 'trustProxy' to an explicit " +
"hop count or subnet, or pass resolveClientIp to the plugin.",
"[seamless-auth] Fastify 'trustProxy' trusts every address (for example `true`), so request.ip is client-controlled. " +
"The client IP will not be forwarded to the auth API. Set 'trustProxy' to the proxy's address or subnet, " +
"or pass resolveClientIp to the plugin.",
);
}

return undefined;
}

// A proxy is adding X-Forwarded-For but nothing trusts it, so request.ip is the
// proxy and every user would reach the auth API from one address. This is what a
// hop count does on Fastify 5.12.1 and later, which ignores it.
if (
!warnedUntrustedProxy &&
req.headers["x-forwarded-for"] !== undefined &&
req.ip === req.socket?.remoteAddress
) {
warnedUntrustedProxy = true;
console.warn(
"[seamless-auth] Requests carry X-Forwarded-For, but Fastify's 'trustProxy' trusts no proxy, so request.ip is the " +
"connecting peer. If this app is behind a proxy, set 'trustProxy' to the proxy's address or subnet (Fastify " +
"5.12.1 and later ignore a hop count), or pass resolveClientIp to the plugin.",
);
}

return req.ip;
}

Expand Down
103 changes: 103 additions & 0 deletions packages/fastify/tests/trustProxy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { jest } from "@jest/globals";
import Fastify from "fastify";

const { default: seamlessAuth } = await import("../dist/index.js");

const OPTIONS = {
prefix: "/auth",
fetchManifest: false,
authServerUrl: "https://auth.example.com",
cookieSecret: "cookie-secret-cookie-secret-cookie-secret",
serviceSecret: "service-secret-service-secret-service-secret",
audience: "https://auth.example.com",
jwksKid: "test-main",
};

let warn;

beforeEach(() => {
warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
global.fetch = jest.fn(async () => Response.json({ error: "nope" }, { status: 400 }));
});

afterEach(() => {
warn.mockRestore();
});

// The x-seamless-client-ip the adapter sends upstream for one login request.
async function forwardedIp(fastifyOptions, { peer, forwardedFor }) {
const app = Fastify(fastifyOptions);
await app.register(seamlessAuth, OPTIONS);
await app.ready();

try {
await app.inject({
method: "POST",
url: "/auth/login",
remoteAddress: peer,
headers: forwardedFor ? { "x-forwarded-for": forwardedFor } : {},
payload: { identifier: "a@b.test" },
});
return global.fetch.mock.calls[0][1].headers["x-seamless-client-ip"];
} finally {
await app.close();
}
}

function warnings() {
return warn.mock.calls.map(([message]) => String(message));
}

// Fastify does not expose trustProxy, so the adapter asks request.ip instead.
describe("client IP forwarding behind a proxy (#208)", () => {
it("drops a client-chosen address when trustProxy trusts everything, and warns", async () => {
const ip = await forwardedIp(
{ trustProxy: true },
{ peer: "10.0.0.5", forwardedFor: "6.6.6.6" },
);

expect(ip).toBeUndefined();
expect(warnings()).toEqual([expect.stringMatching(/trusts every address/)]);
});

it("treats a trust function that accepts every address like trusting everything", async () => {
expect(
await forwardedIp(
{ trustProxy: () => true },
{ peer: "10.0.0.5", forwardedFor: "6.6.6.6" },
),
).toBeUndefined();
});

it("forwards the client behind a trusted proxy subnet, not a spoofed entry", async () => {
expect(
await forwardedIp(
{ trustProxy: "10.0.0.0/8" },
{ peer: "10.0.0.5", forwardedFor: "6.6.6.6, 203.0.113.50" },
),
).toBe("203.0.113.50");
});

it("forwards the client when trust is a one-hop function", async () => {
expect(
await forwardedIp(
{ trustProxy: (_address, hop) => hop < 1 },
{ peer: "10.0.0.5", forwardedFor: "6.6.6.6, 203.0.113.51" },
),
).toBe("203.0.113.51");
});

it("forwards the peer and warns when a proxy's X-Forwarded-For is trusted by nothing", async () => {
const ip = await forwardedIp({}, { peer: "10.0.0.5", forwardedFor: "203.0.113.52" });

expect(ip).toBe("10.0.0.5");
expect(warnings()).toEqual([expect.stringMatching(/trusts no proxy/)]);
});

it("forwards the peer without a warning when there is no proxy", async () => {
const ip = await forwardedIp({}, { peer: "203.0.113.53" });

expect(ip).toBe("203.0.113.53");
expect(warnings()).toEqual([]);
});
});
Loading