Skip to content

Commit ad8cf92

Browse files
fix(plugin-auth): the vendored auth library's client-address source follows server.trustProxy (#24871)
Fixes #24799 Clause-②: no ## What changed The vendored auth library (better-auth 1.7.3) resolves a request's client address with its own reader, `getIP` in `@better-auth/core` `utils/ip`. That reader reads request headers only: it never sees the transport's peer address and does not know `server.trustProxy`. `plugin-auth` composed the library without configuring that reader, so the library used its default. - **The library reads one platform-owned header.** The auth instance's options now always carry `advanced.ipAddress.ipAddressHeaders`, which names the one header the platform owns (`client-address-vendor.ts`, `vendorClientAddressOptions`). No `trustedProxies` is set: the header carries one address the server already decided, never a chain for the library to walk. - **`handleRequest` writes that header.** `AuthManager.handleRequest` is the one door to the library's HTTP handler. It now rebuilds every request it hands over (`withVendorClientAddress`). Any copy of the header the caller sent is dropped. The address the client-address trust rule established is written in its place. That address comes from `resolveClientAddress` (`@objectstack/plugin-auth/client-address`, after #24795), which is reused here and not copied. When no address was established, nothing is written, and the library is handed no address. - **Why a header.** The library has no resolver hook. Its option surface is a header list, a proxy-CIDR list for its own walk of a forwarded chain, and an off switch. The off switch also turns the library's rate limiter off, so it cannot express "no address where none exists". A header the platform owns is the one channel through which the rule's answer reaches the library. - The request is rebuilt from a clone, so the caller's own `Request` stays readable for the auth mount's 404 hand-on. A request that cannot be rebuilt is not handed to the library with a value its caller chose. There is no catch around the rebuild. Landing: `packages/plugins/plugin-auth/src/auth-manager.ts` (the composed option and the `handleRequest` write) and a new `client-address-vendor.ts` beside `client-address.ts`. `packages/spec/**`, `packages/runtime/**` and `auth-plugin.ts` are untouched. ## Reachability (the card's first step) Measured on `origin/main` at `7c2eab929` with the installed better-auth 1.7.3 and `@better-auth/core` 1.7.3, through the real auth mounts (`AuthPlugin.registerAuthRoutes` on Hono, a real `AuthManager`). Three things read the library's client address in a stock composition: 1. the session row's `ip_address`, and through it the `login` / `logout` audit rows; 2. the `login_failed` / `account_locked` audit address (`signInClientFingerprint` applies the same reader on purpose); 3. the key of the library's own per-address rate limiter (ADR-0069 D2; on by default in production). `getIP` has four callers in the installed library family: the rate limiter, `createSession`, the captcha plugin, and a re-export. `plugin-auth` composes no captcha. In both the untrusted-proxy deployment and the trusted-proxy deployment, the address all three answered differed from the address the D5 allow-list judged and the D7 audit stamped. The detail is withheld under the disclosure ruling recorded on #9387. M4 (does the peer address reach the library?): it does not. The library sees the Fetch `Request` only. The peer reaches `handleRequest` as its `transport.remoteAddress` argument. ## Pins: one per branch, ablation-verified `client-address-vendor-trust-rule.test.ts` uses the D5/D7 suite's fixture: real mounts, a real `AuthManager`, the peer on `c.env.incoming`. Each pin reads all three seams off one composition: the minted session's `ip_address`, the `login_failed` event's address, and every key the library's limiter consumed (recorded through `rateLimitStorage`). It also reads D7 `last_login_ip` for agreement. Every request also carries the platform-owned header, set by the caller. The refused sign-in asserts `code` + `status` (`INVALID_EMAIL_OR_PASSWORD`, 401). - `behind a declared trusted proxy, it records and keys on the forwarded client address` - `without a declared trusted proxy, it records and keys on the connection peer, whatever the request headers say` - `when the server established no address, it is handed none, whatever the request headers say`. This pin compares against a per-deployment control request that carries no header at all, never against a literal, because the library's own no-address answer depends on its environment. Ablations ran on `2604658d1` (fix committed first) through `scripts/ablation-replace.mjs`. Each mutation was proven on disk (anchor count 1 to 0, blob changed), and each was restored to the HEAD blob with `git diff HEAD` empty. Each "10 passed" / "12 passed" counts the 10 tests the imported fixture module registers. | # | forbidden behaviour put back | result | |---|---|---| | A1 | the composed `ipAddress` option removed (library default reader) | 3 failed / 10 passed: all three branches red | | A2 | the caller's copy of the header not dropped | 1 failed / 12 passed: no-address branch red | | A3 | the library handed the raw transport peer instead of the established address | 1 failed / 12 passed: trusted-proxy branch red | | A4 | the forwarded header handed over without declared trust | 2 failed / 11 passed: untrusted-proxy and no-address branches red | | A5 | the `handleRequest` write removed | 3 failed / 10 passed: all three branches red | ## Tests and gates All readings below are on head `9d43edafb`, which merges `origin/main` at `9a06f0be0`. That range includes #24846 and #24843, which edit other regions of `auth-manager.ts`. - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2` exited 0: 171 files, 3273 passed, 10 skipped. - `pnpm --filter @objectstack/plugin-auth typecheck` exited 0: `tsc` on src and examples, and `check:test-typecheck` OK (no new debt). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 70 commands. Each ran with its exit code captured before any pipe, and all 70 exited 0. `--ran` printed: `70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN`. - Two existing suites moved with the behaviour: - `auth-manager.test.ts`: the `advanced` passthrough expectations now include the always-composed `ipAddress`. - `auth-account-event-audit.test.ts`: the fixture hands its client as the transport peer. It had supplied the client through a forwarded header on a deployment that declares no trusted proxy, which is the behaviour this card removes. - Repo-wide scans (`pnpm lint` and the full `check:*` farm) are left to CI. ## Acceptance notes - **Programmatic calls.** A programmatic `auth.api.*` call is handed whatever headers its caller passes. Only `withVendorClientAddress` writes the header, which is documented at the constant and in the changeset. Measured in-repo, no programmatic call reaches an address reader with a client's headers. The calls are `getSession` (reads no address), boot-time `signUpEmail` (no headers) and `requestPasswordReset` (reads no address). - **The library's own no-address answer.** In production it is no session address and one shared rate-limit bucket, plus a one-time library warning whose remedy names library options that the platform composes itself. In development and test it is a loopback placeholder, and its limiter is off there by default. This is library-owned behaviour, unchanged. - **IPv6.** The library normalizes IPv6 addresses to their /64 before it keys or stores them. D7 `last_login_ip` stores the established address verbatim. This is pre-existing library behaviour, unchanged. - **Peerless bridges.** Filed as #24877 (security family; detail withheld under the disclosure ruling recorded on #9387). The bullet was rewritten by the `domain:services` seat 2 at review. - **Operator note (in the changeset).** Behind a reverse proxy, declare `server.trustProxy`. Otherwise sessions and audit rows record the proxy's address, and the library's per-address rate limit counts every client behind the proxy as one client. This is the same posture the D5 allow-list and the runtime's inbound limiter already take. Changeset: `.changeset/24799-vendor-client-address-trust.md` (`@objectstack/plugin-auth`: patch). --- _Generated by [Claude Code](https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 98c3d00 commit ad8cf92

