Skip to content

Commit 4ebc356

Browse files
committed
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 <noreply@anthropic.com>
1 parent 69328bd commit 4ebc356

2 files changed

Lines changed: 261 additions & 3 deletions

File tree

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4822,10 +4822,26 @@ describe('AuthManager', () => {
48224822
expect(m.isClientIpAllowed('8.8.8.8')).toBe(false);
48234823
});
48244824

4825-
it('fails OPEN when the client IP cannot be determined (no proxy header)', () => {
4825+
it('refuses an address the server could not establish once ranges are configured', () => {
48264826
const m = mgr({ allowedIpRanges: ['203.0.113.0/24'] });
4827-
expect(m.isClientIpAllowed(undefined)).toBe(true);
4828-
expect(m.isClientIpAllowed('')).toBe(true);
4827+
expect(m.isClientIpAllowed(undefined)).toBe(false);
4828+
expect(m.isClientIpAllowed('')).toBe(false);
4829+
});
4830+
4831+
it('ipMatchesRange reads an IPv4-mapped IPv6 address as the IPv4 address it denotes', () => {
4832+
expect(ipMatchesRange('::ffff:203.0.113.5', '203.0.113.0/24')).toBe(true);
4833+
expect(ipMatchesRange('::FFFF:203.0.114.5', '203.0.113.0/24')).toBe(false);
4834+
expect(ipMatchesRange('::ffff:10.0.0.1', '10.0.0.1')).toBe(true);
4835+
expect(ipMatchesRange('10.0.0.1', '::ffff:10.0.0.1')).toBe(true);
4836+
expect(ipMatchesRange('2001:db8::1', '2001:db8::1')).toBe(true);
4837+
});
4838+
4839+
it('resolveClientAddress believes forwarded headers only under trustProxy', () => {
4840+
const headers = { 'x-forwarded-for': '203.0.113.9, 10.0.0.2' };
4841+
expect(mgr({}).resolveClientAddress({ headers, remoteAddress: '10.0.0.2' })).toBe('10.0.0.2');
4842+
expect(mgr({ trustProxy: true }).resolveClientAddress({ headers, remoteAddress: '10.0.0.2' }))
4843+
.toBe('203.0.113.9');
4844+
expect(mgr({}).resolveClientAddress({ headers })).toBeUndefined();
48294845
});
48304846
});
48314847

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0069 D5 / D7 — the IP allow-list judges, and the login audit records, the
4+
// client address the SERVER established, by the platform's one client-address
5+
// trust rule (`./client-address.ts`, shared with the runtime's inbound rate
6+
// limiter):
7+
//
8+
// - forwarded headers count only when the deployment declares
9+
// `server.trustProxy` (`AuthManagerOptions.trustProxy`);
10+
// - otherwise the address is the transport's peer (`IHttpRequest.remoteAddress`);
11+
// - an address that cannot be established is REFUSED while ranges are set;
12+
// - with no ranges configured nothing is judged.
13+
//
14+
// One pin per branch. The fixture mounts `AuthPlugin.registerAuthRoutes` on a
15+
// real Hono app in front of a real `AuthManager` on the installed better-auth,
16+
// and every verdict is read off a real `Response`: the gate and the route that
17+
// feeds the audit are raw-app mounts, so a suite driving `AuthManager` alone
18+
// would never run them. The transport peer is supplied the way
19+
// `@hono/node-server` supplies it, as the Node request on `c.env.incoming`.
20+
//
21+
// ADR-0112 is `code` AND `status`; every refusal assertion carries both.
22+
23+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
24+
import { Hono } from 'hono';
25+
import { AuthManager } from './auth-manager';
26+
import { AuthPlugin } from './auth-plugin';
27+
import { createMemoryEngine } from './impersonation-bearer-rotation.test';
28+
import { inviteForAudienceGate } from './audience-gate-test-support';
29+
import type { PluginContext } from '@objectstack/core';
30+
31+
const SECRET = 'test-secret-at-least-32-chars-long!!';
32+
const PASSWORD = 'S3cure!Passw0rd-24739';
33+
const ORIGIN = 'http://localhost:3000';
34+
const BASE = '/api/v1/auth';
35+
const EMAIL = 'member.24739@example.com';
36+
37+
/** Documentation ranges (RFC 5737): one allowed, one not, one private proxy hop. */
38+
const RANGE = '203.0.113.0/24';
39+
const INSIDE = '203.0.113.7';
40+
const OUTSIDE = '198.51.100.9';
41+
const PROXY_HOP = '10.0.0.2';
42+
43+
const mockCtx = () =>
44+
({
45+
registerService: vi.fn(),
46+
getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)),
47+
getServices: vi.fn(() => new Map()),
48+
hook: vi.fn(),
49+
trigger: vi.fn(),
50+
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
51+
getKernel: vi.fn(),
52+
}) as unknown as PluginContext & { logger: { warn: ReturnType<typeof vi.fn> } };
53+
54+
/**
55+
* ADMITTED: the request passed the gate and reached the auth service, whose
56+
* session read refuses a request carrying no credential with its own answer.
57+
* REFUSED: the D5 gate answered before the auth service ran.
58+
*/
59+
const ADMITTED = { status: 401, code: 'UNAUTHENTICATED' };
60+
const REFUSED = { status: 403, code: 'IP_NOT_ALLOWED' };
61+
62+
/** Status + the error code the body carries (ADR-0112 nests it under `error`). */
63+
async function verdict(res: Response): Promise<{ status: number; code?: string }> {
64+
const text = await res.text();
65+
let code: string | undefined;
66+
try {
67+
const parsed = JSON.parse(text);
68+
code = parsed?.error?.code ?? parsed?.code;
69+
} catch {
70+
/* non-JSON body → no code */
71+
}
72+
return { status: res.status, code };
73+
}
74+
75+
interface Deployment {
76+
allowedIpRanges?: string[];
77+
trustProxy?: boolean;
78+
}
79+
80+
/** One deployment, served through the REAL mount chain. */
81+
async function serve(deployment: Deployment) {
82+
const engine = createMemoryEngine();
83+
const manager = new AuthManager({
84+
secret: SECRET,
85+
baseUrl: ORIGIN,
86+
dataEngine: engine,
87+
...deployment,
88+
} as any);
89+
90+
const ctx = mockCtx();
91+
const plugin = new AuthPlugin({ secret: SECRET });
92+
await plugin.init(ctx);
93+
(plugin as any).authManager = manager;
94+
const app = new Hono();
95+
(plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx);
96+
97+
/** A request through the mounts. `peer` is the transport's own peer address, or none. */
98+
const send = (
99+
path: string,
100+
init: { method: string; headers?: Record<string, string>; body?: string },
101+
peer?: string,
102+
) =>
103+
app.request(
104+
`${ORIGIN}${BASE}${path}`,
105+
init,
106+
peer ? { incoming: { socket: { remoteAddress: peer } } } : {},
107+
);
108+
109+
/** The cheapest auth request: a session read with no credential ({@link ADMITTED} when let through). */
110+
const probe = (peer: string | undefined, headers: Record<string, string> = {}) =>
111+
send('/get-session', { method: 'GET', headers }, peer);
112+
113+
/** Create the fixture user directly (no mount), so only the sign-in crosses the gate. */
114+
const signUp = async () => {
115+
await inviteForAudienceGate(manager, EMAIL);
116+
const res = await manager.handleRequest(
117+
new Request(`${ORIGIN}${BASE}/sign-up/email`, {
118+
method: 'POST',
119+
headers: { 'content-type': 'application/json', origin: ORIGIN },
120+
body: JSON.stringify({ email: EMAIL, password: PASSWORD, name: 'Allow-list Member' }),
121+
}),
122+
);
123+
expect(res.status, `sign-up: ${await res.clone().text()}`).toBe(200);
124+
};
125+
126+
/** Sign in THROUGH the mounts, as the transport delivers it. */
127+
const signIn = (peer: string | undefined, headers: Record<string, string> = {}) =>
128+
send(
129+
'/sign-in/email',
130+
{
131+
method: 'POST',
132+
headers: { 'content-type': 'application/json', origin: ORIGIN, ...headers },
133+
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
134+
},
135+
peer,
136+
);
137+
138+
const userRow = () =>
139+
((engine.tables.get('sys_user') ?? []) as any[]).find((r) => r.email === EMAIL);
140+
141+
return { ctx, probe, signUp, signIn, userRow };
142+
}
143+
144+
beforeEach(() => {
145+
vi.spyOn(console, 'warn').mockImplementation(() => {});
146+
vi.spyOn(console, 'error').mockImplementation(() => {});
147+
});
148+
afterEach(() => {
149+
vi.restoreAllMocks();
150+
});
151+
152+
describe('ADR-0069 D5 — the allow-list judges the client address the server established', () => {
153+
it('behind a declared trusted proxy, the allow-list judges the forwarded client address', async () => {
154+
const { probe } = await serve({ allowedIpRanges: [RANGE], trustProxy: true });
155+
156+
// The proxy hop is outside the ranges; the forwarded client is inside.
157+
expect(await verdict(await probe(PROXY_HOP, { 'x-forwarded-for': `${INSIDE}, ${PROXY_HOP}` })))
158+
.toEqual(ADMITTED);
159+
// …and the converse: the forwarded client decides, not the hop.
160+
expect(await verdict(await probe(INSIDE, { 'x-forwarded-for': OUTSIDE })))
161+
.toEqual(REFUSED);
162+
});
163+
164+
it('without a declared trusted proxy, the allow-list judges the connection peer and a forwarded header changes nothing', async () => {
165+
const { probe } = await serve({ allowedIpRanges: [RANGE] });
166+
167+
expect(await verdict(await probe(OUTSIDE, { 'x-forwarded-for': INSIDE, 'x-real-ip': INSIDE })))
168+
.toEqual(REFUSED);
169+
expect(await verdict(await probe(INSIDE, { 'x-forwarded-for': OUTSIDE })))
170+
.toEqual(ADMITTED);
171+
});
172+
173+
it('an address the server could not establish is refused while ranges are set, and the operator is told why once', async () => {
174+
const untrusted = await serve({ allowedIpRanges: [RANGE] });
175+
// No transport peer, and a forwarded header that is not believed.
176+
expect(await verdict(await untrusted.probe(undefined, { 'x-forwarded-for': INSIDE })))
177+
.toEqual(REFUSED);
178+
expect(await verdict(await untrusted.probe(undefined)))
179+
.toEqual(REFUSED);
180+
181+
const undetermined = untrusted.ctx.logger.warn.mock.calls
182+
.map((call) => String(call[0]))
183+
.filter((line) => line.includes('allowed_ip_ranges') && line.includes('server.trustProxy'));
184+
expect(undetermined, 'one operator line, naming the setting and server.trustProxy').toHaveLength(1);
185+
186+
// A trusted proxy that forwarded nothing, on a transport with no peer.
187+
const trusted = await serve({ allowedIpRanges: [RANGE], trustProxy: true });
188+
expect(await verdict(await trusted.probe(undefined)))
189+
.toEqual(REFUSED);
190+
});
191+
192+
it('with no ranges configured nothing is judged, whatever the address', async () => {
193+
const { probe } = await serve({});
194+
195+
expect(await verdict(await probe(undefined))).toEqual(ADMITTED);
196+
expect(await verdict(await probe(OUTSIDE, { 'x-forwarded-for': OUTSIDE })))
197+
.toEqual(ADMITTED);
198+
199+
const empty = await serve({ allowedIpRanges: [] });
200+
expect(await verdict(await empty.probe(undefined))).toEqual(ADMITTED);
201+
});
202+
203+
it('an IPv4 peer reported in IPv4-mapped form is judged as its IPv4 address', async () => {
204+
const { probe } = await serve({ allowedIpRanges: [RANGE] });
205+
206+
expect(await verdict(await probe(`::ffff:${INSIDE}`))).toEqual(ADMITTED);
207+
expect(await verdict(await probe(`::ffff:${OUTSIDE}`)))
208+
.toEqual(REFUSED);
209+
});
210+
});
211+
212+
describe('ADR-0069 D7 — the login audit records the address the allow-list judged', () => {
213+
it('records the connection peer, not a forwarded header, when no proxy is trusted', async () => {
214+
const d = await serve({});
215+
await d.signUp();
216+
217+
const res = await d.signIn(INSIDE, { 'x-forwarded-for': OUTSIDE });
218+
expect(res.status, await res.clone().text()).toBe(200);
219+
expect(d.userRow()?.last_login_at instanceof Date).toBe(true);
220+
expect(d.userRow()?.last_login_ip).toBe(INSIDE);
221+
});
222+
223+
it('records the forwarded client address the gate admitted when the proxy is declared trusted', async () => {
224+
const d = await serve({ allowedIpRanges: [RANGE], trustProxy: true });
225+
await d.signUp();
226+
227+
const res = await d.signIn(PROXY_HOP, { 'x-forwarded-for': `${INSIDE}, ${PROXY_HOP}` });
228+
expect(res.status, await res.clone().text()).toBe(200);
229+
expect(d.userRow()?.last_login_ip).toBe(INSIDE);
230+
});
231+
232+
it('records no address when none could be established', async () => {
233+
const d = await serve({});
234+
await d.signUp();
235+
236+
const res = await d.signIn(undefined, { 'x-forwarded-for': OUTSIDE });
237+
expect(res.status, await res.clone().text()).toBe(200);
238+
// The stamp ran (last_login_at), and it carried no address.
239+
expect(d.userRow()?.last_login_at instanceof Date).toBe(true);
240+
expect(d.userRow()?.last_login_ip).toBeUndefined();
241+
});
242+
});

0 commit comments

Comments
 (0)