From 69328bd629a57de452d00aef8c1d5f723010711b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 05:53:33 +0000 Subject: [PATCH 1/3] fix(plugin-auth): the IP allow-list judges the address the server established The ADR-0069 D5 allow-list and the D7 login audit now establish a request's client address by the platform's one client-address trust rule, the rule the runtime's inbound rate limiter already used: forwarded headers only when the stack declares server.trustProxy, the transport's peer address otherwise. With ranges configured, an address that cannot be established is refused (403 IP_NOT_ALLOWED) and explained once in the operator log. The rule moves unchanged from runtime into plugin-auth's dependency-free ./client-address entry (runtime depends on plugin-auth), and the runtime imports it back. objectstack serve hands server.trustProxy to AuthPlugin as well as to the dispatcher. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- content/docs/permissions/authentication.mdx | 2 +- packages/cli/src/commands/serve.ts | 6 + packages/plugins/plugin-auth/package.json | 5 + .../plugins/plugin-auth/src/auth-manager.ts | 142 ++++++++++++++---- .../plugins/plugin-auth/src/auth-plugin.ts | 133 ++++++++++++++-- .../plugins/plugin-auth/src/client-address.ts | 78 ++++++++++ packages/plugins/plugin-auth/src/index.ts | 3 + .../plugin-auth/src/request-client-address.ts | 56 +++++++ packages/plugins/plugin-auth/tsup.config.ts | 7 +- .../src/security/inbound-rate-limit.ts | 29 ++-- .../src/manifests/auth.manifest.ts | 2 +- .../src/translations/es-ES.ts | 2 +- .../src/translations/ja-JP.ts | 2 +- .../src/translations/zh-CN.ts | 2 +- 14 files changed, 402 insertions(+), 67 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/client-address.ts create mode 100644 packages/plugins/plugin-auth/src/request-client-address.ts diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index fdd2cfd2f45..f8eb34fa714 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -590,7 +590,7 @@ often before the account-level lock engages. | Setting | Effect | |---|---| -| `allowed_ip_ranges` | Restrict authenticated access to one or more IPv4 CIDR ranges. IP is extracted with trust-proxy awareness (`x-forwarded-for` / `cf-connecting-ip` / `x-real-ip`); requests whose IP can't be determined fail open. | +| `allowed_ip_ranges` | Restrict authenticated access to one or more IPv4 CIDR ranges (or exact IPs). The address judged is the one the server establishes, by the same rule as the inbound rate limiter: `X-Forwarded-For` / `X-Real-IP` only when the stack declares `server.trustProxy`, the connection's peer address otherwise. Behind a reverse proxy, declare `server.trustProxy`, or the allow-list judges the proxy's own address. A request whose address cannot be established is refused with `403 IP_NOT_ALLOWED`, like one outside the ranges. `last_login_ip` records the same address. | > **How enforcement works.** Password expiry (an administrator-forced password > change included) and enforced MFA share a single *session-validation gate*. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index f28becea4a5..fe02bae615b 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -4255,6 +4255,12 @@ export default class Serve extends Command { appName: resolveDeploymentAppName((config as any).email ?? {}, process.env, (config as any).appName), socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined, trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined, + // The stack's `server.trustProxy`, the same authored value the + // dispatcher's inbound rate limiter receives below: whether the + // ADR-0069 D5 IP allow-list and the D7 login audit may believe + // X-Forwarded-For / X-Real-IP when establishing a client address. + // Undeclared, both judge the transport's peer address. + trustProxy: (config as any).server?.trustProxy === true, // Enable the admin plugin by default so the Setup app's // ban/unban/set-password/impersonate/set-role row actions // resolve to real endpoints. The plugin self-gates by role diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index a4ece37658b..b7834c58dbf 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -16,6 +16,11 @@ "types": "./dist/rate-limit-storage.d.ts", "import": "./dist/rate-limit-storage.mjs", "require": "./dist/rate-limit-storage.js" + }, + "./client-address": { + "types": "./dist/client-address.d.ts", + "import": "./dist/client-address.mjs", + "require": "./dist/client-address.js" } }, "scripts": { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index fc295fb6679..ea44bd0ad1f 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -127,6 +127,8 @@ import { } from './remove-member-permission-guard.js'; import { isPlaceholderEmail } from './placeholder-email.js'; import { readAccountInvitation, settleImportResetDelivery } from './account-invitation-marker.js'; +import { resolveClientAddress } from './client-address.js'; +import { clientAddressStateEntries, readRequestClientAddress } from './request-client-address.js'; import { REVOKE_SESSION_NOT_FOUND_CODE, REVOKE_SESSION_NOT_FOUND_MESSAGE, @@ -1028,13 +1030,35 @@ export interface AuthManagerOptions extends Partial { /** * ADR-0069 D5 — network gating. When non-empty, auth requests (sign-in, - * session) from a client IP outside these CIDR / exact ranges are rejected - * with `IP_NOT_ALLOWED` at the auth-route middleware. Requires a trusted proxy - * to set `x-forwarded-for` / `cf-connecting-ip`; fails OPEN when the client IP - * can't be determined (so a missing proxy header is a no-op, not a lockout). + * session) are judged at the auth-route middleware against these CIDR / exact + * ranges, and refused with `403 IP_NOT_ALLOWED` unless the client address is + * inside one. + * + * The address judged is the one the server ESTABLISHED, by the platform's + * client-address trust rule ({@link resolveClientAddress}, shared with the + * runtime's inbound rate limiter): forwarded headers only when + * {@link trustProxy} is declared, the transport's peer address otherwise. + * Behind a reverse proxy, declare `server.trustProxy`, or the allow-list + * judges the proxy's own address. + * + * A request whose address cannot be established (no peer address, no trusted + * forwarded header) is REFUSED with the same `403 IP_NOT_ALLOWED`, and the + * first such refusal logs a warning naming this setting and `server.trustProxy`. + * Unset or empty: no restriction, and nothing is judged. */ allowedIpRanges?: string[]; + /** + * The deployment's `server.trustProxy`: believe `X-Forwarded-For` / + * `X-Real-IP` when establishing a request's client address for the ADR-0069 + * D5 allow-list ({@link allowedIpRanges}) and the D7 login audit + * (`last_login_ip`). The same authored value the runtime's inbound rate + * limiter reads; `objectstack serve` passes it to both. A host that composes + * `AuthPlugin` itself passes the value it gives the dispatcher. + * @default false + */ + trustProxy?: boolean; + /** * ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to * better-auth's core `rateLimit`. The settings bind tightens `customRules` @@ -1117,22 +1141,41 @@ function ipv4ToInt(ip: string): number | null { return (((p[0] << 24) >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3]) >>> 0; } -/** ADR-0069 D5 — does `ip` match `range` (IPv4 CIDR `a.b.c.d/n`, or exact IP)? */ +/** + * `::ffff:203.0.113.7` → `203.0.113.7`; anything else is returned trimmed. + * + * A dual-stack listener reports an IPv4 peer in this IPv4-mapped IPv6 form, and + * the ADR-0069 D5 allow-list judges the transport's peer address when no proxy + * is trusted. So both sides of a comparison are read as the IPv4 address they + * denote, or an IPv4 range could never admit an IPv4 client on such a socket. + */ +function unmapIpv4(address: string): string { + const trimmed = address.trim(); + const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(trimmed); + return mapped ? mapped[1] : trimmed; +} + +/** + * ADR-0069 D5 — does `ip` match `range` (IPv4 CIDR `a.b.c.d/n`, or exact IP)? + * An IPv4-mapped IPv6 address on either side compares as its IPv4 address. + */ export function ipMatchesRange(ip: string, range: string): boolean { - const r = (range || '').trim(); + const r = unmapIpv4(range || ''); if (!r) return false; + const address = unmapIpv4(ip); if (r.includes('/')) { - const [base, bitsStr] = r.split('/'); + const [rawBase, bitsStr] = r.split('/'); + const base = unmapIpv4(rawBase); const bits = Number(bitsStr); - const ipInt = ipv4ToInt(ip); + const ipInt = ipv4ToInt(address); const baseInt = ipv4ToInt(base); if (ipInt === null || baseInt === null || !(bits >= 0 && bits <= 32)) { - return ip.trim() === base.trim(); // non-IPv4-CIDR → exact-match fallback + return address === base; // non-IPv4-CIDR → exact-match fallback } const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; return ((ipInt & mask) >>> 0) === ((baseInt & mask) >>> 0); } - return ip.trim() === r; + return address === r; } /** @@ -2839,18 +2882,12 @@ export class AuthManager { if (typeof uid === 'string') await this.enforceConcurrentCap(uid); // ADR-0069 D7 — login audit: stamp last_login_at/ip on success. // Independent of lockout config (recordSignInOutcome no-ops when - // lockout is off). IP from the trusted forwarded headers, same - // precedence as the D5 allow-list middleware. + // lockout is off). The IP is the address `handleRequest` + // established for this request by the client-address trust rule, + // the same address the D5 allow-list judged; none when it was + // undetermined. ⛔ Never re-read from a header here. if (typeof uid === 'string') { - const hdr = (k: string): string => - ((ctx?.headers?.get?.(k) ?? ctx?.request?.headers?.get?.(k)) as string) || ''; - const fwd = hdr('x-forwarded-for'); - const ip = - (fwd && fwd.split(',')[0].trim()) || - hdr('cf-connecting-ip') || - hdr('x-real-ip') || - undefined; - await this.stampLastLogin(uid, ip); + await this.stampLastLogin(uid, await readRequestClientAddress()); } } } @@ -6437,9 +6474,27 @@ export class AuthManager { * response status and log server errors so they are not silently swallowed. * * @param request - Web standard Request object + * @param transport - What the transport knows about the request that a Fetch + * `Request` does not carry: `remoteAddress` is its peer address + * (`IHttpRequest.remoteAddress`). Omitted, the request's client address is + * established from trusted forwarded headers alone, and is undetermined + * when `trustProxy` is not declared. * @returns Web standard Response object */ - async handleRequest(request: Request): Promise { + async handleRequest(request: Request, transport?: { remoteAddress?: string }): Promise { + // ADR-0069 D7 — the client address this request's sign-in audit stamps, + // established ONCE, here, by the same trust rule the D5 allow-list judges + // with (`resolveClientAddress`). Read before any rewrite below, and carried + // to the after-hook in better-auth's request state (`request-client-address.ts`). + const clientHeaders: Record = {}; + request.headers.forEach((value, name) => { + clientHeaders[name] = value; + }); + const clientAddress = this.resolveClientAddress({ + headers: clientHeaders, + ...(transport?.remoteAddress ? { remoteAddress: transport.remoteAddress } : {}), + }); + // Dev DX: better-auth's CSRF protection rejects state-changing requests // (e.g. `/sign-in/email`) with a 403 when neither `Origin` nor `Referer` // is present. Browsers always send one; non-browser API clients (curl, @@ -6515,7 +6570,10 @@ export class AuthManager { // The previous single call site flattened it with the `await` below. const runHandler = async (): Promise => await runWithAuthActorScope(() => - runWithRequestState(new WeakMap(), () => auth.handler(request)), + runWithRequestState( + new WeakMap(clientAddressStateEntries(clientAddress)), + () => auth.handler(request), + ), ); // [#7724] A subject-erasure request is ONE unit of work, and better-auth @@ -9598,17 +9656,47 @@ export class AuthManager { /** * ADR-0069 D5 — is `ip` within the configured allow-list? True (allow) when no - * ranges are configured, OR when the IP can't be determined (fail-open so a - * misconfigured proxy never locks everyone out — an admin enabling this must - * ensure forwarded headers are trusted). Supports IPv4 CIDR + exact IPv4/IPv6. + * ranges are configured: nothing is judged then. With ranges configured, true + * only for an address inside one of them, and FALSE for an undetermined + * address (`undefined` / empty): an allow-list is a gate, and a gate that + * opens for "unknown" is open to anyone who can make themselves unknown. + * Supports IPv4 CIDR + exact IPv4/IPv6 (IPv4-mapped IPv6 compares as IPv4). + * + * `ip` must be the address the server established + * ({@link resolveClientAddress}), never a header read by the caller. */ public isClientIpAllowed(ip: string | undefined): boolean { const ranges = this.config.allowedIpRanges; if (!ranges || ranges.length === 0) return true; - if (!ip) return true; // undetermined → fail-open + if (!ip) return false; // undetermined → refused return ranges.some((r) => ipMatchesRange(ip, r)); } + /** + * The client address this deployment establishes for a request: the + * platform's client-address trust rule (`./client-address.ts`, shared with the + * runtime's inbound rate limiter) applied with this deployment's + * {@link AuthManagerOptions.trustProxy}. Forwarded headers count only when + * `trustProxy` is declared; otherwise the transport's `remoteAddress`; + * `undefined` when neither is available. The ADR-0069 D5 allow-list judges + * this address and the D7 login audit stamps it. + */ + public resolveClientAddress(transport: { + headers?: Record; + remoteAddress?: string; + }): string | undefined { + return resolveClientAddress({ + ...(transport.headers ? { headers: transport.headers } : {}), + ...(transport.remoteAddress ? { remoteAddress: transport.remoteAddress } : {}), + trustProxy: this.config.trustProxy === true, + }); + } + + /** Whether this deployment declared `server.trustProxy` ({@link AuthManagerOptions.trustProxy}). */ + public trustsForwardedHeaders(): boolean { + return this.config.trustProxy === true; + } + /** * Returns the data engine wired into this auth manager. Used by route * handlers (e.g. bootstrap-status) that need to query identity tables diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 4de01344a9d..4f0b2170a4b 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -331,6 +331,35 @@ function isLoopbackIssuer(issuer: string): boolean { return ipMatchesRange(host, '127.0.0.0/8'); } +/** + * The transport's peer address for a Hono context, when the runtime exposes + * one: the value `IHttpRequest.remoteAddress` carries on every request the + * transport's own seams build. + * + * This plugin's auth routes are raw-app mounts (`getRawApp()`), which receive + * the Hono context rather than an `IHttpRequest`, so the peer is read here the + * way the transport reads it (`@objectstack/plugin-hono-server` `adapter.ts` + * `readRemoteAddress`: `@hono/node-server` puts the Node `IncomingMessage` on + * `c.env.incoming`). Every access is guarded: a runtime that exposes nothing + * yields `undefined`, which the client-address trust rule reads as "no peer", + * never as a value to fabricate. + */ +function readTransportPeerAddress(c: any): string | undefined { + try { + const incoming = c?.env?.incoming; + const address = incoming?.socket?.remoteAddress ?? incoming?.connection?.remoteAddress; + return typeof address === 'string' && address.length > 0 ? address : undefined; + } catch { + return undefined; + } +} + +/** What the transport knows about a raw-app request that its Fetch `Request` does not carry. */ +function transportOf(c: any): { remoteAddress?: string } { + const remoteAddress = readTransportPeerAddress(c); + return remoteAddress ? { remoteAddress } : {}; +} + /** * Authentication Plugin * @@ -429,6 +458,14 @@ export class AuthPlugin implements Plugin { private tenancy: TenancyService | null = null; /** [ADR-0131 D3] Whether `start()` created the Default Organization (a fresh install). */ private defaultOrganizationCreatedAtBoot = false; + /** + * ADR-0069 D5 — each operator-facing allow-list refusal explanation is logged + * once per process: the first refusal of an undetermined address, and the + * first refusal that judged the peer while an untrusted forwarded header was + * present. + */ + private allowListUndeterminedWarned = false; + private allowListUntrustedHeaderWarned = false; private configuredSocialProviders: SocialProviderConfig | undefined; // ADR-0092 D6 — the EFFECTIVE better-auth secondaryStorage (host-supplied or // the kernel-cache adapter wired in init). The identity write guard's @@ -2609,9 +2646,60 @@ export class AuthPlugin implements Plugin { } } + /** + * ADR-0069 D5 — the operator-facing explanation of an allow-list refusal, + * for the two cases where the reason is not simply "an address outside the + * ranges". Logged at `warn` (a refusal the requester was told about, not a + * durability loss) and ONCE per process per case, so a refused flood does not + * repeat the same sentence per request. + * + * - the address could not be established at all, so the request was refused + * as undetermined; + * - the allow-list judged the transport's peer address while the request + * carried a forwarded header that was not believed because + * `server.trustProxy` is not declared: the shape a deployment behind a + * proxy takes before it declares the proxy trusted. + */ + private explainAllowListRefusal( + ctx: PluginContext, + mgr: AuthManager, + ip: string | undefined, + headers: Record, + ): void { + const trustProxy = mgr.trustsForwardedHeaders(); + if (!ip) { + if (this.allowListUndeterminedWarned) return; + this.allowListUndeterminedWarned = true; + ctx.logger.warn( + '[auth] auth request refused (403 IP_NOT_ALLOWED): the `allowed_ip_ranges` setting (ADR-0069 D5) is set, ' + + 'but the server could not establish this request\'s client address. ' + + (trustProxy + ? '`server.trustProxy` is declared, yet the request carried no X-Forwarded-For / X-Real-IP header ' + + 'and the transport reported no peer address. ' + : 'The transport reported no peer address, and forwarded headers are not believed because ' + + '`server.trustProxy` is not declared. ') + + 'While the allow-list is set, every request whose address cannot be established is refused. Fix: ' + + 'serve through a transport that reports the socket address, or terminate on a reverse proxy you ' + + 'control that sets X-Forwarded-For and declare `server.trustProxy: true`; or clear `allowed_ip_ranges`.', + ); + return; + } + if (trustProxy || this.allowListUntrustedHeaderWarned) return; + if (!headers['x-forwarded-for'] && !headers['x-real-ip']) return; + this.allowListUntrustedHeaderWarned = true; + ctx.logger.warn( + '[auth] auth request refused (403 IP_NOT_ALLOWED) by the `allowed_ip_ranges` setting (ADR-0069 D5): the ' + + 'allow-list judged the connection\'s peer address, because `server.trustProxy` is not declared and so the ' + + 'request\'s X-Forwarded-For / X-Real-IP header was not believed. If this deployment sits behind a reverse ' + + 'proxy you control, declare `server.trustProxy: true`; until then the allow-list judges the proxy\'s own ' + + 'address.', + { judgedAddress: ip }, + ); + } + /** * Register authentication routes with HTTP server - * + * * Uses better-auth's universal handler for all authentication requests. * This forwards all requests under basePath to better-auth, which handles: * - Email/password authentication @@ -2639,23 +2727,35 @@ export class AuthPlugin implements Plugin { const rawApp = (httpServer as any).getRawApp(); // ── ADR-0069 D5 — network gating (IP allow-list) ────────────────────── - // Reject auth requests from a client IP outside the configured ranges, - // BEFORE they reach better-auth. Registered first so it runs ahead of the - // routes below. The public render helpers (/config, /bootstrap-status) are - // exempt so a blocked client still gets a clean login page + error. No-op - // (and no IP parse) when no ranges are configured. + // Reject auth requests from a client address outside the configured + // ranges, BEFORE they reach better-auth. Registered first so it runs ahead + // of the routes below. The public render helpers (/config, + // /bootstrap-status) are exempt so a blocked client still gets a clean + // login page + error. Nothing is judged when no ranges are configured. + // + // The address judged is the one the server ESTABLISHED, by the platform's + // client-address trust rule (`AuthManager.resolveClientAddress`, shared + // with the runtime's inbound rate limiter): forwarded headers only when + // `server.trustProxy` is declared, the transport's peer address otherwise. + // An address that cannot be established is refused like one outside the + // ranges (`isClientIpAllowed`), with an operator log line naming both + // settings. The wildcard route below hands the same peer to + // `handleRequest`, so the D7 login audit stamps this same address. if (typeof rawApp.use === 'function') rawApp.use(`${basePath}/*`, async (c: any, next: any) => { const mgr = this.authManager; - if (!mgr || typeof mgr.isClientIpAllowed !== 'function') return next(); + if ( + !mgr || + typeof mgr.isClientIpAllowed !== 'function' || + typeof mgr.resolveClientAddress !== 'function' + ) { + return next(); + } const path: string = c.req.path || ''; if (path.endsWith('/config') || path.endsWith('/bootstrap-status')) return next(); - const fwd = c.req.header('x-forwarded-for'); - const ip = - (typeof fwd === 'string' && fwd.split(',')[0].trim()) || - c.req.header('cf-connecting-ip') || - c.req.header('x-real-ip') || - undefined; + const headers = (c.req.header() ?? {}) as Record; + const ip = mgr.resolveClientAddress({ headers, ...transportOf(c) }); if (!mgr.isClientIpAllowed(ip)) { + this.explainAllowListRefusal(ctx, mgr, ip, headers); return c.json( { success: false, error: { code: 'IP_NOT_ALLOWED', message: 'Sign-in is not allowed from your network.' } }, 403, @@ -3605,8 +3705,11 @@ export class AuthPlugin implements Plugin { // path it implements, and only its leftovers are up for grabs. rawApp.all(`${basePath}/*`, async (c: any, next: any) => { try { - // Forward the original request to better-auth handler - const response = await this.authManager!.handleRequest(c.req.raw); + // Forward the original request to better-auth handler, with the + // transport's peer address: `handleRequest` establishes the client + // address from it by the same trust rule the D5 gate above judged + // with, and the D7 login audit stamps that address. + const response = await this.authManager!.handleRequest(c.req.raw, transportOf(c)); // [#15417] Yield only a 404 that DISCLAIMS OWNERSHIP. `ownsRoute` asks // better-auth's live `auth.api` — the same seam the route ledger and the diff --git a/packages/plugins/plugin-auth/src/client-address.ts b/packages/plugins/plugin-auth/src/client-address.ts new file mode 100644 index 00000000000..4e35b3786a3 --- /dev/null +++ b/packages/plugins/plugin-auth/src/client-address.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The client-address trust rule: the ONE answer to "which address made this + * request", for every consumer that keys a decision on it. + * + * ## The rule + * + * - `X-Forwarded-For` (its leftmost entry), then `X-Real-IP`, are believed + * **only** when the deployment declares `server.trustProxy`. Untrusted, + * those headers are caller input, and a decision keyed on them is a decision + * the caller makes. + * - Otherwise the address is the transport's own peer address + * (`IHttpRequest.remoteAddress`, `@objectstack/spec` `contracts/http-server.ts`), + * which a client cannot influence. + * - Neither available ⇒ `undefined`: the address is UNDETERMINED. This never + * fabricates a value and never substitutes a header for the peer silently. + * What "undetermined" means is each consumer's own deliberate decision, made + * where the consumer is: the inbound rate limiter keys one shared bucket + * (over-throttling rather than failing open), and the ADR-0069 D5 allow-list + * refuses the request. + * + * ## Consumers (each hands over the same three facts) + * + * - `@objectstack/runtime` `security/inbound-rate-limit.ts` `resolveRateLimitKey`: + * the key of an anonymous caller's bucket. + * - `AuthManager.resolveClientAddress` (this package): the address the + * ADR-0069 D5 allow-list judges and the D7 login audit stamps as + * `last_login_ip`. + * + * ## Why it lives here + * + * `@objectstack/runtime` depends on this package (it already counts through the + * `./rate-limit-storage` entry), so the rule cannot stay in the runtime and be + * read from plugin-auth: that would close a dependency cycle. It moved here + * unchanged and the runtime imports it back. It is published as its own + * `./client-address` subpath entry, like `./rate-limit-storage`, so the runtime + * reaches it without loading the better-auth family (see `tsup.config.ts`). + * ⛔ Keep this module free of imports: a value import here becomes a load-time + * dependency of every consumer of the entry. + */ + +/** The three facts the rule decides from. */ +export interface ClientAddressInput { + /** Request headers, lower-cased as every adapter delivers them. */ + headers?: Record; + /** The transport's peer address (`IHttpRequest.remoteAddress`). */ + remoteAddress?: string; + /** `server.trustProxy`: believe forwarded headers. */ + trustProxy: boolean; +} + +function firstHeader( + headers: Record | undefined, + name: string, +): string | undefined { + if (!headers) return undefined; + const raw = headers[name] ?? headers[name.toLowerCase()]; + const value = Array.isArray(raw) ? raw[0] : raw; + if (typeof value !== 'string') return undefined; + // `X-Forwarded-For: client, proxy1, proxy2`: the LEFTMOST entry is the + // original client. It is also the entry a client controls, which is exactly + // why reading it at all requires `trustProxy`. + const first = value.split(',')[0]?.trim(); + return first ? first : undefined; +} + +/** + * The address the server has established for this request, or `undefined` + * when it has established none. See the module header for the rule. + */ +export function resolveClientAddress(input: ClientAddressInput): string | undefined { + const forwarded = input.trustProxy + ? firstHeader(input.headers, 'x-forwarded-for') ?? firstHeader(input.headers, 'x-real-ip') + : undefined; + const address = forwarded ?? input.remoteAddress; + return address ? address : undefined; +} diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index b4345a0a46c..dabc602cff5 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -61,6 +61,9 @@ export * from './otp-send-guard.js'; // of record out of `sys_session`, which disables the ADR-0069 D4 session // controls, so a host opts into that trade explicitly or not at all. export * from './rate-limit-storage.js'; +// The client-address trust rule shared with the runtime's inbound rate limiter +// (also published as the dependency-free `./client-address` subpath). +export * from './client-address.js'; export * from './secondary-storage.js'; export * from './register-sso-provider.js'; export * from './send-verification-email.js'; diff --git a/packages/plugins/plugin-auth/src/request-client-address.ts b/packages/plugins/plugin-auth/src/request-client-address.ts new file mode 100644 index 00000000000..a8a052838c8 --- /dev/null +++ b/packages/plugins/plugin-auth/src/request-client-address.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0069 D7: carry the client address the server established for one auth + * request from `AuthManager.handleRequest` to the hook that stamps + * `last_login_ip`. + * + * ## Why a carrier at all + * + * The address is decided by the client-address trust rule + * (`./client-address.ts`), and half of that rule's input is the transport's + * peer address, which a Fetch `Request` does not carry. `handleRequest` is the + * one place that receives both the `Request` and (from the route that calls it) + * the peer, so it resolves the address there, once, and the sign-in after-hook + * reads that answer instead of re-deriving one from headers. + * + * ## Where it lives: better-auth's own request state + * + * A {@link defineRequestState} slot in the store `handleRequest` opens per + * request (`runWithRequestState(new WeakMap(entries), …)`), for the reason + * `account-invitation-marker.ts` gives: better-auth's request state is the one + * per-request store that `auth-manager.ts` polyfills on WebContainer, so the + * address propagates wherever better-auth's own per-request state does. + * + * ## Absence + * + * No slot (a programmatic `auth.api.*` call, a door that passed no address, no + * request state at all) reads as `undefined`: the address is undetermined and + * the audit records no address. ⛔ It never falls back to a header. + */ + +import { defineRequestState } from '@better-auth/core/context'; + +const requestClientAddressState = defineRequestState(() => undefined); + +/** + * The request-state entries that record `address` for the request about to + * run: none when it is undetermined, so the slot reads as absent. + */ +export function clientAddressStateEntries(address: string | undefined): Array<[object, unknown]> { + return address ? [[requestClientAddressState.ref, address]] : []; +} + +/** + * The client address the server established for the current auth request, or + * `undefined` when it established none or no request state is open. Never + * throws: the login audit must not fail the sign-in it describes. + */ +export async function readRequestClientAddress(): Promise { + try { + const address = await requestClientAddressState.get(); + return typeof address === 'string' && address.length > 0 ? address : undefined; + } catch { + return undefined; + } +} diff --git a/packages/plugins/plugin-auth/tsup.config.ts b/packages/plugins/plugin-auth/tsup.config.ts index 7f9b16a0f54..7a9395f8360 100644 --- a/packages/plugins/plugin-auth/tsup.config.ts +++ b/packages/plugins/plugin-auth/tsup.config.ts @@ -35,9 +35,14 @@ import { dropSourcesContent } from '../../../scripts/tsup-drop-sources-content.m * `packages/core/tsup.config.ts`, which ship `./node` and `./logger` this way. * The only reason this file exists at all — rather than the shared * `../../../tsup.config.ts` — is the second entry.) + * + * `src/client-address.ts` is the third entry, for the same reason: it is the + * client-address trust rule (`resolveClientAddress`) that `@objectstack/runtime`'s + * inbound rate limiter and this package's ADR-0069 D5 allow-list share, and the + * runtime imports it from `./client-address` rather than through the root. */ export default defineConfig({ - entry: ['src/index.ts', 'src/rate-limit-storage.ts'], + entry: ['src/index.ts', 'src/rate-limit-storage.ts', 'src/client-address.ts'], splitting: false, sourcemap: true, clean: true, diff --git a/packages/runtime/src/security/inbound-rate-limit.ts b/packages/runtime/src/security/inbound-rate-limit.ts index 975b538b274..8451893350b 100644 --- a/packages/runtime/src/security/inbound-rate-limit.ts +++ b/packages/runtime/src/security/inbound-rate-limit.ts @@ -37,6 +37,7 @@ import type { Middleware, IHttpRequest, IHttpResponse } from '@objectstack/core'; import { createLazyCounterStore, type CounterStore } from '@objectstack/plugin-auth/rate-limit-storage'; +import { resolveClientAddress } from '@objectstack/plugin-auth/client-address'; import { buildApiError } from '../error-envelope.js'; import { @@ -124,21 +125,6 @@ export interface RateLimitKeyInput { trustProxy: boolean; } -function firstHeader( - headers: Record | undefined, - name: string, -): string | undefined { - if (!headers) return undefined; - const raw = headers[name] ?? headers[name.toLowerCase()]; - const value = Array.isArray(raw) ? raw[0] : raw; - if (typeof value !== 'string') return undefined; - // `X-Forwarded-For: client, proxy1, proxy2` — the LEFTMOST entry is the - // original client. It is also the entry a client controls, which is exactly - // why reading it at all requires `trustProxy`. - const first = value.split(',')[0]?.trim(); - return first ? first : undefined; -} - /** * Q3=C, in code: the bucket is keyed by **who**, and only falls back to * **where** when there is no who. @@ -163,10 +149,15 @@ function firstHeader( export function resolveRateLimitKey(input: RateLimitKeyInput): { key: string; kind: RateLimitKeyKind } { if (input.principalId) return { key: `principal:${input.principalId}`, kind: 'principal' }; - const forwarded = input.trustProxy - ? firstHeader(input.headers, 'x-forwarded-for') ?? firstHeader(input.headers, 'x-real-ip') - : undefined; - const address = forwarded ?? input.remoteAddress; + // The address half is the platform's ONE client-address trust rule, shared + // with plugin-auth's ADR-0069 D5 allow-list and D7 login audit. It lives in + // `@objectstack/plugin-auth/client-address` because this package depends on + // plugin-auth, never the other way round. + const address = resolveClientAddress({ + ...(input.headers ? { headers: input.headers } : {}), + ...(input.remoteAddress ? { remoteAddress: input.remoteAddress } : {}), + trustProxy: input.trustProxy, + }); if (address) return { key: `ip:${address}`, kind: 'ip' }; return { key: 'ip:unknown', kind: 'unknown' }; diff --git a/packages/services/service-settings/src/manifests/auth.manifest.ts b/packages/services/service-settings/src/manifests/auth.manifest.ts index c6991edd25a..a518c95bdc2 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.ts @@ -359,7 +359,7 @@ const manifest = { label: 'Allowed IP ranges', required: false, description: - 'CIDR ranges or exact IPs (one per line, or comma-separated), e.g. 203.0.113.0/24. When set, sign-in from outside these ranges is rejected. Empty = no restriction. Requires a trusted proxy to set X-Forwarded-For.', + 'CIDR ranges or exact IPs (one per line, or comma-separated), e.g. 203.0.113.0/24. When set, sign-in from outside these ranges is rejected, and so is any request whose client address the server cannot establish. Empty = no restriction. The address judged is the connection\'s peer; behind a reverse proxy, declare server.trustProxy in the stack so the forwarded client address is judged instead.', }, // [#23367, ruling A] The API-key lifecycle policy. Two readers, one // source: `bindAuthSettings` binds these into the auth service diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index 0be06a67aa4..f153b4ca406 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -214,7 +214,7 @@ export const esES: PlatformTranslationData = { }, allowed_ip_ranges: { label: 'Rangos de IP permitidos', - help: 'Rangos CIDR o IP exactas (una por línea o separadas por comas), p. ej. 203.0.113.0/24. Cuando se define, se rechaza el inicio de sesión desde fuera de estos rangos. Vacío = sin restricción. Requiere un proxy de confianza que defina X-Forwarded-For.', + help: 'Rangos CIDR o IP exactas (una por línea o separadas por comas), p. ej. 203.0.113.0/24. Cuando se define, se rechaza el inicio de sesión desde fuera de estos rangos, y también cualquier solicitud cuya dirección de cliente el servidor no pueda determinar. Vacío = sin restricción. Se evalúa la dirección del extremo de la conexión; detrás de un proxy inverso, declare server.trustProxy en el stack para que se evalúe en su lugar la dirección de cliente reenviada.', }, api_key_default_expiry_days: { label: 'Duración predeterminada de la clave (días)', diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index c9d77abfe86..e3779f86d22 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -214,7 +214,7 @@ export const jaJP: PlatformTranslationData = { }, allowed_ip_ranges: { label: '許可 IP レンジ', - help: 'CIDR レンジまたは正確な IP(1行に1つ、またはカンマ区切り)。例:203.0.113.0/24。設定すると、この範囲外からのサインインを拒否します。空欄で制限なし。X-Forwarded-For を設定する信頼できるプロキシが必要です。', + help: 'CIDR レンジまたは正確な IP(1行に1つ、またはカンマ区切り)。例:203.0.113.0/24。設定すると、この範囲外からのサインインを拒否し、サーバーがクライアントアドレスを特定できないリクエストも拒否します。空欄で制限なし。判定には接続元(ピア)のアドレスを使います。リバースプロキシの背後に配置する場合は、スタックで server.trustProxy を宣言すると、転送されたクライアントアドレスが判定に使われます。', }, api_key_default_expiry_days: { label: 'キーの既定の有効期間(日)', diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 8d947000c84..261e0625f35 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -356,7 +356,7 @@ export const zhCN: PlatformTranslationData = { }, allowed_ip_ranges: { label: '允许的 IP 段', - help: 'CIDR 段或精确 IP(每行一个,或用逗号分隔),例如 203.0.113.0/24。设置后,来自这些范围之外的登录将被拒绝。留空表示不限制。需要可信代理设置 X-Forwarded-For。', + help: 'CIDR 段或精确 IP(每行一个,或用逗号分隔),例如 203.0.113.0/24。设置后,来自这些范围之外的登录将被拒绝,服务器无法确定客户端地址的请求同样会被拒绝。留空表示不限制。判定的是连接的对端地址;部署在反向代理之后时,请在 stack 中声明 server.trustProxy,以改为判定转发的客户端地址。', }, api_key_default_expiry_days: { label: '默认密钥有效期(天)', From 4ebc356953841924b2f522142853d54f2149b281 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 05:57:43 +0000 Subject: [PATCH 2/3] test(plugin-auth): pin each branch of the allow-list client-address rule One pin per branch, through the real raw-app mounts: a declared trusted proxy, an undeclared one, an address that cannot be established, no ranges, the IPv4-mapped peer form, and the D7 audit recording the same address or none. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../plugin-auth/src/auth-manager.test.ts | 22 +- .../client-ip-allow-list-trust-rule.test.ts | 242 ++++++++++++++++++ 2 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/client-ip-allow-list-trust-rule.test.ts diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index ed1d0550614..8241ae39838 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -4822,10 +4822,26 @@ describe('AuthManager', () => { expect(m.isClientIpAllowed('8.8.8.8')).toBe(false); }); - it('fails OPEN when the client IP cannot be determined (no proxy header)', () => { + it('refuses an address the server could not establish once ranges are configured', () => { const m = mgr({ allowedIpRanges: ['203.0.113.0/24'] }); - expect(m.isClientIpAllowed(undefined)).toBe(true); - expect(m.isClientIpAllowed('')).toBe(true); + expect(m.isClientIpAllowed(undefined)).toBe(false); + expect(m.isClientIpAllowed('')).toBe(false); + }); + + it('ipMatchesRange reads an IPv4-mapped IPv6 address as the IPv4 address it denotes', () => { + expect(ipMatchesRange('::ffff:203.0.113.5', '203.0.113.0/24')).toBe(true); + expect(ipMatchesRange('::FFFF:203.0.114.5', '203.0.113.0/24')).toBe(false); + expect(ipMatchesRange('::ffff:10.0.0.1', '10.0.0.1')).toBe(true); + expect(ipMatchesRange('10.0.0.1', '::ffff:10.0.0.1')).toBe(true); + expect(ipMatchesRange('2001:db8::1', '2001:db8::1')).toBe(true); + }); + + it('resolveClientAddress believes forwarded headers only under trustProxy', () => { + const headers = { 'x-forwarded-for': '203.0.113.9, 10.0.0.2' }; + expect(mgr({}).resolveClientAddress({ headers, remoteAddress: '10.0.0.2' })).toBe('10.0.0.2'); + expect(mgr({ trustProxy: true }).resolveClientAddress({ headers, remoteAddress: '10.0.0.2' })) + .toBe('203.0.113.9'); + expect(mgr({}).resolveClientAddress({ headers })).toBeUndefined(); }); }); diff --git a/packages/plugins/plugin-auth/src/client-ip-allow-list-trust-rule.test.ts b/packages/plugins/plugin-auth/src/client-ip-allow-list-trust-rule.test.ts new file mode 100644 index 00000000000..0a29c10ea09 --- /dev/null +++ b/packages/plugins/plugin-auth/src/client-ip-allow-list-trust-rule.test.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0069 D5 / D7 — the IP allow-list judges, and the login audit records, the +// client address the SERVER established, by the platform's one client-address +// trust rule (`./client-address.ts`, shared with the runtime's inbound rate +// limiter): +// +// - forwarded headers count only when the deployment declares +// `server.trustProxy` (`AuthManagerOptions.trustProxy`); +// - otherwise the address is the transport's peer (`IHttpRequest.remoteAddress`); +// - an address that cannot be established is REFUSED while ranges are set; +// - with no ranges configured nothing is judged. +// +// One pin per branch. The fixture mounts `AuthPlugin.registerAuthRoutes` on a +// real Hono app in front of a real `AuthManager` on the installed better-auth, +// and every verdict is read off a real `Response`: the gate and the route that +// feeds the audit are raw-app mounts, so a suite driving `AuthManager` alone +// would never run them. The transport peer is supplied the way +// `@hono/node-server` supplies it, as the Node request on `c.env.incoming`. +// +// ADR-0112 is `code` AND `status`; every refusal assertion carries both. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Hono } from 'hono'; +import { AuthManager } from './auth-manager'; +import { AuthPlugin } from './auth-plugin'; +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; +import { inviteForAudienceGate } from './audience-gate-test-support'; +import type { PluginContext } from '@objectstack/core'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-24739'; +const ORIGIN = 'http://localhost:3000'; +const BASE = '/api/v1/auth'; +const EMAIL = 'member.24739@example.com'; + +/** Documentation ranges (RFC 5737): one allowed, one not, one private proxy hop. */ +const RANGE = '203.0.113.0/24'; +const INSIDE = '203.0.113.7'; +const OUTSIDE = '198.51.100.9'; +const PROXY_HOP = '10.0.0.2'; + +const mockCtx = () => + ({ + registerService: vi.fn(), + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + }) as unknown as PluginContext & { logger: { warn: ReturnType } }; + +/** + * ADMITTED: the request passed the gate and reached the auth service, whose + * session read refuses a request carrying no credential with its own answer. + * REFUSED: the D5 gate answered before the auth service ran. + */ +const ADMITTED = { status: 401, code: 'UNAUTHENTICATED' }; +const REFUSED = { status: 403, code: 'IP_NOT_ALLOWED' }; + +/** Status + the error code the body carries (ADR-0112 nests it under `error`). */ +async function verdict(res: Response): Promise<{ status: number; code?: string }> { + const text = await res.text(); + let code: string | undefined; + try { + const parsed = JSON.parse(text); + code = parsed?.error?.code ?? parsed?.code; + } catch { + /* non-JSON body → no code */ + } + return { status: res.status, code }; +} + +interface Deployment { + allowedIpRanges?: string[]; + trustProxy?: boolean; +} + +/** One deployment, served through the REAL mount chain. */ +async function serve(deployment: Deployment) { + const engine = createMemoryEngine(); + const manager = new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine, + ...deployment, + } as any); + + const ctx = mockCtx(); + const plugin = new AuthPlugin({ secret: SECRET }); + await plugin.init(ctx); + (plugin as any).authManager = manager; + const app = new Hono(); + (plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); + + /** A request through the mounts. `peer` is the transport's own peer address, or none. */ + const send = ( + path: string, + init: { method: string; headers?: Record; body?: string }, + peer?: string, + ) => + app.request( + `${ORIGIN}${BASE}${path}`, + init, + peer ? { incoming: { socket: { remoteAddress: peer } } } : {}, + ); + + /** The cheapest auth request: a session read with no credential ({@link ADMITTED} when let through). */ + const probe = (peer: string | undefined, headers: Record = {}) => + send('/get-session', { method: 'GET', headers }, peer); + + /** Create the fixture user directly (no mount), so only the sign-in crosses the gate. */ + const signUp = async () => { + await inviteForAudienceGate(manager, EMAIL); + const res = await manager.handleRequest( + new Request(`${ORIGIN}${BASE}/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN }, + body: JSON.stringify({ email: EMAIL, password: PASSWORD, name: 'Allow-list Member' }), + }), + ); + expect(res.status, `sign-up: ${await res.clone().text()}`).toBe(200); + }; + + /** Sign in THROUGH the mounts, as the transport delivers it. */ + const signIn = (peer: string | undefined, headers: Record = {}) => + send( + '/sign-in/email', + { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN, ...headers }, + body: JSON.stringify({ email: EMAIL, password: PASSWORD }), + }, + peer, + ); + + const userRow = () => + ((engine.tables.get('sys_user') ?? []) as any[]).find((r) => r.email === EMAIL); + + return { ctx, probe, signUp, signIn, userRow }; +} + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('ADR-0069 D5 — the allow-list judges the client address the server established', () => { + it('behind a declared trusted proxy, the allow-list judges the forwarded client address', async () => { + const { probe } = await serve({ allowedIpRanges: [RANGE], trustProxy: true }); + + // The proxy hop is outside the ranges; the forwarded client is inside. + expect(await verdict(await probe(PROXY_HOP, { 'x-forwarded-for': `${INSIDE}, ${PROXY_HOP}` }))) + .toEqual(ADMITTED); + // …and the converse: the forwarded client decides, not the hop. + expect(await verdict(await probe(INSIDE, { 'x-forwarded-for': OUTSIDE }))) + .toEqual(REFUSED); + }); + + it('without a declared trusted proxy, the allow-list judges the connection peer and a forwarded header changes nothing', async () => { + const { probe } = await serve({ allowedIpRanges: [RANGE] }); + + expect(await verdict(await probe(OUTSIDE, { 'x-forwarded-for': INSIDE, 'x-real-ip': INSIDE }))) + .toEqual(REFUSED); + expect(await verdict(await probe(INSIDE, { 'x-forwarded-for': OUTSIDE }))) + .toEqual(ADMITTED); + }); + + it('an address the server could not establish is refused while ranges are set, and the operator is told why once', async () => { + const untrusted = await serve({ allowedIpRanges: [RANGE] }); + // No transport peer, and a forwarded header that is not believed. + expect(await verdict(await untrusted.probe(undefined, { 'x-forwarded-for': INSIDE }))) + .toEqual(REFUSED); + expect(await verdict(await untrusted.probe(undefined))) + .toEqual(REFUSED); + + const undetermined = untrusted.ctx.logger.warn.mock.calls + .map((call) => String(call[0])) + .filter((line) => line.includes('allowed_ip_ranges') && line.includes('server.trustProxy')); + expect(undetermined, 'one operator line, naming the setting and server.trustProxy').toHaveLength(1); + + // A trusted proxy that forwarded nothing, on a transport with no peer. + const trusted = await serve({ allowedIpRanges: [RANGE], trustProxy: true }); + expect(await verdict(await trusted.probe(undefined))) + .toEqual(REFUSED); + }); + + it('with no ranges configured nothing is judged, whatever the address', async () => { + const { probe } = await serve({}); + + expect(await verdict(await probe(undefined))).toEqual(ADMITTED); + expect(await verdict(await probe(OUTSIDE, { 'x-forwarded-for': OUTSIDE }))) + .toEqual(ADMITTED); + + const empty = await serve({ allowedIpRanges: [] }); + expect(await verdict(await empty.probe(undefined))).toEqual(ADMITTED); + }); + + it('an IPv4 peer reported in IPv4-mapped form is judged as its IPv4 address', async () => { + const { probe } = await serve({ allowedIpRanges: [RANGE] }); + + expect(await verdict(await probe(`::ffff:${INSIDE}`))).toEqual(ADMITTED); + expect(await verdict(await probe(`::ffff:${OUTSIDE}`))) + .toEqual(REFUSED); + }); +}); + +describe('ADR-0069 D7 — the login audit records the address the allow-list judged', () => { + it('records the connection peer, not a forwarded header, when no proxy is trusted', async () => { + const d = await serve({}); + await d.signUp(); + + const res = await d.signIn(INSIDE, { 'x-forwarded-for': OUTSIDE }); + expect(res.status, await res.clone().text()).toBe(200); + expect(d.userRow()?.last_login_at instanceof Date).toBe(true); + expect(d.userRow()?.last_login_ip).toBe(INSIDE); + }); + + it('records the forwarded client address the gate admitted when the proxy is declared trusted', async () => { + const d = await serve({ allowedIpRanges: [RANGE], trustProxy: true }); + await d.signUp(); + + const res = await d.signIn(PROXY_HOP, { 'x-forwarded-for': `${INSIDE}, ${PROXY_HOP}` }); + expect(res.status, await res.clone().text()).toBe(200); + expect(d.userRow()?.last_login_ip).toBe(INSIDE); + }); + + it('records no address when none could be established', async () => { + const d = await serve({}); + await d.signUp(); + + const res = await d.signIn(undefined, { 'x-forwarded-for': OUTSIDE }); + expect(res.status, await res.clone().text()).toBe(200); + // The stamp ran (last_login_at), and it carried no address. + expect(d.userRow()?.last_login_at instanceof Date).toBe(true); + expect(d.userRow()?.last_login_ip).toBeUndefined(); + }); +}); From c42164ff22837de47e21b97cea71a0a2f1fb89bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 05:58:35 +0000 Subject: [PATCH 3/3] chore(changeset): the auth IP allow-list judges the established client address Also keeps the package root's export surface unchanged: the rule is reached only through the ./client-address entry. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../24739-auth-ip-allowlist-trust-rule.md | 29 +++++++++++++++++++ packages/plugins/plugin-auth/src/index.ts | 3 -- 2 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 .changeset/24739-auth-ip-allowlist-trust-rule.md diff --git a/.changeset/24739-auth-ip-allowlist-trust-rule.md b/.changeset/24739-auth-ip-allowlist-trust-rule.md new file mode 100644 index 00000000000..e7d710d28e2 --- /dev/null +++ b/.changeset/24739-auth-ip-allowlist-trust-rule.md @@ -0,0 +1,29 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/runtime': patch +'@objectstack/cli': patch +'@objectstack/service-settings': patch +--- + +fix(plugin-auth): the auth IP allow-list judges the client address the server established (#24739) + +Clause-②: no + +The `allowed_ip_ranges` auth setting (ADR-0069 D5) and the `last_login_ip` login audit (D7) now establish a request's client address by the same rule as the inbound rate limiter: `X-Forwarded-For` (its first entry), then `X-Real-IP`, count only when the stack declares `server.trustProxy`. Otherwise the address is the connection's peer address. + +**For operators who set `allowed_ip_ranges`:** + +- Behind a reverse proxy or load balancer, declare `server: { trustProxy: true }` in the stack before you upgrade. Without it, the allow-list judges the proxy's own address for every request. +- A request whose client address cannot be established (the transport reports no peer address, and no forwarded header is trusted) is now refused with `403 IP_NOT_ALLOWED`. Before, it was admitted. The first such refusal logs a warning that names `allowed_ip_ranges` and `server.trustProxy`. +- `CF-Connecting-IP` is no longer read. Behind Cloudflare, declare `server.trustProxy`, and the `X-Forwarded-For` it sets is used. +- A dual-stack listener reports an IPv4 client in IPv4-mapped form (`::ffff:203.0.113.7`). That address is now matched as the IPv4 address it denotes. +- With `allowed_ip_ranges` unset or empty, nothing changes. + +**`last_login_ip`** records the address the allow-list judged, or no address when none could be established. It is no longer read from a forwarded header the deployment does not trust. + +**By package:** + +- `@objectstack/plugin-auth`: the rule is published as the dependency-free `@objectstack/plugin-auth/client-address` entry (`resolveClientAddress`). `AuthPlugin` and `AuthManager` take a `trustProxy` option. `AuthManager.handleRequest` takes an optional second argument that carries the transport's `remoteAddress`. +- `@objectstack/runtime`: the inbound rate limiter imports the rule from that entry instead of keeping its own copy. Its keys are unchanged. +- `@objectstack/cli`: `objectstack serve` passes the stack's `server.trustProxy` to `AuthPlugin` as well as to the dispatcher. A host that composes `AuthPlugin` itself passes `trustProxy` the same way. +- `@objectstack/service-settings`: the `allowed_ip_ranges` help text says which address is judged. diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index dabc602cff5..b4345a0a46c 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -61,9 +61,6 @@ export * from './otp-send-guard.js'; // of record out of `sys_session`, which disables the ADR-0069 D4 session // controls, so a host opts into that trade explicitly or not at all. export * from './rate-limit-storage.js'; -// The client-address trust rule shared with the runtime's inbound rate limiter -// (also published as the dependency-free `./client-address` subpath). -export * from './client-address.js'; export * from './secondary-storage.js'; export * from './register-sso-provider.js'; export * from './send-verification-email.js';