diff --git a/.changeset/allowed-ip-range-entry-rule.md b/.changeset/allowed-ip-range-entry-rule.md new file mode 100644 index 00000000000..a8f70ade1a6 --- /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 the first two rules 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, or whose stored `auth.allowed_ip_ranges` value is not text. + +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 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, 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. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index d78b4d0d641..a4ccabc5401 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,25 @@ 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 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 @@ -668,7 +687,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 4c13aa30452..d97ccaee916 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; locked out, restart with `OS_AUTH_ALLOWED_IP_RANGES=` (empty), sign in, clear the stored list (`PUT /api/settings/auth` with `allowed_ip_ranges: null`), then remove the variable | +| Network | `allowed_ip_ranges` (IPv4 or IPv6 CIDR, or exact addresses) for tenant- or office-scoped access; locked out, restart with `OS_AUTH_ALLOWED_IP_RANGES=` (empty), sign in, clear the stored list (`PUT /api/settings/auth` with `allowed_ip_ranges: null`), then remove the variable | Password expiry and enforced MFA share a *session-validation gate*: a non-compliant session can authenticate but is blocked from data access (auth + 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 0f5d30a3916..e934269edc5 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 @@ -34,7 +34,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 { @@ -73,6 +73,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 }): @@ -153,7 +154,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; @@ -390,6 +391,156 @@ describe('#30398 the address read serves the address the save check judges', () }); }); +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.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'] }); + } + }); + + 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')); + }); +}); + +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); + } + }); +}); + describe('#30385 the env recovery clears the stored list, so lifting the override does not lock the caller out again', () => { it('set the override empty, sign in, clear the stored list through the settings door, remove the override: the caller is admitted', async () => { const env: Record = {}; diff --git a/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts b/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts index 4f72605433e..08fe68680ef 100644 --- a/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts +++ b/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts @@ -4,13 +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, 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`, @@ -25,9 +30,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 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. * * 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, @@ -41,15 +48,16 @@ import { resolveLocale } from '@objectstack/core'; import type { SettingsCopyLocale } from './settings-copy-locale.js'; -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). */ 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 +67,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 @@ -110,13 +151,21 @@ export interface AllowedIpRangesWriteInput { locale?: string; } -/** A refusal, in the settings write predicate's shape. */ +/** + * 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: { clientAddress: string | null }; + constraint: { type: 'textarea' } | { invalidEntries: string[] } | { clientAddress: string | null }; } export interface AllowedIpRangesRefusalCopy { + notText: string; + unreadable(entries: string): string; outside(address: string): string; undetermined: string; recovery: string; @@ -129,6 +178,13 @@ export interface AllowedIpRangesRefusalCopy { */ export const ALLOWED_IP_RANGES_REFUSAL_COPY: Record = { 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, ' + + '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.', @@ -142,6 +198,13 @@ export const ALLOWED_IP_RANGES_REFUSAL_COPY: Record + `以下条目不是 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} 匹配,保存后你自己下一次登录就会被拒绝。` + '请把你的地址或所在网段加进列表,或在列表能匹配的地址上保存。', @@ -153,6 +216,13 @@ export const ALLOWED_IP_RANGES_REFUSAL_COPY: Record + `次の項目は 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} に一致する項目がないため、保存するとご自身の次回のサインインが拒否されます。` + 'ご自身のアドレスまたはその範囲をリストに追加するか、リストに一致するアドレスから保存してください。', @@ -165,6 +235,15 @@ export const ALLOWED_IP_RANGES_REFUSAL_COPY: Record + `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 ' @@ -179,6 +258,13 @@ export const ALLOWED_IP_RANGES_REFUSAL_COPY: Record + `以下條目不是 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} 相符,儲存後你自己下一次登入就會被拒絕。` + '請把你的位址或所在網段加進清單,或在清單能相符的位址上儲存。', @@ -188,6 +274,13 @@ export const ALLOWED_IP_RANGES_REFUSAL_COPY: Record + `ليس عنوان IP ولا نطاق CIDR: ${entries}. كل إدخال إما عنوان (192.0.2.7، 2001:db8::1)، أو عنوان يليه "/" ` + + 'ثم طول بادئة بأرقام عشرية: من 0 إلى 32 لـ IPv4، ومن 0 إلى 128 لـ IPv6 (203.0.113.0/24، 2001:db8::/32). ' + + 'الإدخال الذي لا يستطيع الخادم قراءته لا يطابق أي عنوان، لذا لم تُحفظ القائمة.', outside: (address) => `لا يطابق أي إدخال في هذه القائمة العنوان ${address} الذي تتصل منه، لذا فإن حفظها سيرفض تسجيل دخولك التالي ` + 'أنت نفسك. أضف عنوانك أو نطاقه إلى القائمة، أو احفظ القائمة من عنوان تطابقه.', @@ -197,6 +290,15 @@ export const ALLOWED_IP_RANGES_REFUSAL_COPY: Record + `Ni une adresse IP ni une plage CIDR : ${entries}. Chaque entrée est une adresse (192.0.2.7, 2001:db8::1), ou ` + + 'une adresse suivie de "/" et d’une longueur de préfixe en chiffres décimaux : de 0 à 32 pour IPv4, de 0 à 128 ' + + 'pour IPv6 (203.0.113.0/24, 2001:db8::/32). Une entrée que le serveur ne peut pas lire ne correspond à aucune ' + + 'adresse : la liste n’est donc pas enregistrée.', outside: (address) => `Aucune entrée de cette liste ne correspond à ${address}, l’adresse depuis laquelle vous êtes connecté : ` + 'l’enregistrer refuserait votre propre prochaine connexion. Ajoutez votre adresse ou sa plage à la liste, ou ' @@ -208,6 +310,15 @@ export const ALLOWED_IP_RANGES_REFUSAL_COPY: Record + `Não é um endereço IP nem uma faixa CIDR: ${entries}. Cada entrada é um endereço (192.0.2.7, 2001:db8::1) ou ` + + 'um endereço seguido de "/" e de um comprimento de prefixo em dígitos decimais: de 0 a 32 para IPv4 e de 0 a ' + + '128 para IPv6 (203.0.113.0/24, 2001:db8::/32). Uma entrada que o servidor não consegue ler não corresponde a ' + + 'nenhum endereço, então a lista não foi salva.', outside: (address) => `Nenhuma entrada desta lista corresponde a ${address}, o endereço do qual você está conectado, então salvá-la ` + 'recusaria o seu próprio próximo login. Adicione o seu endereço ou a faixa dele à lista, ou salve a lista a ' @@ -219,6 +330,15 @@ export const ALLOWED_IP_RANGES_REFUSAL_COPY: Record + `Не IP-адрес и не диапазон CIDR: ${entries}. Каждая запись — это адрес (192.0.2.7, 2001:db8::1) либо адрес, ` + + 'за которым следуют "/" и длина префикса десятичными цифрами: от 0 до 32 для IPv4, от 0 до 128 для IPv6 ' + + '(203.0.113.0/24, 2001:db8::/32). Запись, которую сервер не может прочитать, не соответствует ни одному ' + + 'адресу, поэтому список не сохранён.', outside: (address) => `Ни одна запись этого списка не соответствует адресу ${address}, с которого вы подключены, поэтому после ` + 'сохранения ваш собственный следующий вход будет отклонён. Добавьте в список свой адрес или его диапазон ' @@ -230,6 +350,15 @@ export const ALLOWED_IP_RANGES_REFUSAL_COPY: Record + `Weder eine IP-Adresse noch ein CIDR-Bereich: ${entries}. Jeder Eintrag ist eine Adresse (192.0.2.7, ` + + '2001:db8::1) oder eine Adresse, gefolgt von "/" und einer Präfixlänge in Dezimalziffern: 0 bis 32 für IPv4, ' + + '0 bis 128 für IPv6 (203.0.113.0/24, 2001:db8::/32). Ein Eintrag, den der Server nicht lesen kann, passt zu ' + + 'keiner Adresse, daher wird die Liste nicht gespeichert.', outside: (address) => `Kein Eintrag dieser Liste passt zu ${address}, der Adresse, von der aus Sie verbunden sind. Nach dem Speichern ` + 'würde daher Ihre eigene nächste Anmeldung abgewiesen. Fügen Sie Ihre Adresse oder deren Bereich der Liste ' @@ -241,6 +370,13 @@ export const ALLOWED_IP_RANGES_REFUSAL_COPY: Record + `다음 항목은 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}와 일치하는 항목이 없어, 저장하면 본인의 다음 로그인이 거부됩니다. ` + '본인의 주소나 그 대역을 목록에 추가하거나, 목록과 일치하는 주소에서 저장하세요.', @@ -260,8 +396,11 @@ export function allowedIpRangesRefusalCopy(locale: string | undefined): AllowedI } /** - * `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 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, @@ -269,9 +408,20 @@ 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); + const reading = readAllowedIpRanges(next); + if (reading.notText) { + return { message: allowedIpRangesRefusalCopy(locale).notText, constraint: { type: 'textarea' } }; + } + const { ranges } = reading; if (ranges.length === 0) return undefined; + const unreadable = unreadableIpRangeEntries(ranges); + if (unreadable.length > 0) { + return { + message: allowedIpRangesRefusalCopy(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.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', () => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index f49e0c4a690..6752e12e0f9 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. * - * 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 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. + * + * 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 @@ -2098,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 b4619a86de3..6127f5a4a90 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,12 @@ 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, + readAllowedIpRanges, + type AllowedIpRangesReading, +} from './allowed-ip-ranges.js'; import { AUTH_SETTING_RESTORE, captureDeclaredAuthOptions, @@ -560,6 +566,8 @@ export class AuthPlugin implements Plugin { */ private allowListUndeterminedWarned = false; private allowListUntrustedHeaderWarned = false; + /** 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 * `auth:configure` handlers) for every option an `auth.*` setting can drive, @@ -2419,7 +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')) { - patch.allowedIpRanges = parseAllowedIpRanges(values.allowed_ip_ranges); + 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) @@ -3236,6 +3246,63 @@ export class AuthPlugin implements Plugin { } } + /** + * 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 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; + 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