From ec91a185d9cc8f5c1a787f5db06b2439d1fa0f71 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 10:28:43 +0000 Subject: [PATCH 1/3] wip(plugin-auth): admin read of the caller address the allowed IP ranges save check judges Claude-Session: https://claude.ai/code/session_01Utp6CZbTJpa9PNbpp3fdB7 Co-authored-by: Claude --- .../src/allowed-ip-ranges.pin.test.ts | 134 +++++++++++++++++- .../plugin-auth/src/allowed-ip-ranges.ts | 50 +++++-- .../plugins/plugin-auth/src/auth-plugin.ts | 24 ++++ .../src/auth-route-ledger.conformance.test.ts | 5 + .../plugin-auth/src/auth-route-ledger.ts | 4 + .../plugin-auth/src/client-address-read.ts | 106 ++++++++++++++ 6 files changed, 312 insertions(+), 11 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/client-address-read.ts diff --git a/packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts b/packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts index 56cef32bb1f..39df0a9825b 100644 --- a/packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts +++ b/packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts @@ -21,12 +21,17 @@ * the deployment declares `trustProxy`. A list that includes it saves. * - The recovery the refusal names: `OS_AUTH_ALLOWED_IP_RANGES` set empty * opens the gate on the next boot over a stored list. + * - [#30398] The read an administrator asks before saving, + * `GET /api/v1/auth/admin/client-address`, answers the address that same + * save would be judged by: the plugin's real auth routes on a real Hono app, + * the transport peer supplied the way `@hono/node-server` supplies it. * * `@objectstack/service-settings` resolves to its `src/` here (this package's * `vitest.config.ts`), so the routes and the service are the checkout's. */ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; import { SettingsService, @@ -56,11 +61,18 @@ class MockHttp implements IHttpServer { getInstance() { return null; } } +type Transport = { headers?: Record; remoteAddress?: string }; + +/** The session the read's platform-admin gate is handed: a platform admin unless a test says otherwise. */ +const PLATFORM_ADMIN_SESSION = { user: { id: 'usr_admin', email: 'admin@example.com', isPlatformAdmin: true } }; + interface Rig { svc: SettingsService; manager: AuthManager; - save(body: Record, transport?: { headers?: Record; remoteAddress?: string }): - Promise<{ status: number; body: any }>; + save(body: Record, transport?: Transport): Promise<{ status: number; body: any }>; + /** `GET /api/v1/auth/admin/client-address` on the plugin's real auth routes. */ + read(transport?: Transport, opts?: { session?: unknown; query?: string }): + Promise<{ status: number; body: any; cacheControl: string | null }>; } async function boot( @@ -116,7 +128,28 @@ async function boot( await http.routes.get('PUT /api/settings/:namespace')!(req, res); return out; }; - return { svc, manager, save }; + + let app: Hono | undefined; + const read: Rig['read'] = async (transport = {}, opts = {}) => { + if (!app) { + app = new Hono(); + (plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); + } + const session = 'session' in opts ? opts.session : PLATFORM_ADMIN_SESSION; + // The gate reads the session through the auth service's in-process read. + const getApi = vi.spyOn(manager, 'getApi').mockResolvedValue({ getSession: async () => session } as any); + try { + const res = await app.request( + `http://localhost:3000/api/v1/auth/admin/client-address${opts.query ?? ''}`, + { method: 'GET', headers: transport.headers ?? {} }, + transport.remoteAddress ? { incoming: { socket: { remoteAddress: transport.remoteAddress } } } : {}, + ); + return { status: res.status, body: await res.json(), cacheControl: res.headers.get('cache-control') }; + } finally { + getApi.mockRestore(); + } + }; + return { svc, manager, save, read }; } const stored = async (svc: SettingsService) => (await svc.get('auth', 'allowed_ip_ranges', {})).value ?? null; @@ -257,3 +290,98 @@ describe('#30320 an IPv6 range is judged at both doors as the range it names', ( expect(manager.isClientIpAllowed('2001:db8:1234::7')).toBe(true); }); }); + +describe('#30398 the address read serves the address the save check judges', () => { + // TEST-NET-1: no address any case below connects from falls inside it, so a + // save of this list is always refused and its refusal names the judged address. + const NOBODY = '192.0.2.0/24'; + + /** One connection, asked at both doors: the read's answer and the save check's judged address. */ + const bothDoors = async (rig: Rig, transport: Transport) => { + const read = await rig.read(transport); + expect(read.status, JSON.stringify(read.body)).toBe(200); + const save = await rig.save({ allowed_ip_ranges: NOBODY }, transport); + expect(save.status, JSON.stringify(save.body)).toBe(400); + const judged = save.body.error.details.fields.find((f: any) => f.field === 'allowed_ip_ranges').constraint.clientAddress; + return { shown: read.body.data, judged }; + }; + + it('a direct peer: the read shows the peer address the save judges', async () => { + const { shown, judged } = await bothDoors(await boot(), { remoteAddress: '203.0.113.9' }); + expect(shown).toEqual({ clientAddress: '203.0.113.9', trustProxy: false }); + expect(judged).toBe(shown.clientAddress); + }); + + it('an IPv4-mapped peer: the read shows the mapped text the save judges, not a rewritten one', async () => { + const { shown, judged } = await bothDoors(await boot(), { remoteAddress: '::ffff:127.0.0.1' }); + expect(shown).toEqual({ clientAddress: '::ffff:127.0.0.1', trustProxy: false }); + expect(judged).toBe(shown.clientAddress); + }); + + it('trustProxy declared: the read shows the forwarded address the save judges', async () => { + const forwarded = { headers: { 'x-forwarded-for': '198.51.100.7, 10.0.0.2' }, remoteAddress: '10.0.0.1' }; + const { shown, judged } = await bothDoors(await boot({ plugin: { trustProxy: true } }), forwarded); + expect(shown).toEqual({ clientAddress: '198.51.100.7', trustProxy: true }); + expect(judged).toBe(shown.clientAddress); + }); + + it('trustProxy not declared: a forged X-Forwarded-For is ignored at both doors, the peer is shown', async () => { + const forged = { headers: { 'x-forwarded-for': '198.51.100.7', 'x-real-ip': '198.51.100.8' }, remoteAddress: '10.0.0.1' }; + const { shown, judged } = await bothDoors(await boot(), forged); + expect(shown).toEqual({ clientAddress: '10.0.0.1', trustProxy: false }); + expect(judged).toBe(shown.clientAddress); + }); + + it('an address the server cannot establish reads null at both doors', async () => { + const { shown, judged } = await bothDoors(await boot(), {}); + expect(shown).toEqual({ clientAddress: null, trustProxy: false }); + expect(judged).toBeNull(); + }); + + it('the answer is never stored by a cache', async () => { + const res = await (await boot()).read({ remoteAddress: '203.0.113.9' }); + expect(res.status).toBe(200); + expect(res.cacheControl).toBe('no-store'); + }); + + describe('who may read it: the platform-admin gate of every /admin mount, before any address is read', () => { + const refusedBeforeTheRead = async (session: unknown) => { + const rig = await boot(); + const asked = vi.spyOn(rig.manager, 'trustsForwardedHeaders'); + const res = await rig.read({ remoteAddress: '203.0.113.9' }, { session }); + expect(asked).not.toHaveBeenCalled(); + expect(res.body.success).toBe(false); + expect(res.body.data).toBeUndefined(); + return res; + }; + + it('no session: 401 UNAUTHENTICATED', async () => { + const res = await refusedBeforeTheRead(null); + expect(res.status).toBe(401); + expect(res.body.error.code).toBe('UNAUTHENTICATED'); + }); + + it('a signed-in caller who is not a platform admin: 403 PERMISSION_DENIED', async () => { + const res = await refusedBeforeTheRead({ user: { id: 'usr_member', email: 'member@example.com' } }); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('PERMISSION_DENIED'); + }); + + it("a held platform admin's session (ADR-0069 D3): 403 with the posture's own code", async () => { + const held = { user: { ...PLATFORM_ADMIN_SESSION.user, authGate: { code: 'MFA_REQUIRED', message: 'Set up two-factor authentication.' } } }; + const res = await refusedBeforeTheRead(held); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe('MFA_REQUIRED'); + }); + }); + + it('the query set is closed and empty: any parameter is refused 400 VALIDATION_ERROR before the address is read', async () => { + const rig = await boot(); + const asked = vi.spyOn(rig.manager, 'trustsForwardedHeaders'); + const res = await rig.read({ remoteAddress: '203.0.113.9' }, { query: '?address=198.51.100.7' }); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + expect(res.body.data).toBeUndefined(); + expect(asked).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts b/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts index 007e9add7ee..579bf04d4da 100644 --- a/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts +++ b/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts @@ -11,6 +11,11 @@ * console, of a list that leaves out the address the saving request itself * comes from. Saved, such a list refuses the administrator's own next * sign-in, and nothing at the save said so. + * - {@link judgedClientAddress} is THE reading of a request's address for this + * setting, called by that save check and by the read that shows an + * administrator the address before they save (`GET /admin/client-address`, + * `client-address-read.ts`, #30398), so the address shown is the address + * judged. * * The address judged at the save is the one the gate judges at sign-in: the * auth manager's {@link AuthManager.resolveClientAddress}, from the same two @@ -32,6 +37,7 @@ import { resolveLocale } from '@objectstack/core'; import { ipMatchesRange, type AuthManager } from './auth-manager.js'; +import { FORWARDED_CLIENT_ADDRESS_HEADERS } from './client-address-forwarding.js'; /** The settings env override that pins `auth.allowed_ip_ranges` (`OS_` + namespace + key). */ export const ALLOWED_IP_RANGES_ENV = 'OS_AUTH_ALLOWED_IP_RANGES'; @@ -49,14 +55,45 @@ export function parseAllowedIpRanges(raw: unknown): string[] { } /** - * The HTTP request a settings write arrived on, as the settings routes hand it - * to a write predicate (`SettingsWriteOptions.transport` in `service-settings`). + * The two facts a door holds about the HTTP request it serves: its headers and + * the transport's own peer address. The settings routes hand a write predicate + * this shape (`SettingsWriteOptions.transport` in `service-settings`). */ -interface WriteTransport { +export interface RequestTransport { headers?: Record; remoteAddress?: string; } +/** + * [#30398] The address `auth.allowed_ip_ranges` judges for a request, or + * `undefined` when the server cannot establish one: the auth manager's + * {@link AuthManager.resolveClientAddress} (`server.trustProxy` read there), + * handed the request's peer address and only the forwarded-address headers that + * rule reads ({@link FORWARDED_CLIENT_ADDRESS_HEADERS}), the narrowing the + * settings service applies to the save check's input before it arrives here. + * + * Both doors that speak about this address call it: the save check below, and + * the read that serves the address to an administrator before a save + * (`client-address-read.ts`). One call, so what the read shows and what the + * save judges cannot drift apart. + */ +export function judgedClientAddress( + manager: Pick, + transport: RequestTransport, +): string | undefined { + let headers: Record | undefined; + if (transport.headers) { + headers = {}; + for (const [name, value] of Object.entries(transport.headers)) { + if (FORWARDED_CLIENT_ADDRESS_HEADERS.includes(name.toLowerCase())) headers[name] = value; + } + } + return manager.resolveClientAddress({ + ...(headers ? { headers } : {}), + ...(transport.remoteAddress ? { remoteAddress: transport.remoteAddress } : {}), + }); +} + /** * What this predicate reads of a settings write predicate's input. Structural, * for the reason `SettingsReadSurface` is: plugin-auth does not depend on @@ -64,7 +101,7 @@ interface WriteTransport { */ export interface AllowedIpRangesWriteInput { next: unknown; - transport?: WriteTransport; + transport?: RequestTransport; locale?: string; } @@ -145,10 +182,7 @@ export function allowedIpRangesWritePredicate( if (ranges.length === 0) return undefined; const mgr = manager(); if (!mgr) return undefined; - const address = mgr.resolveClientAddress({ - ...(transport.headers ? { headers: transport.headers } : {}), - ...(transport.remoteAddress ? { remoteAddress: transport.remoteAddress } : {}), - }); + const address = judgedClientAddress(mgr, transport); if (address && ranges.some((range) => ipMatchesRange(address, range))) return undefined; const copy = copyFor(locale); return { diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 6ef278f3a58..6fde9673520 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -3622,6 +3622,30 @@ export class AuthPlugin implements Plugin { } }); + // ──────────────────────────────────────────────────────────────────── + // [#30398] ADR-0069 D5 — admin: the caller's own address as the + // `auth.allowed_ip_ranges` save check and the sign-in gate judge it, so + // Setup can show it before a save rather than the save refusal naming it + // after. The shared gate first, then the read, which takes the address from + // the save check's own call on this request's headers and transport peer + // (client-address-read.ts). Closed, empty query set; no body is read. + rawApp.get(`${basePath}/admin/client-address`, async (c: any) => { + try { + const actor = await gateAdmin(c); + if (actor instanceof Response) return actor; + const { runClientAddressRead } = await import('./client-address-read.js'); + const answer = runClientAddressRead(this.authManager!, { + queries: (c.req.queries() ?? {}) as Record, + transport: { headers: (c.req.header() ?? {}) as Record, ...transportOf(c) }, + }); + return c.json(answer.body, answer.status as any, answer.headers); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('[AuthPlugin] admin/client-address failed', err); + return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: err.message } }, 500); + } + }); + // ──────────────────────────────────────────────────────────────────── // #2766 V1 — admin direct user management. `sys_user` CRUD is suppressed // (managedBy better-auth), and until now the only add-a-teammate path was diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts index 66a2b44a464..c9dd78849f0 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts @@ -220,6 +220,11 @@ describe('auth route ledger hygiene', () => { // ruling of 2026-08-23). const own = AUTH_ROUTE_LEDGER.filter((e) => e.source === 'objectstack').map((e) => e.route).sort(); expect(own).toEqual([ + // [#30398] On this pin's own terms: `auth-plugin.ts` mounts it itself (a + // `rawApp.get` on the `${basePath}/admin/client-address` template, ahead + // of the catch-all), and the `live.has(route)` loop below holds it to the + // same proof — better-auth serves no address read. + 'GET /api/v1/auth/admin/client-address', 'GET /api/v1/auth/bootstrap-status', 'GET /api/v1/auth/config', // On this pin's own terms: `auth-plugin.ts` mounts it itself (a diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index aa69f8f2f27..5cdd2ad576a 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -231,6 +231,10 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ // `requires` follows the add-member precedent: it names the better-auth // plugin the route's WORK needs, not whether the mount is conditional — // every one of these is mounted unconditionally on the raw app. + // + // #30398 — the tracker id stays HERE and never in the `note` string, per the + // #16678 note below. + { route: 'GET /api/v1/auth/admin/client-address', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the console\'s Authentication settings page reads it beside the Allowed IP ranges field. Answers { clientAddress, trustProxy }: the caller\'s address exactly as the allowed_ip_ranges save check and the sign-in gate judge it (the save check\'s own call to AuthManager.resolveClientAddress, server.trustProxy deciding whether forwarded headers count), null when the server cannot establish one; Cache-Control: no-store; closed, empty query set; platform-admin gated (ADR-0068), ADR-0069 D5' }, { route: 'POST /api/v1/auth/admin/import-users', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — objectui app-shell\'s identity-import wizard (views/identityImport.ts) posts it directly from the Users list; platform-admin gated (ADR-0068). A dedicated identity-import door rather than the generic sys_user import, because a direct ObjectQL write would bypass better-auth\'s password hashing and mint no sys_account credential' }, { route: 'POST /api/v1/auth/admin/list-platform-admins', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_user list_platform_admins action posts it directly from the Users list; answers who holds platform-admin standing (the last-admin guard\'s enumerator, each id confirmed by hasPlatformAdminStanding), served only to a caller who holds that standing; reads no body. ADR-0095 D3' }, { route: 'POST /api/v1/auth/admin/oauth2/toggle-disabled', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_oauth_application disable/enable actions post it directly; ObjectStack mount closing a vendor gap (better-auth\'s /admin/oauth2/update-client strips `disabled` from its body schema), platform-admin gated (ADR-0068)' }, diff --git a/packages/plugins/plugin-auth/src/client-address-read.ts b/packages/plugins/plugin-auth/src/client-address-read.ts new file mode 100644 index 00000000000..ab4ac2712c2 --- /dev/null +++ b/packages/plugins/plugin-auth/src/client-address-read.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30398] `GET /admin/client-address` — the caller's address as the + * `auth.allowed_ip_ranges` save check and the ADR-0069 D5 sign-in gate judge + * it, served to an administrator BEFORE a save. + * + * Without it the only way to learn that address was to be refused: the save + * check names it in its refusal (`constraint.clientAddress`), after the fact. + * The console's Authentication settings page reads this route to show it beside + * the Allowed IP ranges field. ⛔ A client never works its own address out: + * behind a proxy the address it can see is not the one the server judges, and + * `server.trustProxy` decides which one counts. + * + * ## One reading of the address + * + * `clientAddress` is {@link judgedClientAddress}, the very call the save check + * makes (`allowed-ip-ranges.ts`), on the two facts every auth mount hands the + * rule: the request's headers and the transport's peer address. So the address + * shown is the address a save from the same connection is judged by. It is + * `null` when the server cannot establish an address; while any list is set, + * such a request is refused at the gate, and any non-empty list at the save. + * `trustProxy` says whether forwarded headers counted + * (`AuthManager.trustsForwardedHeaders`). + * + * ## Who may read it + * + * The ADR-0068 D2 platform-admin gate every ObjectStack `/admin/*` mount calls + * (`gateAdmin` in `auth-plugin.ts`, the judge in `platform-admin-gate.ts`), + * asked before this module runs: no session → `401`, a held session (ADR-0069 + * D3) → `403` with the posture's own code, any other caller who is not a + * platform admin → `403`. The setting this informs lives in the `auth` settings + * namespace, written on `manage_platform_settings`, a platform-exclusive + * capability (`admin_full_access` carries it, `organization_admin` withholds + * it). ⛔ Not on the anonymous `GET /config`: an address echo is not served to a + * caller with no session. + * + * The route sits under the auth mount, so the D5 gate runs ahead of it like it + * does for every auth route but the sign-in page's two render reads: a caller + * whose address is outside the list already in force is refused there with + * `403 IP_NOT_ALLOWED`. + * + * ## The answer + * + * `200 { success: true, data: { clientAddress, trustProxy } }` with + * `Cache-Control: no-store`: it describes one connection, and no cache may hand + * it to another. The query set is closed and empty, so any query parameter is + * refused with `400 VALIDATION_ERROR`. No body is read. + */ + +import { unknownQueryParamRefusalMessage } from '@objectstack/rest'; +import { judgedClientAddress, type RequestTransport } from './allowed-ip-ranges.js'; +import type { AuthManager } from './auth-manager.js'; + +/** The route under the auth base path. */ +export const CLIENT_ADDRESS_READ_PATH = '/admin/client-address'; + +/** The route's CLOSED query-parameter set: the handler reads none. */ +export const CLIENT_ADDRESS_READ_QUERY_PARAMS: readonly string[] = Object.freeze([]); + +/** What the read serves. */ +export interface ClientAddressReadData { + /** The address the allow-list judges for this connection, `null` when none can be established. */ + clientAddress: string | null; + /** Whether this deployment declares `server.trustProxy`, so forwarded headers counted. */ + trustProxy: boolean; +} + +/** The request facts the read needs, as the raw mount gathers them. */ +export interface ClientAddressReadRequest { + /** The parsed query, every value as the list of its occurrences. */ + queries: Record; + /** The request's headers and the transport's peer address. */ + transport: RequestTransport; +} + +export interface ClientAddressReadAnswer { + status: 200 | 400; + body: + | { success: true; data: ClientAddressReadData } + | { success: false; error: { code: 'VALIDATION_ERROR'; message: string } }; + headers: Record; +} + +/** Answer the read for a caller the platform-admin gate already admitted. */ +export function runClientAddressRead( + manager: Pick, + request: ClientAddressReadRequest, +): ClientAddressReadAnswer { + const headers = { 'Cache-Control': 'no-store' }; + const unknown = unknownQueryParamRefusalMessage(request.queries, CLIENT_ADDRESS_READ_QUERY_PARAMS); + if (unknown !== undefined) { + return { status: 400, body: { success: false, error: { code: 'VALIDATION_ERROR', message: unknown } }, headers }; + } + return { + status: 200, + body: { + success: true, + data: { + clientAddress: judgedClientAddress(manager, request.transport) ?? null, + trustProxy: manager.trustsForwardedHeaders(), + }, + }, + headers, + }; +} From d7643c58d5b7b0283257a375c7052edee59ef599 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 10:33:59 +0000 Subject: [PATCH 2/3] wip(plugin-auth): changeset for the caller address read Claude-Session: https://claude.ai/code/session_01Utp6CZbTJpa9PNbpp3fdB7 Co-authored-by: Claude --- .changeset/30398-caller-address-read.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/30398-caller-address-read.md diff --git a/.changeset/30398-caller-address-read.md b/.changeset/30398-caller-address-read.md new file mode 100644 index 00000000000..7d493d03326 --- /dev/null +++ b/.changeset/30398-caller-address-read.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-auth': patch +--- + +feat(plugin-auth): a platform admin can read the address the allowed IP ranges save check will judge, before saving, at `GET /api/v1/auth/admin/client-address` (#30398) + +Clause-②: yes (widening) — a new authenticated HTTP read on the auth mount, ledgered `server-only` in the plugin's auth route ledger; no spec schema, SDK method or package export moves. +Metadata-protocol: none + +A save of `auth.allowed_ip_ranges` (ADR-0069 D5) is refused when the list leaves out the address the saving request comes from, and the refusal names that address. Until now that refusal was the only place the address appeared, so an administrator learned which address counts by being refused. The new read answers it beforehand. + +- **The answer.** `200 { success: true, data: { clientAddress, trustProxy } }` with `Cache-Control: no-store`. `clientAddress` is the caller's address exactly as the save check and the sign-in gate judge it, through the same call the save check makes: the forwarded `X-Forwarded-For` / `X-Real-IP` address only when the stack declares `server.trustProxy`, the connection's peer address otherwise, in the same text (an IPv4-mapped peer reads `::ffff:127.0.0.1`, as the refusal names it). It is `null` when the server cannot establish an address; while any list is set, such a connection is refused at sign-in and any non-empty list at the save. `trustProxy` says whether forwarded headers counted. +- **Who may read it.** A platform admin, through the gate every ObjectStack `/admin/*` auth route uses: no session answers `401 UNAUTHENTICATED`, a session held by the authentication policy (an expired password, required two-factor not set up) answers `403` with that posture's code, and any other caller `403 PERMISSION_DENIED`. It is not served to anonymous callers, and `GET /api/v1/auth/config` is unchanged. +- **Closed query set.** The route takes no query parameters; any one is refused with `400 VALIDATION_ERROR`. +- **Behind the allow-list.** Like every auth route except the sign-in page's two render reads, the read is judged by the allow-list in force: a caller already outside it gets `403 IP_NOT_ALLOWED`. + +The save check, the sign-in gate and the address rule are unchanged: the save check now reaches the rule through the helper the read shares, with the same inputs. From 510dffb622fdda2cf1f89843b4ff92f64d80be27 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 12:06:59 +0000 Subject: [PATCH 3/3] test(dogfood): classify the caller address read in both /admin/ route sweeps Claude-Session: https://claude.ai/code/session_01Utp6CZbTJpa9PNbpp3fdB7 Co-authored-by: Claude --- .../test/admin-platform-admin-standing.dogfood.test.ts | 5 +++++ .../test/admin-route-nonadmin-refusal.dogfood.test.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts index d33eb480787..c8cf384bce5 100644 --- a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts @@ -236,6 +236,11 @@ const ADMITTED = [ // gate still honours the legacy role scalar). No body is read, and no // stored state moves, so it has no individual effect pin below. 'POST /api/v1/auth/admin/list-platform-admins', + // #30398 — the address the Allowed IP ranges save check judges, shown on + // the Authentication settings page before a save. A READ: an ObjectStack raw + // mount carrying the ADR-0068 gate first; no body or query parameter is + // read and no stored state moves, so it has no individual effect pin below. + 'GET /api/v1/auth/admin/client-address', 'POST /api/v1/auth/admin/unlock-user', 'POST /api/v1/auth/admin/import-users', 'POST /api/v1/auth/admin/oauth2/toggle-disabled', diff --git a/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts b/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts index 5df8f25dde0..9bd4194e091 100644 --- a/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts @@ -278,6 +278,11 @@ function expectationsFor(targetUserId: string): Record body: {}, note: 'a read: the gate runs first, then the handler confirms the caller\'s own standing; the admin gets 200 with the roster', }, + // ── #30398 — the address the Allowed IP ranges save check judges (a read) ── + 'GET /api/v1/auth/admin/client-address': { + bucket: 'objectstack-gate', + note: 'a read: the gate runs first and the handler reads no body and no query parameter; the admin gets 200 { clientAddress, trustProxy }', + }, // ── #9653: the /admin/sso/* bridges, gated ahead of their delegation ──── // // The ADR-0068 gate runs BEFORE the bridge re-dispatches into better-auth,