From da71a1fb9ab88b8698eaf9cc150d35f73fecfa2a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 21:28:06 +0000 Subject: [PATCH 1/8] test(plugin-auth): pin the allowed IP range entry rule at the gate and at every save door An allowed IP range entry is an address, or an address, a slash and a prefix length of 1-2 decimal digits from 0 to 32 (IPv4) or 1-3 from 0 to 128 (IPv6). These pins state that any other entry matches no address and is refused at each door that saves the list. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- .../src/allowed-ip-ranges.pin.test.ts | 90 ++++++++++++++++++- .../plugin-auth/src/auth-manager.test.ts | 89 ++++++++++++++++-- 2 files changed, 169 insertions(+), 10 deletions(-) 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 39df0a9825b..03e854d27c3 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 @@ -30,7 +30,7 @@ * `vitest.config.ts`), so the routes and the service are the checkout's. */ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'; import { Hono } from 'hono'; import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; import { @@ -69,6 +69,7 @@ const PLATFORM_ADMIN_SESSION = { user: { id: 'usr_admin', email: 'admin@example. interface Rig { svc: SettingsService; manager: AuthManager; + logger: { info: Mock; error: Mock; warn: Mock; debug: Mock }; 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 }): @@ -149,7 +150,7 @@ async function boot( getApi.mockRestore(); } }; - return { svc, manager, save, read }; + return { svc, manager, logger: ctx.logger, save, read }; } const stored = async (svc: SettingsService) => (await svc.get('auth', 'allowed_ip_ranges', {})).value ?? null; @@ -385,3 +386,88 @@ describe('#30398 the address read serves the address the save check judges', () expect(asked).not.toHaveBeenCalled(); }); }); + +describe('an allowed IP range whose prefix is not decimal digits within its family is refused at every save door and never matches at the gate', () => { + // An entry is an address, or an address, `/` and a prefix length of one or + // two decimal digits from 0 to 32 (IPv4) or one to three from 0 to 128 + // (IPv6). Each entry here is neither. The saving address, 10.1.2.3, is one + // the list's other entry matches, so only the entry itself can refuse it. + const UNREADABLE = ['10.0.0.0/', '10.0.0.0/ 24', '10.0.0.0/0x18', '10.0.0.0/33', '10.0.0.0/8/8', '2001:db8::/', '2001:db8::/129', 'office']; + const SAVER = { remoteAddress: '10.1.2.3' }; + + it('the console save refuses a list holding one, names it, and stores nothing', async () => { + const { svc, save } = await boot(); + for (const entry of UNREADABLE) { + for (const list of [entry, `10.1.2.3\n${entry}`]) { + const res = await save({ allowed_ip_ranges: list }, SAVER); + expect(res.status, JSON.stringify(list)).toBe(400); + expect(res.body.error.code).toBe('SETTINGS_VALIDATION'); + const field = res.body.error.details.fields.find((f: any) => f.field === 'allowed_ip_ranges'); + expect(field.code).toBe('invalid_value'); + expect(field.constraint).toEqual({ invalidEntries: [entry] }); + expect(field.message).toContain(entry); + expect(await stored(svc)).toBeNull(); + } + } + }); + + it('the console save names the unreadable entry first, whatever address it is saved from', async () => { + const { save } = await boot(); + for (const transport of [{}, { remoteAddress: '198.51.100.1' }]) { + const res = await save({ allowed_ip_ranges: '203.0.113.0/24, 10.0.0.0/, 2001:db8::/0x20' }, transport); + expect(res.status).toBe(400); + expect(res.body.error.details.fields[0].constraint).toEqual({ invalidEntries: ['10.0.0.0/', '2001:db8::/0x20'] }); + } + }); + + it('an in-process write of a list holding one is refused too', async () => { + const { svc } = await boot(); + const err: any = await svc.set('auth', 'allowed_ip_ranges', '10.0.0.0/', {}).then(() => null, (e) => e); + expect(err?.code).toBe('SETTINGS_VALIDATION'); + expect(err.fields).toEqual([ + expect.objectContaining({ field: 'allowed_ip_ranges', code: 'invalid_value', constraint: { invalidEntries: ['10.0.0.0/'] } }), + ]); + expect(await stored(svc)).toBeNull(); + }); + + it("the deployment's own declared allowedIpRanges holding one refuses the boot, naming it", async () => { + await expect(boot({ plugin: { allowedIpRanges: ['10.0.0.0/8', '10.0.0.0/'] } })).rejects.toThrow( + /allowedIpRanges.*"10\.0\.0\.0\/"/, + ); + }); + + it('a list stored before the save check admits no client through the unreadable entry, and the boot names it', async () => { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(authManifest); + // Written before the plugin registers its save check: the shape of a list stored by an earlier release. + await svc.set('auth', 'allowed_ip_ranges', '10.0.0.0/\n192.0.2.7', {}); + const { manager, logger } = await boot({ svc }); + expect(manager.isClientIpAllowed('10.1.2.3')).toBe(false); + expect(manager.isClientIpAllowed('8.8.8.8')).toBe(false); + expect(manager.isClientIpAllowed('192.0.2.7')).toBe(true); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('"10.0.0.0/"')); + }); + + it('OS_AUTH_ALLOWED_IP_RANGES holding one admits no client through it, and the boot names it', async () => { + const { manager, logger } = await boot({ env: { OS_AUTH_ALLOWED_IP_RANGES: '10.0.0.0/, 192.0.2.7' } }); + expect(manager.isClientIpAllowed('10.1.2.3')).toBe(false); + expect(manager.isClientIpAllowed('192.0.2.7')).toBe(true); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('"10.0.0.0/"')); + }); + + it('a list of well-formed entries saves and gates as before (preservation)', async () => { + const list = '10.0.0.0/8\n10.0.0.0/08, 192.0.2.7/32\n2001:db8::/32\n::1/128\n203.0.113.5\n2001:db8::1'; + const { svc, manager, logger, save } = await boot(); + const res = await save({ allowed_ip_ranges: list }, SAVER); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(await stored(svc)).toBe(list); + await vi.waitFor(() => expect(manager.isClientIpAllowed('198.51.100.1')).toBe(false)); + expect(manager.isClientIpAllowed('10.200.0.1')).toBe(true); + expect(manager.isClientIpAllowed('192.0.2.7')).toBe(true); + expect(manager.isClientIpAllowed('192.0.2.8')).toBe(false); + expect(manager.isClientIpAllowed('2001:db8:ffff::1')).toBe(true); + expect(manager.isClientIpAllowed('::1')).toBe(true); + expect(manager.isClientIpAllowed('203.0.113.5')).toBe(true); + expect(logger.error).not.toHaveBeenCalledWith(expect.stringContaining('allowed_ip_ranges')); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 9f9c69815f2..cae17c00782 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -5118,14 +5118,87 @@ describe('AuthManager', () => { expect(ipMatchesRange('203.0.114.5', '::ffff:203.0.113.0/24')).toBe(false); }); - it('ipMatchesRange never widens a malformed IPv6 range past its base address', () => { - expect(ipMatchesRange('2001:db8::5', '2001:db8::/129')).toBe(false); - expect(ipMatchesRange('2001:db8::5', '2001:db8::/')).toBe(false); - expect(ipMatchesRange('2001:db8::5', '2001:db8::/3x')).toBe(false); - expect(ipMatchesRange('2001:db8::5', '2001:db8:::/32')).toBe(false); - expect(ipMatchesRange('2001:db8::5', 'zz01:db8::/32')).toBe(false); - // A prefix the family does not read leaves the base address alone, as before. - expect(ipMatchesRange('2001:db8::', '2001:db8::/129')).toBe(true); + // An entry is an address, or an address, `/` and a prefix length written in + // decimal digits: one or two of them, 0 to 32, for IPv4 (an IPv4-mapped base + // included); one to three, 0 to 128, for IPv6. Any other entry names no + // range, so it matches no address, its own base address included. + const UNREADABLE_IPV4_ENTRIES = [ + '10.0.0.0/', '10.0.0.0/ 8', '10.0.0.0 /8', '10.0.0.0/0x8', '10.0.0.0/+8', '10.0.0.0/-0', + '10.0.0.0/8.0', '10.0.0.0/1e1', '10.0.0.0/008', '10.0.0.0/33', '10.0.0.0/8/8', '/8', + ]; + const UNREADABLE_IPV6_ENTRIES = [ + '2001:db8::/', '2001:db8::/ 32', '2001:db8::/0x20', '2001:db8::/+32', '2001:db8::/0032', + '2001:db8::/129', '2001:db8::/32/32', '2001:db8::/3x', '2001:db8:::/32', 'zz01:db8::/32', + ]; + + it('ipMatchesRange matches no address through an IPv4 entry whose prefix is not 1-2 decimal digits from 0 to 32', () => { + for (const entry of UNREADABLE_IPV4_ENTRIES) { + for (const client of ['10.0.0.0', '10.1.2.3', '8.8.8.8', '::ffff:10.1.2.3']) { + expect(ipMatchesRange(client, entry), `${client} against ${JSON.stringify(entry)}`).toBe(false); + } + } + // An IPv4-mapped base is an IPv4 address, so it takes an IPv4 prefix. + expect(ipMatchesRange('203.0.113.0', '::ffff:203.0.113.0/120')).toBe(false); + }); + + it('ipMatchesRange matches no address through an IPv6 entry whose prefix is not 1-3 decimal digits from 0 to 128', () => { + for (const entry of UNREADABLE_IPV6_ENTRIES) { + for (const client of ['2001:db8::', '2001:db8::5', '2001:db9::5']) { + expect(ipMatchesRange(client, entry), `${client} against ${JSON.stringify(entry)}`).toBe(false); + } + } + }); + + it('ipMatchesRange matches no address through an entry that is not an address, even one spelled the same', () => { + for (const entry of ['unknown', 'localhost', 'office-network', 'unknown/24']) { + expect(ipMatchesRange(entry.split('/')[0]!, entry), JSON.stringify(entry)).toBe(false); + } + }); + + it('ipMatchesRange reads every well-formed entry as it did: an address, or an address and a decimal prefix within its family', () => { + expect(ipMatchesRange('10.200.0.1', '10.0.0.0/8')).toBe(true); + expect(ipMatchesRange('11.0.0.1', '10.0.0.0/8')).toBe(false); + expect(ipMatchesRange('10.200.0.1', '10.0.0.0/08')).toBe(true); + expect(ipMatchesRange('192.0.2.7', '192.0.2.7/32')).toBe(true); + expect(ipMatchesRange('192.0.2.8', '192.0.2.7/32')).toBe(false); + expect(ipMatchesRange('192.0.2.7', '192.0.2.7')).toBe(true); + expect(ipMatchesRange('192.0.2.8', '192.0.2.7')).toBe(false); + expect(ipMatchesRange('198.51.100.1', '0.0.0.0/0')).toBe(true); + // Bits past the prefix are not judged, and the entry's own surrounding blanks are not part of it. + expect(ipMatchesRange('203.0.113.200', '203.0.113.5/24')).toBe(true); + expect(ipMatchesRange('203.0.113.5', ' 203.0.113.0/24 ')).toBe(true); + expect(ipMatchesRange('2001:db8::1', '2001:db8::1/128')).toBe(true); + expect(ipMatchesRange('2001:db8::2', '2001:db8::1/128')).toBe(false); + expect(ipMatchesRange('2001:db8::1', '2001:db8::1')).toBe(true); + expect(ipMatchesRange('2001:db8:ffff::1', '2001:db8::/32')).toBe(true); + expect(ipMatchesRange('2001:db8::5', '2001:db8::/032')).toBe(true); + expect(ipMatchesRange('2001:db9::5', '::/0')).toBe(true); + expect(ipMatchesRange('203.0.113.5', '::ffff:203.0.113.0/24')).toBe(true); + expect(ipMatchesRange('fe80::1%eth0', 'fe80::/10')).toBe(true); + }); + + it('isClientIpAllowed admits no client through an entry that names no range, and still through the readable ones', () => { + const m = mgr({}); + m.applyConfigPatch({ allowedIpRanges: ['10.0.0.0/', '2001:db8::/129', '192.0.2.7'] }); + expect(m.isClientIpAllowed('10.1.2.3')).toBe(false); + expect(m.isClientIpAllowed('8.8.8.8')).toBe(false); + expect(m.isClientIpAllowed('2001:db8::')).toBe(false); + expect(m.isClientIpAllowed('192.0.2.7')).toBe(true); + }); + + it('refuses at construction a declared allowedIpRanges entry that names no range, naming each one', () => { + const declare = (allowedIpRanges: unknown[]) => () => + new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', allowedIpRanges: allowedIpRanges as string[] }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(declare(['10.0.0.0/8', '10.0.0.0/', '2001:db8::/129'])).toThrow( + /allowedIpRanges.*"10\.0\.0\.0\/".*"2001:db8::\/129"/, + ); + expect(declare(['10.0.0.0/8', 42])).toThrow(/allowedIpRanges.*42/); + expect(declare(['10.0.0.0/8', '2001:db8::/32', '192.0.2.7', '::1/128'])).not.toThrow(); + } finally { + warn.mockRestore(); + } }); it('isClientIpAllowed admits an IPv6 client inside an allowed IPv6 range', () => { From 461f996336d92d1055dac4176573d2515e2228ed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 21:33:01 +0000 Subject: [PATCH 2/8] fix(plugin-auth)!: an allowed IP range that names no range is refused at save and never matches at the gate One reading of an allowed IP range entry now serves the sign-in gate, the settings save check, the settings binding and the deployment's declared list (`parseIpRange`): an address, or an address, "/" and a prefix length of 1-2 decimal digits from 0 to 32 (IPv4) or 1-3 from 0 to 128 (IPv6). Any other entry names no range. - The gate: such an entry matches no address, its own base address included. - The settings save (console and in-process writes): a list holding one is refused with SETTINGS_VALIDATION, invalid_value, the entries in constraint.invalidEntries, before the saving address is judged. - The declared allowedIpRanges (plugin options, auth:configure): refused at construction, naming each entry. - A stored or env-pinned list holding one is applied (the entry matches nothing) and the binding names it once at error. Every well-formed entry keeps its meaning. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- .../plugin-auth/src/allowed-ip-ranges.ts | 62 +++++++-- .../plugins/plugin-auth/src/auth-manager.ts | 131 ++++++++++++++---- .../plugins/plugin-auth/src/auth-plugin.ts | 39 +++++- 3 files changed, 192 insertions(+), 40 deletions(-) diff --git a/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts b/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts index 579bf04d4da..b52f7047427 100644 --- a/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts +++ b/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts @@ -7,10 +7,13 @@ * - {@link parseAllowedIpRanges} is THE reading of the setting's textarea, for * the settings binding (`bindAuthSettings`) and for the save predicate below, * so the list a save is judged by is the list the gate then enforces. - * - {@link allowedIpRangesWritePredicate} refuses a save, through the settings - * 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 allowedIpRangesWritePredicate} refuses a save of a list holding an + * entry that names no range (`parseIpRange` in `auth-manager.ts`, the + * gate's own reading: such an entry matches no address there). It also + * refuses a save, through the settings 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`, @@ -25,9 +28,11 @@ * is refused at the gate whenever a list is set, so a non-empty list is refused * at the save for it too. * - * Only a write that arrives over HTTP is judged. An in-process write (boot, - * seed, a migration) carries no request, so there is no caller connection to - * lock out, and it is let through. + * The entries are judged on every write, an in-process one (boot, seed, a + * migration) included: an entry the gate cannot read admits nobody it was + * written for, whoever writes it. The address is judged only on a write that + * arrives over HTTP. An in-process write carries no request, so there is no + * caller connection to lock out, and its address is not judged. * * The way back when locked out anyway — a list saved from another address, or * an address that changed — is the settings env override every auth key has: @@ -36,7 +41,7 @@ */ import { resolveLocale } from '@objectstack/core'; -import { ipMatchesRange, type AuthManager } from './auth-manager.js'; +import { ipMatchesRange, unreadableIpRangeEntries, 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). */ @@ -105,13 +110,18 @@ export interface AllowedIpRangesWriteInput { locale?: string; } -/** A refusal, in the settings write predicate's shape. */ +/** + * A refusal, in the settings write predicate's shape: the entries that name no + * range (`invalidEntries`, as written), or the judged address the list leaves + * out (`clientAddress`, `null` when the server cannot establish one). + */ export interface AllowedIpRangesWriteRefusal { message: string; - constraint: { clientAddress: string | null }; + constraint: { invalidEntries: string[] } | { clientAddress: string | null }; } interface RefusalCopy { + unreadable(entries: string): string; outside(address: string): string; undetermined: string; recovery: string; @@ -120,6 +130,10 @@ interface RefusalCopy { /** One entry per locale the settings bundle ships. */ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = { en: { + unreadable: (entries) => + `Not an IP address or a CIDR range: ${entries}. Each entry is an address (192.0.2.7, 2001:db8::1), or an ` + + 'address, "/" and a prefix length in decimal digits: 0 to 32 for IPv4, 0 to 128 for IPv6 (203.0.113.0/24, ' + + '2001:db8::/32). An entry the server cannot read matches no address, so the list is not saved.', outside: (address) => `No entry in this list matches ${address}, the address you are connected from, so saving it would refuse your ` + 'own next sign-in. Add your address or its range to the list, or save the list from an address it matches.', @@ -130,6 +144,10 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = { recovery: ` If you are locked out, start the server with ${ALLOWED_IP_RANGES_ENV} set to an empty value.`, }, 'zh-CN': { + unreadable: (entries) => + `以下条目不是 IP 地址或 CIDR 网段:${entries}。每一项应为一个地址(如 192.0.2.7、2001:db8::1),` + + '或地址加 "/" 再加用十进制数字写的前缀长度:IPv4 为 0 到 32,IPv6 为 0 到 128(如 203.0.113.0/24、2001:db8::/32)。' + + '服务器无法识别的条目不会匹配任何地址,因此列表未保存。', outside: (address) => `这份列表中没有任何一项与你当前连接所用的地址 ${address} 匹配,保存后你自己下一次登录就会被拒绝。` + '请把你的地址或所在网段加进列表,或在列表能匹配的地址上保存。', @@ -139,6 +157,10 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = { recovery: `如果已被锁在外面,可将环境变量 ${ALLOWED_IP_RANGES_ENV} 设为空值后重启服务器。`, }, 'ja-JP': { + unreadable: (entries) => + `次の項目は IP アドレスでも CIDR 範囲でもありません:${entries}。各項目はアドレス(例:192.0.2.7、2001:db8::1)、` + + 'またはアドレスに "/" と 10 進数字のプレフィックス長を続けたもの(IPv4 は 0〜32、IPv6 は 0〜128。例:203.0.113.0/24、' + + '2001:db8::/32)にしてください。サーバーが読み取れない項目はどのアドレスにも一致しないため、リストは保存されません。', outside: (address) => `このリストには、現在接続しているアドレス ${address} に一致する項目がないため、保存するとご自身の次回のサインインが拒否されます。` + 'ご自身のアドレスまたはその範囲をリストに追加するか、リストに一致するアドレスから保存してください。', @@ -148,6 +170,11 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = { recovery: `締め出された場合は、環境変数 ${ALLOWED_IP_RANGES_ENV} を空の値にしてサーバーを再起動してください。`, }, 'es-ES': { + unreadable: (entries) => + `No es una dirección IP ni un rango CIDR: ${entries}. Cada entrada es una dirección (192.0.2.7, 2001:db8::1) o ` + + 'una dirección seguida de "/" y una longitud de prefijo en dígitos decimales: de 0 a 32 para IPv4 y de 0 a 128 ' + + 'para IPv6 (203.0.113.0/24, 2001:db8::/32). Una entrada que el servidor no puede leer no coincide con ninguna ' + + 'dirección, así que la lista no se guarda.', outside: (address) => `Ninguna entrada de esta lista coincide con ${address}, la dirección desde la que está conectado, así que al ` + 'guardarla se rechazaría su propio próximo inicio de sesión. Añada su dirección o su rango a la lista, o guarde ' @@ -168,8 +195,10 @@ function copyFor(locale: string | undefined): RefusalCopy { } /** - * `auth.allowed_ip_ranges`'s settings write predicate. `manager` is read per - * write, so the address rule is the one in force when the save happens. + * `auth.allowed_ip_ranges`'s settings write predicate. A list holding an entry + * that names no range is refused first, on every write, naming the entries; + * then a list that leaves out the judged address of an HTTP write. `manager` is + * read per write, so the address rule is the one in force when the save happens. */ export function allowedIpRangesWritePredicate( manager: () => Pick | null | undefined, @@ -177,9 +206,16 @@ export function allowedIpRangesWritePredicate( return (input) => { // The settings write predicate's input (`SettingsWritePredicateInput`), read structurally. const { next, transport, locale } = (input ?? {}) as AllowedIpRangesWriteInput; - if (!transport) return undefined; const ranges = parseAllowedIpRanges(next); if (ranges.length === 0) return undefined; + const unreadable = unreadableIpRangeEntries(ranges); + if (unreadable.length > 0) { + return { + message: copyFor(locale).unreadable(unreadable.map((entry) => JSON.stringify(entry)).join(', ')), + constraint: { invalidEntries: unreadable }, + }; + } + if (!transport) return undefined; const mgr = manager(); if (!mgr) return undefined; const address = judgedClientAddress(mgr, transport); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index f49e0c4a690..fe780f7a3b6 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1210,42 +1210,113 @@ function ipv6PrefixEqual(a: readonly number[], b: readonly number[], bits: numbe } /** - * ADR-0069 D5 — does `ip` match `range`? A range is an IPv4 CIDR - * (`a.b.c.d/n`), an IPv6 CIDR (`2001:db8::/32`: the first `n` of 128 bits), - * or one exact address. Both sides are read as the address they denote - * ({@link readMatchedAddress}), so an IPv4-mapped IPv6 address on either side - * compares as its IPv4 address, and an exact IPv6 entry matches however it is - * spelled. An address is matched only against a range of its own family: an - * IPv4 range never admits a pure IPv6 address, nor an IPv6 range an IPv4 one. + * ADR-0069 D5 — one allow-list entry read as the range it names: its base + * address ({@link readMatchedAddress}) and how many leading bits a client + * address must share with it. + */ +export type IpRange = { v4: number; bits: number } | { v6: number[]; bits: number }; + +/** + * A prefix length, by family width: one or two decimal digits for IPv4, one to + * three for IPv6. ASCII digits only; the value is then held to the width. + */ +const PREFIX_LENGTH_DIGITS: Record<32 | 128, RegExp> = { 32: /^[0-9]{1,2}$/, 128: /^[0-9]{1,3}$/ }; + +/** + * ADR-0069 D5 — THE reading of an allow-list entry, for every door that judges + * or saves the list: the sign-in gate ({@link ipMatchesRange}), the settings + * save check (`allowed-ip-ranges.ts`), the settings binding that applies a + * stored or env-pinned list (`auth-plugin.ts`), and the deployment's own + * declaration ({@link assertAllowedIpRanges}). One reading, so a list is never + * saved under one rule and enforced under another. + * + * An entry is an address, IPv4 or IPv6 (an IPv4-mapped IPv6 address is the + * IPv4 address it maps, and an IPv6 zone identifier is dropped), optionally + * followed by `/` and a prefix length written in decimal digits: one or two, + * from 0 to 32, for an IPv4 base; one to three, from 0 to 128, for an IPv6 + * base. Blanks around the entry are not part of it. Anything else names no + * range and reads `null`: a prefix with any other character in it, one out of + * the family's range, or nothing after the `/`; a second `/`; a blank inside + * the entry; text that is not an address. Bits of the base past the prefix are + * not judged (`203.0.113.5/24` is `203.0.113.0/24`). + */ +export function parseIpRange(entry: unknown): IpRange | null { + if (typeof entry !== 'string') return null; + const text = entry.trim(); + if (!text || /\s/.test(text)) return null; + const slash = text.indexOf('/'); + const base = readMatchedAddress(slash === -1 ? text : text.slice(0, slash)); + if (!base) return null; + const width = 'v4' in base ? 32 : 128; + let bits: number = width; + if (slash !== -1) { + const prefix = text.slice(slash + 1); + if (!PREFIX_LENGTH_DIGITS[width].test(prefix)) return null; + bits = Number.parseInt(prefix, 10); + if (bits > width) return null; + } + return 'v4' in base ? { v4: base.v4, bits } : { v6: base.v6, bits }; +} + +/** The entries of an allow-list that name no range ({@link parseIpRange}), in list order. */ +export function unreadableIpRangeEntries(entries: readonly T[]): T[] { + return entries.filter((entry) => parseIpRange(entry) === null); +} + +/** + * ADR-0069 D5 — refuse a declared allow-list ({@link AuthManagerOptions.allowedIpRanges}) + * that is not a list, or that holds an entry naming no range: loudly, at + * construction, naming every such entry. An entry is never dropped: the + * deployment would boot with a gate that admits nobody the entry was written + * for, and say nothing. + */ +export function assertAllowedIpRanges(configured: unknown): void { + if (configured === undefined || configured === null) return; + if (!Array.isArray(configured)) { + throw new Error( + '[auth] allowedIpRanges must be an array of IP addresses or CIDR ranges, e.g. ["203.0.113.0/24", "2001:db8::/32"].', + ); + } + const unreadable = unreadableIpRangeEntries(configured); + if (unreadable.length === 0) return; + const named = unreadable.map((entry) => JSON.stringify(entry) ?? String(entry)).join(', '); + throw new Error( + `[auth] invalid allowedIpRanges: ${named} ${unreadable.length === 1 ? 'is' : 'are'} not an IP address or a CIDR ` + + 'range. Each entry is an address, or an address, "/" and a prefix length in decimal digits: 0 to 32 for IPv4, ' + + '0 to 128 for IPv6 (e.g. "192.0.2.7", "203.0.113.0/24", "2001:db8::/32").', + ); +} + +/** + * ADR-0069 D5 — does `ip` match `range`? The range is read by + * {@link parseIpRange}: an IPv4 CIDR (`a.b.c.d/n`), an IPv6 CIDR + * (`2001:db8::/32`: the first `n` of 128 bits), or one exact address. The + * address is read as the address it denotes ({@link readMatchedAddress}), so + * an IPv4-mapped IPv6 address on either side compares as its IPv4 address, and + * an exact IPv6 entry matches however it is spelled. An address is matched only + * against a range of its own family: an IPv4 range never admits a pure IPv6 + * address, nor an IPv6 range an IPv4 one. * - * A CIDR whose prefix is not one the family reads (an IPv6 prefix must be a - * whole number from 0 to 128) is read as its base address alone, and any - * other entry that is not an address is compared as text, which an address - * the server established never equals. The IPv4 prefix keeps its earlier - * reading (`Number`, 0 to 32). + * An entry that names no range matches no address, its own base address + * included, so a list holding one admits exactly what its other entries admit. + * An `ip` that is not an address matches no entry. * * One matcher for every door that judges the list: the sign-in gate * ({@link AuthManager.isClientIpAllowed}) and the settings save check * (`allowed-ip-ranges.ts`) must judge the same address the same way. */ export function ipMatchesRange(ip: string, range: string): boolean { - const r = (range || '').trim(); - if (!r) return false; + const parsed = parseIpRange(range); + if (!parsed || typeof ip !== 'string') return false; const address = readMatchedAddress(ip); - const [rawBase, bitsStr] = r.split('/'); - const base = readMatchedAddress(rawBase!); - if (!base || !address) return ip.trim() === rawBase!.trim(); - if ('v4' in base) { + if (!address) return false; + if ('v4' in parsed) { if (!('v4' in address)) return false; - const bits = bitsStr === undefined ? 32 : Number(bitsStr); - if (!(bits >= 0 && bits <= 32)) return address.v4 === base.v4; // not an IPv4 prefix → exact base - const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; - return ((address.v4 & mask) >>> 0) === ((base.v4 & mask) >>> 0); + const mask = parsed.bits === 0 ? 0 : (~0 << (32 - parsed.bits)) >>> 0; + return ((address.v4 & mask) >>> 0) === ((parsed.v4 & mask) >>> 0); } if (!('v6' in address)) return false; - const prefix = bitsStr === undefined ? '128' : bitsStr.trim(); - const bits = /^\d{1,3}$/.test(prefix) && Number(prefix) <= 128 ? Number(prefix) : 128; // not an IPv6 prefix → exact base - return ipv6PrefixEqual(address.v6, base.v6, bits); + return ipv6PrefixEqual(address.v6, parsed.v6, parsed.bits); } /** @@ -1702,6 +1773,16 @@ export class AuthManager { // `applyConfigPatch` re-runs it on the merged result. assertTrustedAppSchemes(config.trustedAppSchemes); + // ADR-0069 D5 — the deployment's own allow-list, same boot-loudly + // rationale: an entry that names no range is refused here, never dropped + // (`assertAllowedIpRanges`). `applyConfigPatch` deliberately does NOT re-run + // it: there a refusal keeps the standing list in force instead of the one + // the settings binding applies, and the standing list can admit addresses + // the stored one does not. A stored or env-pinned entry that names no range + // matches no address at the gate instead (`ipMatchesRange`), and the + // binding names it. + assertAllowedIpRanges(config.allowedIpRanges); + // WebContainer (StackBlitz) compatibility — install a synchronous // AsyncLocalStorage polyfill for better-auth's request-state global // BEFORE better-auth ever instantiates its own. See the helper for the diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 52e2d1a1651..bbf32500eeb 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -42,6 +42,7 @@ import { readMcpServerEnabledEnv, isOAuthEligibleBaseUrl, ipMatchesRange, + unreadableIpRangeEntries, // [#16384] The one place `'/api/v1/auth'` is written — see its docblock in // auth-manager.ts. This file no longer carries an independent copy. DEFAULT_AUTH_BASE_PATH, @@ -130,7 +131,7 @@ import { } from './demo-staff.js'; import { cloudManagedRefusal } from './cloud-managed.js'; import { registerCopyInvitationLinkAction } from './invitation-link-action.js'; -import { allowedIpRangesWritePredicate, parseAllowedIpRanges } from './allowed-ip-ranges.js'; +import { ALLOWED_IP_RANGES_ENV, allowedIpRangesWritePredicate, parseAllowedIpRanges } from './allowed-ip-ranges.js'; import { FORWARDED_CLIENT_ADDRESS_HEADERS } from './client-address-forwarding.js'; import { runAdminBanUser, @@ -553,6 +554,8 @@ export class AuthPlugin implements Plugin { */ private allowListUndeterminedWarned = false; private allowListUntrustedHeaderWarned = false; + /** The applied allow-list whose unreadable entries were last reported, so one list is reported once. */ + private allowListUnreadableReportedFor: string | undefined; /** * [#30203] The allow-list the deployment itself declared (plugin options or * an `auth:configure` handler), taken before any setting is applied: what @@ -2398,8 +2401,10 @@ export class AuthPlugin implements Plugin { // at once, rather than leaving the last stored list in force until a // restart while the console shows the field empty. if (isExplicit('allowed_ip_ranges')) { - patch.allowedIpRanges = parseAllowedIpRanges(values.allowed_ip_ranges); + const ranges = parseAllowedIpRanges(values.allowed_ip_ranges); + patch.allowedIpRanges = ranges; this.allowedIpRangesFromSettings = true; + this.reportUnreadableAllowListEntries(ctx, ranges, sources.allowed_ip_ranges === 'env'); } else if (this.allowedIpRangesFromSettings) { patch.allowedIpRanges = this.declaredAllowedIpRanges ? [...this.declaredAllowedIpRanges] : []; this.allowedIpRangesFromSettings = false; @@ -3123,6 +3128,36 @@ export class AuthPlugin implements Plugin { } } + /** + * ADR-0069 D5 — name the entries of an applied `allowed_ip_ranges` list that + * name no range (`unreadableIpRangeEntries`, the gate's own reading). The + * save check refuses such a list, so this is a list it never judged: one + * stored by an earlier release, or pinned by `OS_AUTH_ALLOWED_IP_RANGES`. + * Each such entry matches no address at the gate, while the settings console + * shows the list as in force, so `error` (AGENTS.md, degradation log levels), + * once per distinct list, with the fix in the same line. + */ + private reportUnreadableAllowListEntries(ctx: PluginContext, ranges: readonly string[], fromEnv: boolean): void { + const unreadable = unreadableIpRangeEntries(ranges); + const key = unreadable.length > 0 ? JSON.stringify(ranges) : undefined; + if (key === this.allowListUnreadableReportedFor) return; + this.allowListUnreadableReportedFor = key; + if (!key) return; + const named = unreadable.map((entry) => JSON.stringify(entry)).join(', '); + ctx.logger.error( + `[auth] allowed_ip_ranges (ADR-0069 D5) holds ${unreadable.length === 1 ? 'an entry' : 'entries'} that ` + + `${unreadable.length === 1 ? 'is' : 'are'} not an IP address or a CIDR range: ${named}. ` + + `${unreadable.length === 1 ? 'It matches' : 'Each matches'} no address, so sign-in is admitted only from ` + + "the list's other entries, if any, while the settings console shows the list as in force. Fix: " + + (fromEnv + ? `correct ${ALLOWED_IP_RANGES_ENV} (read at startup) ` + : 'correct the stored list in the settings console ') + + 'so each entry is an address, or an address, "/" and a prefix length in decimal digits (0 to 32 for IPv4, ' + + '0 to 128 for IPv6), or clear it' + + (fromEnv ? '.' : `; locked out, start the server with ${ALLOWED_IP_RANGES_ENV} set to an empty value.`), + ); + } + /** * 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 From 3a965795cb737bfd0f932106cd81724d06e3dc12 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 21:34:33 +0000 Subject: [PATCH 3/8] docs(plugin-auth): state the allowed IP range entry rule, and add its changeset The authentication guide's allowed_ip_ranges row and its saving rules state that an entry is an address, or an address, "/" and a decimal prefix length within its family, that any other entry is refused on every write and at startup for a declared list, and that one already stored matches no address. The hardening guide's Network row names IPv6 and exact addresses. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- .changeset/allowed-ip-range-entry-rule.md | 21 +++++++++++++++++++++ content/docs/permissions/authentication.mdx | 16 ++++++++++++++-- docs/HARDENING.md | 2 +- 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 .changeset/allowed-ip-range-entry-rule.md diff --git a/.changeset/allowed-ip-range-entry-rule.md b/.changeset/allowed-ip-range-entry-rule.md new file mode 100644 index 00000000000..3af8ec448aa --- /dev/null +++ b/.changeset/allowed-ip-range-entry-rule.md @@ -0,0 +1,21 @@ +--- +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth)!: an allowed IP range that names no range is refused at save and never matches at the gate + +Clause-②: no (narrowing) +Metadata-protocol: none +Skills-docs: updated here — `content/docs/permissions/authentication.mdx` (the `allowed_ip_ranges` row, and a first rule under "Saving, clearing and lockout recovery") and `docs/HARDENING.md` (the Network row); `skills/**` teaches no `allowed_ip_ranges` + + + +**BREAKING** for a deployment whose `auth.allowed_ip_ranges` (ADR-0069 D5), `OS_AUTH_ALLOWED_IP_RANGES` or declared `allowedIpRanges` holds an entry that is not an IP address or a CIDR range. + +An entry is an IPv4 or IPv6 address, or an address, `/` and a prefix length written in decimal digits: one or two digits, from 0 to 32, for an IPv4 base (an IPv4-mapped one included), and one to three digits, from 0 to 128, for an IPv6 base. Every such entry keeps its meaning. Any other entry names no range, and one reading of that rule now serves every door that judges or saves the list: + +- **The sign-in gate.** An entry that names no range matches no address, its own base address included, so a list holding one admits exactly what its other entries admit. `ipMatchesRange` answers `false` for it. +- **The settings save.** A list holding one is refused with `400 SETTINGS_VALIDATION`, `invalid_value` at `allowed_ip_ranges`, the entries in `constraint.invalidEntries`, and a message in the caller's language that names them. The entries are judged before the saving address, on every write: the console's `PUT /api/settings/auth` and writes made inside the server alike. Saving the list already in force again is not judged, as before. +- **The declared list.** An `allowedIpRanges` passed to `AuthPlugin` or `AuthManager`, or set by an `auth:configure` handler, that holds one, or that is not an array, is refused at construction with an error naming each such entry. +- **A list already stored, or pinned by `OS_AUTH_ALLOWED_IP_RANGES`.** It is applied, the entry matches no address, and the settings binding logs one `error` that names the entry and the fix. +- **Who moves.** A deployment with such an entry corrects it to an address, or an address and a prefix length (`203.0.113.0/24`, `2001:db8::/32`), or removes it. Locked out meanwhile, start the server with `OS_AUTH_ALLOWED_IP_RANGES` set to an empty value, correct the stored list, then remove the variable. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 787aee00ff6..28f04e9a5fe 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -619,7 +619,7 @@ often before the account-level lock engages. | Setting | Effect | |---|---| -| `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. A save that leaves out your own address is refused, and clearing the field applies at once: see [Saving, clearing and lockout recovery](#saving-clearing-and-lockout-recovery). | +| `allowed_ip_ranges` | Restrict authenticated access to one or more CIDR ranges or exact addresses, IPv4 or IPv6 (`203.0.113.0/24`, `2001:db8::/32`, `192.0.2.7`). An entry that is neither is refused when you save it. 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. A save that leaves out your own address is refused, and clearing the field applies at once: see [Saving, clearing and lockout recovery](#saving-clearing-and-lockout-recovery). | > **How enforcement works.** Password expiry (an administrator-forced password > change included) and enforced MFA share a single *session-validation gate*. @@ -654,6 +654,18 @@ often before the account-level lock engages. These rules apply to `allowed_ip_ranges`. +- **An entry the server cannot read is refused.** Each entry is an IPv4 or IPv6 + address, or an address followed by `/` and a prefix length written in decimal + digits: one or two digits, from 0 to 32, for IPv4, and one to three digits, from + 0 to 128, for IPv6. Any other entry is refused with `400 SETTINGS_VALIDATION`, + whatever address you save from, and nothing is stored. The field error names the + entries, also listed in `constraint.invalidEntries`. This check runs before the + address check below, on every write, including writes made inside the server. An + `allowedIpRanges` list the deployment declares in its own auth configuration is + held to the same rule and refused at startup. An entry the server cannot read + matches no address at sign-in. If a stored list or `OS_AUTH_ALLOWED_IP_RANGES` + already holds one, the server logs it at startup with the entry named, and only + the list's other entries admit anyone. - **A save that would lock you out is refused.** When you save the field, the server checks the new list against the address your own request comes from. It finds that address by the same rule as the gate (`server.trustProxy` decides whether the @@ -668,7 +680,7 @@ These rules apply to `allowed_ip_ranges`. If the server cannot establish your address at all, any non-empty list is refused, because the gate would refuse you too. Only a change is checked: saving the list already in force again is not refused. Writes made inside the server (boot, seed, - migrations) carry no request and are not checked. + migrations) carry no request, so no address is checked for them. - **Setup shows your address before you save.** Under the field, **Setup → Authentication** shows the address the server judges for your request, read from `GET /api/v1/auth/admin/client-address` (platform administrators only), which diff --git a/docs/HARDENING.md b/docs/HARDENING.md index 71594d5949c..7eaf8101d64 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -241,7 +241,7 @@ production deployment, enable at least: | Anti-abuse | `lockout_threshold: 5`, `lockout_duration_minutes: 15`, `rate_limit_max` / `rate_limit_window_seconds` | | MFA | `mfa_required: true` (or per-org `require_mfa`, which needs two-factor enrollment on), `mfa_grace_period_days: 7`; neither saves while `OS_AUTH_TWO_FACTOR=false` | | Sessions | `session_idle_timeout_minutes: 30–60`, `session_absolute_max_hours: 8–12`, `max_concurrent_sessions_per_user` | -| Network | `allowed_ip_ranges` (IPv4 CIDR) for tenant- or office-scoped access | +| Network | `allowed_ip_ranges` (IPv4 or IPv6 CIDR, or exact addresses) for tenant- or office-scoped access | Password expiry and enforced MFA share a *session-validation gate*: a non-compliant session can authenticate but is blocked from data access (auth + From d28f95ba40c6796413e6b6fc318c73dd73f99a30 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 00:11:11 +0000 Subject: [PATCH 4/8] test(plugin-auth): an allowed_ip_ranges value that is not text is refused at save and admits nobody where stored Pins the rule at both save doors (the console's PUT /api/settings/auth and an in-process SettingsService write: 400 SETTINGS_VALIDATION, invalid_value at allowed_ip_ranges, constraint { type: 'textarea' }, nothing stored), and at the binding (a value of another form stored before the save check admits no client, is logged at error, and is replaced by a save of the list as text). The case that judges an unreadable entry before the saving address now also asserts the envelope's code and the field error's code. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- .../src/allowed-ip-ranges.pin.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) 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 03e854d27c3..38169ed880f 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 @@ -416,6 +416,9 @@ describe('an allowed IP range whose prefix is not decimal digits within its fami for (const transport of [{}, { remoteAddress: '198.51.100.1' }]) { const res = await save({ allowed_ip_ranges: '203.0.113.0/24, 10.0.0.0/, 2001:db8::/0x20' }, transport); expect(res.status).toBe(400); + expect(res.body.error.code).toBe('SETTINGS_VALIDATION'); + expect(res.body.error.details.fields[0].field).toBe('allowed_ip_ranges'); + expect(res.body.error.details.fields[0].code).toBe('invalid_value'); expect(res.body.error.details.fields[0].constraint).toEqual({ invalidEntries: ['10.0.0.0/', '2001:db8::/0x20'] }); } }); @@ -471,3 +474,65 @@ describe('an allowed IP range whose prefix is not decimal digits within its fami expect(logger.error).not.toHaveBeenCalledWith(expect.stringContaining('allowed_ip_ranges')); }); }); + +describe('an allowed_ip_ranges value that is not text is refused at every save door, and where it is stored the gate admits nobody through it', () => { + // The setting is a textarea: its value is text, one entry per line or + // comma-separated. Each value here is not text. The saving address, + // 10.1.2.3, is one each of them spells, so only the value's form can refuse it. + const NOT_TEXT: unknown[] = [['10.1.2.3'], ['10.0.0.0/8', '10.1.2.3'], [], 167838211, true, { ranges: '10.1.2.3' }]; + const SAVER = { remoteAddress: '10.1.2.3' }; + + it('the console save refuses it and stores nothing', async () => { + const { svc, save } = await boot(); + for (const value of NOT_TEXT) { + const res = await save({ allowed_ip_ranges: value }, SAVER); + expect(res.status, JSON.stringify(value)).toBe(400); + expect(res.body.error.code).toBe('SETTINGS_VALIDATION'); + const field = res.body.error.details.fields.find((f: any) => f.field === 'allowed_ip_ranges'); + expect(field.code).toBe('invalid_value'); + expect(field.constraint).toEqual({ type: 'textarea' }); + expect(field.message).toContain('not text'); + expect(await stored(svc)).toBeNull(); + } + }); + + it('it is refused whatever address it is saved from', async () => { + const { save } = await boot(); + for (const transport of [{}, { remoteAddress: '198.51.100.1' }]) { + const res = await save({ allowed_ip_ranges: ['203.0.113.0/24'] }, transport); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('SETTINGS_VALIDATION'); + expect(res.body.error.details.fields[0].code).toBe('invalid_value'); + expect(res.body.error.details.fields[0].constraint).toEqual({ type: 'textarea' }); + } + }); + + it('an in-process write of it is refused too', async () => { + const { svc } = await boot(); + for (const value of NOT_TEXT) { + const err: any = await svc.set('auth', 'allowed_ip_ranges', value, {}).then(() => null, (e) => e); + expect(err?.code, JSON.stringify(value)).toBe('SETTINGS_VALIDATION'); + expect(err.fields).toEqual([ + expect.objectContaining({ field: 'allowed_ip_ranges', code: 'invalid_value', constraint: { type: 'textarea' } }), + ]); + expect(await stored(svc)).toBeNull(); + } + }); + + it('a value stored before the save check admits no client, the boot names it, and saving the list as text replaces it', async () => { + for (const value of [['10.1.2.3'], 167838211, { ranges: '10.1.2.3' }]) { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(authManifest); + // Written before the plugin registers its save check: the shape of a value stored by an earlier release. + await svc.set('auth', 'allowed_ip_ranges', value, {}); + const { manager, logger, save } = await boot({ svc }); + expect(manager.isClientIpAllowed('10.1.2.3'), JSON.stringify(value)).toBe(false); + expect(manager.isClientIpAllowed('8.8.8.8')).toBe(false); + expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/allowed_ip_ranges.*not text/)); + const res = await save({ allowed_ip_ranges: '10.1.2.3' }, SAVER); + expect(res.status, JSON.stringify(res.body)).toBe(200); + await vi.waitFor(() => expect(manager.isClientIpAllowed('10.1.2.3')).toBe(true)); + expect(manager.isClientIpAllowed('8.8.8.8')).toBe(false); + } + }); +}); From 73ba4b055baf8a33fb3af5f9d00fd3edca915f4d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 00:30:55 +0000 Subject: [PATCH 5/8] fix(plugin-auth): an allowed_ip_ranges value that is not text is refused at save and admits nobody where stored The setting is a textarea, so its value is text. readAllowedIpRanges is the one reading of a whole value, for the save predicate and the settings binding: text is read as its entries, no value as no list, and any other value names no list. - The save predicate refuses such a value first, on every write, with invalid_value and constraint { type: 'textarea' } and a message in the caller's language (en, zh-CN, ja-JP, es-ES). - The binding applies a stored one as a list that matches no address, so the gate admits nobody while it is in force, and logs one error per distinct value naming the fix. - AuthManager.applyConfigPatch's docblock states that an entry naming no range is applied, not refused, matches no address, and is logged by the binding, not by the method. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- .../plugin-auth/src/allowed-ip-ranges.ts | 105 ++++++++++++++---- .../plugins/plugin-auth/src/auth-manager.ts | 10 ++ .../plugins/plugin-auth/src/auth-plugin.ts | 58 +++++++--- 3 files changed, 136 insertions(+), 37 deletions(-) diff --git a/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts b/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts index b52f7047427..76a22a205c2 100644 --- a/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts +++ b/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts @@ -4,16 +4,18 @@ * ADR-0069 D5 — the `auth.allowed_ip_ranges` setting outside the gate itself * (#30203). * - * - {@link parseAllowedIpRanges} is THE reading of the setting's textarea, for - * the settings binding (`bindAuthSettings`) and for the save predicate below, - * so the list a save is judged by is the list the gate then enforces. - * - {@link allowedIpRangesWritePredicate} refuses a save of a list holding an - * entry that names no range (`parseIpRange` in `auth-manager.ts`, the - * gate's own reading: such an entry matches no address there). It also - * refuses a save, through the settings 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 readAllowedIpRanges} is THE reading of the setting's value, for the + * settings binding (`bindAuthSettings`) and for the save predicate below, + * so the list a save is judged by is the list the gate then enforces. The + * setting is a textarea: its value is text, and a value that is not text + * names no list. + * - {@link allowedIpRangesWritePredicate} refuses a save of a value that is + * not text, and of a list holding an entry that names no range + * (`parseIpRange` in `auth-manager.ts`, the gate's own reading: such an + * entry matches no address there). It also refuses a save, through the + * settings 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`, @@ -28,9 +30,9 @@ * is refused at the gate whenever a list is set, so a non-empty list is refused * at the save for it too. * - * The entries are judged on every write, an in-process one (boot, seed, a - * migration) included: an entry the gate cannot read admits nobody it was - * written for, whoever writes it. The address is judged only on a write that + * The value and its entries are judged on every write, an in-process one + * (boot, seed, a migration) included: a value or an entry the gate cannot read + * admits nobody it was written for, whoever writes it. The address is judged only on a write that * arrives over HTTP. An in-process write carries no request, so there is no * caller connection to lock out, and its address is not judged. * @@ -48,8 +50,9 @@ import { FORWARDED_CLIENT_ADDRESS_HEADERS } from './client-address-forwarding.js export const ALLOWED_IP_RANGES_ENV = 'OS_AUTH_ALLOWED_IP_RANGES'; /** - * The setting's textarea as a list: entries separated by newlines or commas, - * trimmed, blanks dropped. Anything that is not a string reads as no list. + * The setting's textarea text as a list: entries separated by newlines or + * commas, trimmed, blanks dropped. Anything that is not a string reads as no + * entries; {@link readAllowedIpRanges} is the reading of a whole value. */ export function parseAllowedIpRanges(raw: unknown): string[] { if (typeof raw !== 'string') return []; @@ -59,6 +62,39 @@ export function parseAllowedIpRanges(raw: unknown): string[] { .filter(Boolean); } +/** + * The entry {@link readAllowedIpRanges} hands the gate for a value that is not + * text. It names no range (it is not an address, and it holds blanks), so it + * matches no address, and the gate, which judges any non-empty list, admits + * nobody while that value is in force. + */ +const NOT_TEXT_ENTRY = 'auth.allowed_ip_ranges is not text'; + +/** {@link readAllowedIpRanges}'s answer. */ +export interface AllowedIpRangesReading { + /** The list the gate is handed. */ + ranges: string[]; + /** + * Set when the value is neither text nor absent: what it is instead + * (`list`, `number`, `boolean`, `object`). Such a value names no list, so + * {@link ranges} is then one entry that matches no address. + */ + notText?: string; +} + +/** + * THE reading of an `auth.allowed_ip_ranges` value. Text is read as its + * entries ({@link parseAllowedIpRanges}); no value (`null`, `undefined`) as no + * list. Any other value is not the text the setting declares and names no + * list: the save refuses it, and where it is already stored the gate admits + * nobody through it. + */ +export function readAllowedIpRanges(raw: unknown): AllowedIpRangesReading { + if (raw === null || raw === undefined) return { ranges: [] }; + if (typeof raw === 'string') return { ranges: parseAllowedIpRanges(raw) }; + return { ranges: [NOT_TEXT_ENTRY], notText: Array.isArray(raw) ? 'list' : typeof raw }; +} + /** * 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 @@ -111,16 +147,19 @@ export interface AllowedIpRangesWriteInput { } /** - * A refusal, in the settings write predicate's shape: the entries that name no - * range (`invalidEntries`, as written), or the judged address the list leaves - * out (`clientAddress`, `null` when the server cannot establish one). + * A refusal, in the settings write predicate's shape: a value that is not text + * (`type`, the specifier type the setting declares: `textarea`), the entries + * that name no range (`invalidEntries`, as written), or the judged address the + * list leaves out (`clientAddress`, `null` when the server cannot establish + * one). */ export interface AllowedIpRangesWriteRefusal { message: string; - constraint: { invalidEntries: string[] } | { clientAddress: string | null }; + constraint: { type: 'textarea' } | { invalidEntries: string[] } | { clientAddress: string | null }; } interface RefusalCopy { + notText: string; unreadable(entries: string): string; outside(address: string): string; undetermined: string; @@ -130,6 +169,9 @@ interface RefusalCopy { /** One entry per locale the settings bundle ships. */ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = { en: { + notText: + 'The value is not text. This setting holds its entries as text, one per line or comma-separated ' + + '(203.0.113.0/24, 2001:db8::/32, 192.0.2.7). A value that is not text names no list, so it is not saved.', unreadable: (entries) => `Not an IP address or a CIDR range: ${entries}. Each entry is an address (192.0.2.7, 2001:db8::1), or an ` + 'address, "/" and a prefix length in decimal digits: 0 to 32 for IPv4, 0 to 128 for IPv6 (203.0.113.0/24, ' @@ -144,6 +186,9 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = { recovery: ` If you are locked out, start the server with ${ALLOWED_IP_RANGES_ENV} set to an empty value.`, }, 'zh-CN': { + notText: + '取值不是文本。此设置以文本保存条目,每行一项或用逗号分隔(如 203.0.113.0/24、2001:db8::/32、192.0.2.7)。' + + '不是文本的取值不构成任何列表,因此未保存。', unreadable: (entries) => `以下条目不是 IP 地址或 CIDR 网段:${entries}。每一项应为一个地址(如 192.0.2.7、2001:db8::1),` + '或地址加 "/" 再加用十进制数字写的前缀长度:IPv4 为 0 到 32,IPv6 为 0 到 128(如 203.0.113.0/24、2001:db8::/32)。' @@ -157,6 +202,9 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = { recovery: `如果已被锁在外面,可将环境变量 ${ALLOWED_IP_RANGES_ENV} 设为空值后重启服务器。`, }, 'ja-JP': { + notText: + '値がテキストではありません。この設定は項目をテキストで保持します。1 行に 1 項目、またはカンマ区切りで記述してください' + + '(例:203.0.113.0/24、2001:db8::/32、192.0.2.7)。テキストでない値はリストを表さないため、保存されません。', unreadable: (entries) => `次の項目は IP アドレスでも CIDR 範囲でもありません:${entries}。各項目はアドレス(例:192.0.2.7、2001:db8::1)、` + 'またはアドレスに "/" と 10 進数字のプレフィックス長を続けたもの(IPv4 は 0〜32、IPv6 は 0〜128。例:203.0.113.0/24、' @@ -170,6 +218,10 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = { recovery: `締め出された場合は、環境変数 ${ALLOWED_IP_RANGES_ENV} を空の値にしてサーバーを再起動してください。`, }, 'es-ES': { + notText: + 'El valor no es texto. Este ajuste guarda sus entradas como texto, una por línea o separadas por comas ' + + '(203.0.113.0/24, 2001:db8::/32, 192.0.2.7). Un valor que no es texto no forma ninguna lista, así que no se ' + + 'guarda.', unreadable: (entries) => `No es una dirección IP ni un rango CIDR: ${entries}. Cada entrada es una dirección (192.0.2.7, 2001:db8::1) o ` + 'una dirección seguida de "/" y una longitud de prefijo en dígitos decimales: de 0 a 32 para IPv4 y de 0 a 128 ' @@ -195,10 +247,11 @@ function copyFor(locale: string | undefined): RefusalCopy { } /** - * `auth.allowed_ip_ranges`'s settings write predicate. A list holding an entry - * that names no range is refused first, on every write, naming the entries; - * then a list that leaves out the judged address of an HTTP write. `manager` is - * read per write, so the address rule is the one in force when the save happens. + * `auth.allowed_ip_ranges`'s settings write predicate. A value that is not text + * is refused first, then a list holding an entry that names no range, naming + * the entries, both on every write; then a list that leaves out the judged + * address of an HTTP write. `manager` is read per write, so the address rule is + * the one in force when the save happens. */ export function allowedIpRangesWritePredicate( manager: () => Pick | null | undefined, @@ -206,7 +259,11 @@ export function allowedIpRangesWritePredicate( return (input) => { // The settings write predicate's input (`SettingsWritePredicateInput`), read structurally. const { next, transport, locale } = (input ?? {}) as AllowedIpRangesWriteInput; - const ranges = parseAllowedIpRanges(next); + const reading = readAllowedIpRanges(next); + if (reading.notText) { + return { message: copyFor(locale).notText, constraint: { type: 'textarea' } }; + } + const { ranges } = reading; if (ranges.length === 0) return undefined; const unreadable = unreadableIpRangeEntries(ranges); if (unreadable.length > 0) { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index fe780f7a3b6..6752e12e0f9 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -2179,6 +2179,16 @@ export class AuthManager { * better-auth itself is created lazily, so changing config before the first * request is enough. If an instance already exists, reset it so the next * request rebuilds with the new policy. + * + * An `allowedIpRanges` entry that names no range ({@link parseIpRange}) is + * applied as given, not refused as the constructor refuses it: a refusal here + * would keep the standing list in force, and that list can admit addresses + * the new one does not. The entry matches no address at the gate + * ({@link isClientIpAllowed}), so the list admits only what its other + * entries admit, and none at all when it has none. This method logs nothing + * for it: the settings binding, which applies the stored list through here, + * names such an entry at `error`, and a host that patches the list itself is + * the one that has to say so. */ applyConfigPatch( patch: Partial, diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index bc07ce9d51d..6127f5a4a90 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -131,7 +131,12 @@ import { } from './demo-staff.js'; import { cloudManagedRefusal } from './cloud-managed.js'; import { registerCopyInvitationLinkAction } from './invitation-link-action.js'; -import { ALLOWED_IP_RANGES_ENV, allowedIpRangesWritePredicate, parseAllowedIpRanges } from './allowed-ip-ranges.js'; +import { + ALLOWED_IP_RANGES_ENV, + allowedIpRangesWritePredicate, + readAllowedIpRanges, + type AllowedIpRangesReading, +} from './allowed-ip-ranges.js'; import { AUTH_SETTING_RESTORE, captureDeclaredAuthOptions, @@ -561,7 +566,7 @@ export class AuthPlugin implements Plugin { */ private allowListUndeterminedWarned = false; private allowListUntrustedHeaderWarned = false; - /** The applied allow-list whose unreadable entries were last reported, so one list is reported once. */ + /** The applied allow-list value whose unreadable value or entries were last reported, so one is reported once. */ private allowListUnreadableReportedFor: string | undefined; /** * [#30203, #30312] What the deployment itself declared (plugin options and @@ -2422,9 +2427,9 @@ export class AuthPlugin implements Plugin { // [#30203] A cleared value hands the gate back to the deployment's own // declaration at once (the restore arms below). if (isExplicit('allowed_ip_ranges')) { - const ranges = parseAllowedIpRanges(values.allowed_ip_ranges); - patch.allowedIpRanges = ranges; - this.reportUnreadableAllowListEntries(ctx, ranges, sources.allowed_ip_ranges === 'env'); + const reading = readAllowedIpRanges(values.allowed_ip_ranges); + patch.allowedIpRanges = reading.ranges; + this.reportUnreadableAllowList(ctx, reading, values.allowed_ip_ranges, sources.allowed_ip_ranges === 'env'); } // Anti-abuse (ADR-0069 D2) — account lockout (custom, per-identity) @@ -3242,15 +3247,42 @@ export class AuthPlugin implements Plugin { } /** - * ADR-0069 D5 — name the entries of an applied `allowed_ip_ranges` list that - * name no range (`unreadableIpRangeEntries`, the gate's own reading). The - * save check refuses such a list, so this is a list it never judged: one - * stored by an earlier release, or pinned by `OS_AUTH_ALLOWED_IP_RANGES`. - * Each such entry matches no address at the gate, while the settings console - * shows the list as in force, so `error` (AGENTS.md, degradation log levels), - * once per distinct list, with the fix in the same line. + * ADR-0069 D5 — name what the gate cannot read in an applied + * `allowed_ip_ranges` value: a value that is not text (`readAllowedIpRanges`, + * which hands the gate a list that admits nobody for it), or the entries of a + * list that name no range (`unreadableIpRangeEntries`, the gate's own + * reading). The save check refuses both, so this is a value it never judged: + * one stored by an earlier release, or pinned by `OS_AUTH_ALLOWED_IP_RANGES`. + * Such a value or entry matches no address at the gate while the setting + * reads as set, so `error` (AGENTS.md, degradation log levels), once per + * distinct value, with the fix in the same line. */ - private reportUnreadableAllowListEntries(ctx: PluginContext, ranges: readonly string[], fromEnv: boolean): void { + private reportUnreadableAllowList( + ctx: PluginContext, + reading: AllowedIpRangesReading, + raw: unknown, + fromEnv: boolean, + ): void { + if (reading.notText) { + let spelled: string; + try { + spelled = JSON.stringify(raw); + } catch { + spelled = String(raw); + } + const notTextKey = `not text: ${spelled}`; + if (notTextKey === this.allowListUnreadableReportedFor) return; + this.allowListUnreadableReportedFor = notTextKey; + const kind = reading.notText === 'object' ? 'an object' : `a ${reading.notText}`; + ctx.logger.error( + `[auth] allowed_ip_ranges (ADR-0069 D5) holds a value that is not text (${kind}), so it names no list, ` + + 'and sign-in is admitted from no address while it is in force. Fix: save the list in the settings ' + + 'console as text, one entry per line or comma-separated, or clear it; locked out, start the server ' + + `with ${ALLOWED_IP_RANGES_ENV} set to an empty value.`, + ); + return; + } + const { ranges } = reading; const unreadable = unreadableIpRangeEntries(ranges); const key = unreadable.length > 0 ? JSON.stringify(ranges) : undefined; if (key === this.allowListUnreadableReportedFor) return; From 2d57759ab5224fa51febef10487eadea8b736c41 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 00:30:55 +0000 Subject: [PATCH 6/8] docs(plugin-auth): an allowed_ip_ranges value that is not text is refused The changeset and the authentication guide state the rule: the setting holds its entries as text, a value in another form is refused at save, and one already stored admits no address and is logged at startup. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- .changeset/allowed-ip-range-entry-rule.md | 12 ++++++------ content/docs/permissions/authentication.mdx | 7 +++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.changeset/allowed-ip-range-entry-rule.md b/.changeset/allowed-ip-range-entry-rule.md index 3af8ec448aa..93355f7e379 100644 --- a/.changeset/allowed-ip-range-entry-rule.md +++ b/.changeset/allowed-ip-range-entry-rule.md @@ -8,14 +8,14 @@ Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: updated here — `content/docs/permissions/authentication.mdx` (the `allowed_ip_ranges` row, and a first rule under "Saving, clearing and lockout recovery") and `docs/HARDENING.md` (the Network row); `skills/**` teaches no `allowed_ip_ranges` - + -**BREAKING** for a deployment whose `auth.allowed_ip_ranges` (ADR-0069 D5), `OS_AUTH_ALLOWED_IP_RANGES` or declared `allowedIpRanges` holds an entry that is not an IP address or a CIDR range. +**BREAKING** for a deployment whose `auth.allowed_ip_ranges` (ADR-0069 D5), `OS_AUTH_ALLOWED_IP_RANGES` or declared `allowedIpRanges` holds an entry that is not an IP address or a CIDR range, or whose stored `auth.allowed_ip_ranges` value is not text. -An entry is an IPv4 or IPv6 address, or an address, `/` and a prefix length written in decimal digits: one or two digits, from 0 to 32, for an IPv4 base (an IPv4-mapped one included), and one to three digits, from 0 to 128, for an IPv6 base. Every such entry keeps its meaning. Any other entry names no range, and one reading of that rule now serves every door that judges or saves the list: +The setting holds its entries as text, one per line or comma-separated; a value that is not text (a list, a number, an object) names no list. An entry is an IPv4 or IPv6 address, or an address, `/` and a prefix length written in decimal digits: one or two digits, from 0 to 32, for an IPv4 base (an IPv4-mapped one included), and one to three digits, from 0 to 128, for an IPv6 base. Every such entry keeps its meaning. Any other entry names no range, and one reading of that rule now serves every door that judges or saves the list: - **The sign-in gate.** An entry that names no range matches no address, its own base address included, so a list holding one admits exactly what its other entries admit. `ipMatchesRange` answers `false` for it. -- **The settings save.** A list holding one is refused with `400 SETTINGS_VALIDATION`, `invalid_value` at `allowed_ip_ranges`, the entries in `constraint.invalidEntries`, and a message in the caller's language that names them. The entries are judged before the saving address, on every write: the console's `PUT /api/settings/auth` and writes made inside the server alike. Saving the list already in force again is not judged, as before. +- **The settings save.** A value that is not a list of entries written as text is refused with `400 SETTINGS_VALIDATION`, `invalid_value` at `allowed_ip_ranges` and `constraint: { type: 'textarea' }`. A list holding an entry that names no range is refused the same way, the entries in `constraint.invalidEntries`, with a message in the caller's language that names them. Both are judged before the saving address, on every write: the console's `PUT /api/settings/auth` and writes made inside the server alike. Saving the value already in force again is not judged, as before. - **The declared list.** An `allowedIpRanges` passed to `AuthPlugin` or `AuthManager`, or set by an `auth:configure` handler, that holds one, or that is not an array, is refused at construction with an error naming each such entry. -- **A list already stored, or pinned by `OS_AUTH_ALLOWED_IP_RANGES`.** It is applied, the entry matches no address, and the settings binding logs one `error` that names the entry and the fix. -- **Who moves.** A deployment with such an entry corrects it to an address, or an address and a prefix length (`203.0.113.0/24`, `2001:db8::/32`), or removes it. Locked out meanwhile, start the server with `OS_AUTH_ALLOWED_IP_RANGES` set to an empty value, correct the stored list, then remove the variable. +- **A list already stored, or pinned by `OS_AUTH_ALLOWED_IP_RANGES`.** It is applied, the entry matches no address, and the settings binding logs one `error` that names the entry and the fix. A stored value that is not text admits no address while it is in force, and the binding logs one `error` that says so and names the fix. +- **Who moves.** A deployment with such an entry corrects it to an address, or an address and a prefix length (`203.0.113.0/24`, `2001:db8::/32`), or removes it. A deployment whose stored value is not text saves the list again as text, or clears it. Locked out meanwhile, start the server with `OS_AUTH_ALLOWED_IP_RANGES` set to an empty value, correct the stored value, then remove the variable. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 28f04e9a5fe..a0dfab785f9 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -666,6 +666,13 @@ These rules apply to `allowed_ip_ranges`. matches no address at sign-in. If a stored list or `OS_AUTH_ALLOWED_IP_RANGES` already holds one, the server logs it at startup with the entry named, and only the list's other entries admit anyone. +- **A value that is not text is refused.** The field holds its entries as text, one + per line or comma-separated. A value written in another form, such as a JSON list, + a number or an object, names no list: through the API or by a write inside the + server, it is refused the same way, with `constraint: { type: 'textarea' }`, and + nothing is stored. A value of another + form stored before this check admits no address at sign-in, and the server logs + it at startup. Save the list as text, or clear it. - **A save that would lock you out is refused.** When you save the field, the server checks the new list against the address your own request comes from. It finds that address by the same rule as the gate (`server.trustProxy` decides whether the From 4b2c6130c0047d78895c70c8b435314a46287540 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 00:32:42 +0000 Subject: [PATCH 7/8] docs(changeset): name both guide rules the allowed_ip_ranges change adds Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg --- .changeset/allowed-ip-range-entry-rule.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/allowed-ip-range-entry-rule.md b/.changeset/allowed-ip-range-entry-rule.md index 93355f7e379..93abc3302a9 100644 --- a/.changeset/allowed-ip-range-entry-rule.md +++ b/.changeset/allowed-ip-range-entry-rule.md @@ -6,7 +6,7 @@ fix(plugin-auth)!: an allowed IP range that names no range is refused at save an Clause-②: no (narrowing) Metadata-protocol: none -Skills-docs: updated here — `content/docs/permissions/authentication.mdx` (the `allowed_ip_ranges` row, and a first rule under "Saving, clearing and lockout recovery") and `docs/HARDENING.md` (the Network row); `skills/**` teaches no `allowed_ip_ranges` +Skills-docs: updated here — `content/docs/permissions/authentication.mdx` (the `allowed_ip_ranges` row, and the first two rules under "Saving, clearing and lockout recovery") and `docs/HARDENING.md` (the Network row); `skills/**` teaches no `allowed_ip_ranges` From 03655cf8e198cb56d9ca9e3df2f8510f2a6ad2b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 05:13:09 +0000 Subject: [PATCH 8/8] docs(changeset): the allowed_ip_ranges lock-out step clears the stored value, the one write the override lets through Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc --- .changeset/allowed-ip-range-entry-rule.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/allowed-ip-range-entry-rule.md b/.changeset/allowed-ip-range-entry-rule.md index 93abc3302a9..a8f70ade1a6 100644 --- a/.changeset/allowed-ip-range-entry-rule.md +++ b/.changeset/allowed-ip-range-entry-rule.md @@ -18,4 +18,4 @@ The setting holds its entries as text, one per line or comma-separated; a value - **The settings save.** A value that is not a list of entries written as text is refused with `400 SETTINGS_VALIDATION`, `invalid_value` at `allowed_ip_ranges` and `constraint: { type: 'textarea' }`. A list holding an entry that names no range is refused the same way, the entries in `constraint.invalidEntries`, with a message in the caller's language that names them. Both are judged before the saving address, on every write: the console's `PUT /api/settings/auth` and writes made inside the server alike. Saving the value already in force again is not judged, as before. - **The declared list.** An `allowedIpRanges` passed to `AuthPlugin` or `AuthManager`, or set by an `auth:configure` handler, that holds one, or that is not an array, is refused at construction with an error naming each such entry. - **A list already stored, or pinned by `OS_AUTH_ALLOWED_IP_RANGES`.** It is applied, the entry matches no address, and the settings binding logs one `error` that names the entry and the fix. A stored value that is not text admits no address while it is in force, and the binding logs one `error` that says so and names the fix. -- **Who moves.** A deployment with such an entry corrects it to an address, or an address and a prefix length (`203.0.113.0/24`, `2001:db8::/32`), or removes it. A deployment whose stored value is not text saves the list again as text, or clears it. Locked out meanwhile, start the server with `OS_AUTH_ALLOWED_IP_RANGES` set to an empty value, correct the stored value, then remove the variable. +- **Who moves.** A deployment with such an entry corrects it to an address, or an address and a prefix length (`203.0.113.0/24`, `2001:db8::/32`), or removes it. A deployment whose stored value is not text saves the list again as text, or clears it. Locked out meanwhile, start the server with `OS_AUTH_ALLOWED_IP_RANGES` set to an empty value, sign in, clear the stored value (`PUT /api/settings/auth` with `allowed_ip_ranges: null`, the one write the override lets through), remove the variable and restart, then save the corrected list.