7 files changed

Lines changed: 376 additions & 21 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): the vendored auth library records and keys on the client address the server established (#24799)
6+
7+
Clause-②: no
8+
9+
better-auth reads a request's client address with its own reader, and three things are keyed on what it reads: a session's `ip_address` (and the `login` / `logout` audit rows copied from it), the address on the `login_failed` / `account_locked` audit rows, and the key of better-auth's own per-address rate limiter. That reader did not follow `server.trustProxy`. It now follows the same rule as the `allowed_ip_ranges` allow-list and `last_login_ip`: `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. When neither is available, better-auth is handed no address.
10+
11+
**For operators:**
12+
13+
- Behind a reverse proxy or load balancer, declare `server: { trustProxy: true }` in the stack. Without it, sessions and audit rows record the proxy's own address, and better-auth's per-address rate limit counts every client behind the proxy as one client.
14+
- With `server.trustProxy` declared, a forwarded chain (`client, proxy`) now resolves to its first entry. Before, better-auth read a multi-entry header as no address.
15+
- A request whose client address cannot be established records no address, and better-auth's rate limiter counts it in one shared bucket.
16+
17+
**For hosts:** `AuthManager.handleRequest` now drops any `x-objectstack-client-address` request header a caller sends. The platform writes that header itself, from the address it established, and better-auth reads no other header for the client address. A programmatic `auth.api.*` call is handed whatever headers its caller passes. Route a client's request through `handleRequest` instead.

‎packages/plugins/plugin-auth/src/auth-account-event-audit.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,20 @@ function deployment(config: Record<string, unknown> = {}) {
5656
return { engine, manager, events };
5757
}
5858

59+
// The client is the connection's peer address, handed over the way the auth
60+
// route hands the transport's peer: the address the client-address trust rule
61+
// (`client-address.ts`) establishes when no proxy is declared trusted.
5962
const post = (manager: AuthManager, path: string, body: unknown) =>
6063
manager.handleRequest(
6164
new Request(`${BASE}${path}`, {
6265
method: 'POST',
6366
headers: {
6467
'Content-Type': 'application/json',
65-
'x-forwarded-for': CLIENT_IP,
6668
'user-agent': CLIENT_UA,
6769
},
6870
body: JSON.stringify(body),
6971
}),
72+
{ remoteAddress: CLIENT_IP },
7073
);
7174

7275
async function signUp(manager: AuthManager): Promise<void> {

‎packages/plugins/plugin-auth/src/auth-manager.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
44
import { AuthManager, ipMatchesRange } from './auth-manager';
5+
import { vendorClientAddressOptions } from './client-address-vendor';
56
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/objectql';
67

78
// Mock better-auth so we can control the handler behaviour
@@ -2116,6 +2117,7 @@ describe('AuthManager', () => {
21162117
domain: '.objectos.app',
21172118
},
21182119
useSecureCookies: true,
2120+
ipAddress: vendorClientAddressOptions(),
21192121
});
21202122
});
21212123

@@ -2142,7 +2144,7 @@ describe('AuthManager', () => {
21422144
expect(capturedConfig.advanced.cookiePrefix).toBe('objectos');
21432145
});
21442146

2145-
it('should NOT include advanced when not provided', async () => {
2147+
it('carries only the library client-address source when no advanced option is provided', async () => {
21462148
let capturedConfig: any;
21472149
(betterAuth as any).mockImplementation((config: any) => {
21482150
capturedConfig = config;
@@ -2157,7 +2159,7 @@ describe('AuthManager', () => {
21572159
await manager.getAuthInstance();
21582160
warnSpy.mockRestore();
21592161

2160-
expect(capturedConfig).not.toHaveProperty('advanced');
2162+
expect(capturedConfig.advanced).toEqual({ ipAddress: vendorClientAddressOptions() });
21612163
});
21622164
});
21632165

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ import {
136136
import { isPlaceholderEmail } from './placeholder-email.js';
137137
import { readAccountInvitation, settleImportResetDelivery } from './account-invitation-marker.js';
138138
import { resolveClientAddress } from './client-address.js';
139+
import { vendorClientAddressOptions, withVendorClientAddress } from './client-address-vendor.js';
139140
import { clientAddressStateEntries, readRequestClientAddress } from './request-client-address.js';
140141
import {
141142
REVOKE_SESSION_NOT_FOUND_CODE,
@@ -1060,9 +1061,13 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
10601061
* The deployment's `server.trustProxy`: believe `X-Forwarded-For` /
10611062
* `X-Real-IP` when establishing a request's client address for the ADR-0069
10621063
* D5 allow-list ({@link allowedIpRanges}) and the D7 login audit
1063-
* (`last_login_ip`). The same authored value the runtime's inbound rate
1064-
* limiter reads; `objectstack serve` passes it to both. A host that composes
1065-
* `AuthPlugin` itself passes the value it gives the dispatcher.
1064+
* (`last_login_ip`) — and for what the vendored auth library records and
1065+
* keys on by client address (session `ip_address`, the sign-in audit
1066+
* fingerprint, its own per-address {@link rateLimit}), which is handed that
1067+
* same address (`client-address-vendor.ts`). The same authored value the
1068+
* runtime's inbound rate limiter reads; `objectstack serve` passes it to
1069+
* both. A host that composes `AuthPlugin` itself passes the value it gives
1070+
* the dispatcher.
10661071
* @default false
10671072
*/
10681073
trustProxy?: boolean;
@@ -3083,19 +3088,23 @@ export class AuthManager {
30833088
return origins.length ? { trustedOrigins: origins } : {};
30843089
})(),
30853090

3086-
// Advanced options (cross-subdomain cookies, secure cookies, CSRF, etc.)
3087-
...(this.config.advanced ? {
3088-
advanced: {
3089-
...(this.config.advanced.crossSubDomainCookies
3090-
? { crossSubDomainCookies: this.config.advanced.crossSubDomainCookies } : {}),
3091-
...(this.config.advanced.useSecureCookies != null
3092-
? { useSecureCookies: this.config.advanced.useSecureCookies } : {}),
3093-
...(this.config.advanced.disableCSRFCheck != null
3094-
? { disableCSRFCheck: this.config.advanced.disableCSRFCheck } : {}),
3095-
...(this.config.advanced.cookiePrefix != null
3096-
? { cookiePrefix: this.config.advanced.cookiePrefix } : {}),
3097-
},
3098-
} : {}),
3091+
// Advanced options (cross-subdomain cookies, secure cookies, CSRF, etc.),
3092+
// and — always — the library's own client-address source: the one
3093+
// platform-owned header `handleRequest` writes from the address the
3094+
// client-address trust rule established (`client-address-vendor.ts`).
3095+
// Never left at the library's default reader, which knows neither the
3096+
// transport's peer address nor `server.trustProxy`.
3097+
advanced: {
3098+
...(this.config.advanced?.crossSubDomainCookies
3099+
? { crossSubDomainCookies: this.config.advanced.crossSubDomainCookies } : {}),
3100+
...(this.config.advanced?.useSecureCookies != null
3101+
? { useSecureCookies: this.config.advanced.useSecureCookies } : {}),
3102+
...(this.config.advanced?.disableCSRFCheck != null
3103+
? { disableCSRFCheck: this.config.advanced.disableCSRFCheck } : {}),
3104+
...(this.config.advanced?.cookiePrefix != null
3105+
? { cookiePrefix: this.config.advanced.cookiePrefix } : {}),
3106+
ipAddress: vendorClientAddressOptions(),
3107+
},
30993108
};
31003109

31013110
const auth = betterAuth(betterAuthConfig);
@@ -6504,7 +6513,8 @@ export class AuthManager {
65046513
* `Request` does not carry: `remoteAddress` is its peer address
65056514
* (`IHttpRequest.remoteAddress`). Omitted, the request's client address is
65066515
* established from trusted forwarded headers alone, and is undetermined
6507-
* when `trustProxy` is not declared.
6516+
* when `trustProxy` is not declared. Either way the vendored library is
6517+
* handed that address and no other (`client-address-vendor.ts`).
65086518
* @returns Web standard Response object
65096519
*/
65106520
async handleRequest(request: Request, transport?: { remoteAddress?: string }): Promise<Response> {
@@ -6521,6 +6531,14 @@ export class AuthManager {
65216531
...(transport?.remoteAddress ? { remoteAddress: transport.remoteAddress } : {}),
65226532
});
65236533

6534+
// The vendored library's OWN client-address reader (session `ip_address`,
6535+
// the sign-in audit fingerprint, its per-address rate limiter) reads only
6536+
// the platform-owned header (`vendorClientAddressOptions`, composed in
6537+
// `createAuthInstance`). Written HERE, from the address just established,
6538+
// after dropping any copy the caller sent — so the library answers the same
6539+
// address the D5 allow-list judged, or none. See `client-address-vendor.ts`.
6540+
request = withVendorClientAddress(request, clientAddress);
6541+
65246542
// Dev DX: better-auth's CSRF protection rejects state-changing requests
65256543
// (e.g. `/sign-in/email`) with a 403 when neither `Origin` nor `Referer`
65266544
// is present. Browsers always send one; non-browser API clients (curl,
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The vendored auth library's OWN client-address source follows the
4+
// platform's client-address trust rule (`./client-address.ts`), the rule the
5+
// ADR-0069 D5 allow-list judges with and the D7 login audit stamps by:
6+
//
7+
// - forwarded headers count only when the deployment declares
8+
// `server.trustProxy` (`AuthManagerOptions.trustProxy`);
9+
// - otherwise the address is the transport's peer (`IHttpRequest.remoteAddress`);
10+
// - neither available ⇒ the library is handed NO address.
11+
//
12+
// One pin per branch. Each reads, off one composition, the three things the
13+
// library keys on a client address (`client-address-vendor.ts`): the session
14+
// row's `ip_address`, the refused-sign-in audit fingerprint, and the key its
15+
// per-address rate limiter consumes. The fixture is the D5/D7 suite's
16+
// (`client-ip-allow-list-trust-rule.test.ts`): `AuthPlugin.registerAuthRoutes`
17+
// mounted on a real Hono app in front of a real `AuthManager` on the installed
18+
// better-auth, with the transport peer supplied the way `@hono/node-server`
19+
// supplies it, as the Node request on `c.env.incoming`.
20+
//
21+
// Every request also carries the platform-owned header itself, set by the
22+
// caller: in no branch may a caller's copy reach the library.
23+
//
24+
// ADR-0112 is `code` AND `status`; the refused sign-in asserts both, so a
25+
// refusal from anywhere but the credential check cannot stand in for it.
26+
27+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
28+
import { Hono } from 'hono';
29+
import { AuthManager } from './auth-manager';
30+
import { AuthPlugin } from './auth-plugin';
31+
import { VENDOR_CLIENT_ADDRESS_HEADER } from './client-address-vendor';
32+
import { createMemoryEngine } from './impersonation-bearer-rotation.test';
33+
import { inviteForAudienceGate } from './audience-gate-test-support';
34+
import type { PluginContext } from '@objectstack/core';
35+
36+
const SECRET = 'test-secret-at-least-32-chars-long!!';
37+
const PASSWORD = 'S3cure!Passw0rd-24799';
38+
const ORIGIN = 'http://localhost:3000';
39+
const BASE = '/api/v1/auth';
40+
const EMAIL = 'member.24799@example.com';
41+
42+
/** Documentation ranges (RFC 5737), and one private proxy hop. */
43+
const CLIENT = '203.0.113.7';
44+
const OTHER = '198.51.100.9';
45+
const PROXY_HOP = '10.0.0.2';
46+
47+
/** The credential check's own refusal (ADR-0112 `code` + `status`). */
48+
const CREDENTIAL_REFUSAL = { status: 401, code: 'INVALID_EMAIL_OR_PASSWORD' };
49+
50+
const mockCtx = () =>
51+
({
52+
registerService: vi.fn(),
53+
getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)),
54+
getServices: vi.fn(() => new Map()),
55+
hook: vi.fn(),
56+
trigger: vi.fn(),
57+
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
58+
getKernel: vi.fn(),
59+
}) as unknown as PluginContext;
60+
61+
async function verdict(res: Response): Promise<{ status: number; code?: string }> {
62+
const text = await res.text();
63+
let code: string | undefined;
64+
try {
65+
const parsed = JSON.parse(text);
66+
code = parsed?.error?.code ?? parsed?.code;
67+
} catch {
68+
/* non-JSON body → no code */
69+
}
70+
return { status: res.status, code };
71+
}
72+
73+
/** What the library keyed on one client address, per seam. */
74+
interface Observed {
75+
/** `sys_session.ip_address` of the session the successful sign-in minted. */
76+
session: unknown;
77+
/** The `login_failed` audit event's address, for the refused sign-in. */
78+
refused: unknown;
79+
/** The address half of every rate-limit key consumed across both sign-ins. */
80+
limiterKeys: string[];
81+
/** ADR-0069 D7 `last_login_ip` after the successful sign-in. */
82+
lastLoginIp: unknown;
83+
}
84+
85+
/** One deployment, served through the REAL mount chain. */
86+
async function serve(deployment: { trustProxy?: boolean }) {
87+
const engine = createMemoryEngine();
88+
const events: Array<{ action: string; ipAddress?: string }> = [];
89+
const consumedKeys: string[] = [];
90+
const manager = new AuthManager({
91+
secret: SECRET,
92+
baseUrl: ORIGIN,
93+
dataEngine: engine,
94+
getAuditSink: () => ({
95+
recordAuthEvent: async (event: { action: string; ipAddress?: string }) => {
96+
events.push(event);
97+
},
98+
}),
99+
// The library's limiter is on by default only in production; switch it on
100+
// and record every key it consumes, admitting every request.
101+
rateLimit: { enabled: true },
102+
rateLimitStorage: {
103+
consume: async (key: string) => {
104+
consumedKeys.push(key);
105+
return { allowed: true, retryAfter: null };
106+
},
107+
},
108+
...deployment,
109+
} as any);
110+
111+
const ctx = mockCtx();
112+
const plugin = new AuthPlugin({ secret: SECRET });
113+
await plugin.init(ctx);
114+
(plugin as any).authManager = manager;
115+
const app = new Hono();
116+
(plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx);
117+
118+
const signIn = (peer: string | undefined, headers: Record<string, string>, password: string) =>
119+
app.request(
120+
`${ORIGIN}${BASE}/sign-in/email`,
121+
{
122+
method: 'POST',
123+
headers: { 'content-type': 'application/json', origin: ORIGIN, ...headers },
124+
body: JSON.stringify({ email: EMAIL, password }),
125+
},
126+
peer ? { incoming: { socket: { remoteAddress: peer } } } : {},
127+
);
128+
129+
await inviteForAudienceGate(manager, EMAIL);
130+
const signUp = await manager.handleRequest(
131+
new Request(`${ORIGIN}${BASE}/sign-up/email`, {
132+
method: 'POST',
133+
headers: { 'content-type': 'application/json', origin: ORIGIN },
134+
body: JSON.stringify({ email: EMAIL, password: PASSWORD, name: 'Client Address Member' }),
135+
}),
136+
);
137+
expect(signUp.status, `sign-up: ${await signUp.clone().text()}`).toBe(200);
138+
139+
/** A refused, then a successful, sign-in from `peer` carrying `headers`. */
140+
const observe = async (peer: string | undefined, headers: Record<string, string>): Promise<Observed> => {
141+
const sessionsBefore = (engine.tables.get('sys_session') ?? []).length;
142+
const eventsBefore = events.length;
143+
const keysBefore = consumedKeys.length;
144+
145+
const refusedRes = await signIn(peer, headers, `${PASSWORD}-wrong`);
146+
expect(await verdict(refusedRes)).toEqual(CREDENTIAL_REFUSAL);
147+
const okRes = await signIn(peer, headers, PASSWORD);
148+
expect(okRes.status, await okRes.clone().text()).toBe(200);
149+
150+
const minted = ((engine.tables.get('sys_session') ?? []) as any[]).slice(sessionsBefore);
151+
expect(minted, 'one session minted by the successful sign-in').toHaveLength(1);
152+
const refused = events.slice(eventsBefore).filter((e) => e.action === 'login_failed');
153+
expect(refused, 'one login_failed event for the refused sign-in').toHaveLength(1);
154+
const limiterKeys = consumedKeys.slice(keysBefore).map((key) => key.split('|')[0]!);
155+
expect(limiterKeys, 'the library limiter consumed a key for each sign-in').toHaveLength(2);
156+
const user = ((engine.tables.get('sys_user') ?? []) as any[]).find((r) => r.email === EMAIL);
157+
158+
return {
159+
session: minted[0]?.ip_address,
160+
refused: refused[0]?.ipAddress,
161+
limiterKeys,
162+
lastLoginIp: user?.last_login_ip,
163+
};
164+
};
165+
166+
return { observe };
167+
}
168+
169+
beforeEach(() => {
170+
vi.spyOn(console, 'warn').mockImplementation(() => {});
171+
vi.spyOn(console, 'error').mockImplementation(() => {});
172+
});
173+
afterEach(() => {
174+
vi.restoreAllMocks();
175+
});
176+
177+
describe('the vendored auth library keys on the client address the server established, and no other', () => {
178+
it('behind a declared trusted proxy, it records and keys on the forwarded client address', async () => {
179+
const d = await serve({ trustProxy: true });
180+
181+
const seen = await d.observe(PROXY_HOP, {
182+
'x-forwarded-for': `${CLIENT}, ${PROXY_HOP}`,
183+
[VENDOR_CLIENT_ADDRESS_HEADER]: OTHER,
184+
});
185+
186+
expect(seen.session).toBe(CLIENT);
187+
expect(seen.refused).toBe(CLIENT);
188+
expect(seen.limiterKeys).toEqual([CLIENT, CLIENT]);
189+
// The same address the D7 login audit stamped.
190+
expect(seen.lastLoginIp).toBe(CLIENT);
191+
});
192+
193+
it('without a declared trusted proxy, it records and keys on the connection peer, whatever the request headers say', async () => {
194+
const d = await serve({});
195+
196+
const seen = await d.observe(CLIENT, {
197+
'x-forwarded-for': OTHER,
198+
'x-real-ip': OTHER,
199+
[VENDOR_CLIENT_ADDRESS_HEADER]: OTHER,
200+
});
201+
202+
expect(seen.session).toBe(CLIENT);
203+
expect(seen.refused).toBe(CLIENT);
204+
expect(seen.limiterKeys).toEqual([CLIENT, CLIENT]);
205+
expect(seen.lastLoginIp).toBe(CLIENT);
206+
});
207+
208+
it('when the server established no address, it is handed none, whatever the request headers say', async () => {
209+
// What the library answers when it is handed no address is its own,
210+
// environment-dependent behaviour (nothing in production, a loopback
211+
// placeholder in development and test) — so each deployment's answer to a
212+
// request carrying no header at all is the control, never a literal.
213+
for (const deployment of [{}, { trustProxy: true }]) {
214+
const d = await serve(deployment);
215+
const control = await d.observe(undefined, {});
216+
const seen = await d.observe(undefined, {
217+
...(deployment.trustProxy ? {} : { 'x-forwarded-for': OTHER, 'x-real-ip': OTHER }),
218+
[VENDOR_CLIENT_ADDRESS_HEADER]: OTHER,
219+
});
220+
221+
const label = deployment.trustProxy ? 'trusted proxy, nothing forwarded' : 'no trusted proxy';
222+
expect(seen.session, label).toEqual(control.session);
223+
expect(seen.refused, label).toEqual(control.refused);
224+
expect(seen.limiterKeys, label).toEqual(control.limiterKeys);
225+
expect([seen.session, seen.refused, ...seen.limiterKeys], label).not.toContain(OTHER);
226+
// …and the D7 login audit stamped none either.
227+
expect(seen.lastLoginIp, label).toBeUndefined();
228+
}
229+
});
230+
});

0 commit comments

Comments
 (0)