diff --git a/.changeset/30320-ipv6-cidr-allowed-ranges.md b/.changeset/30320-ipv6-cidr-allowed-ranges.md new file mode 100644 index 00000000000..3df60dd6617 --- /dev/null +++ b/.changeset/30320-ipv6-cidr-allowed-ranges.md @@ -0,0 +1,15 @@ +--- +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth): an IPv6 CIDR in the allowed IP ranges admits the IPv6 clients inside it, at the sign-in gate and at the settings save check + +Clause-②: no +Metadata-protocol: none + +`auth.allowed_ip_ranges` (ADR-0069 D5) promises CIDR ranges, but its matcher read only IPv4 CIDR and exact addresses: an IPv6 entry such as `2001:db8::/32` was compared as text, so it never matched a real client. The sign-in gate then refused every IPv6 client the list named, and the settings save check refused the save for an administrator connected over IPv6 from inside it. `ipMatchesRange` now reads an IPv6 CIDR as the first `n` of 128 bits, and both doors use it, so they keep judging the same address the same way. + +- An exact IPv6 entry matches the address it denotes however it is spelled (`2001:DB8:0:0::1` matches `2001:db8::1`). +- An IPv4-mapped address, dotted (`::ffff:203.0.113.5`) or hex (`::ffff:cb00:7105`), is still judged as the IPv4 address it maps. A zone identifier on an address (`fe80::1%eth0`) is dropped before matching. +- Families never cross: an IPv4 range never admits a pure IPv6 address, and an IPv6 range never admits an IPv4 one. +- An IPv6 prefix must be a whole number from 0 to 128. An entry with any other prefix matches only its base address, as it did before. IPv4 entries are read exactly as before. 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 95dc4b3cc42..56cef32bb1f 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 @@ -229,3 +229,31 @@ describe('#30203 the recovery the refusal names', () => { expect(recovered.manager.isClientIpAllowed('::1')).toBe(true); }); }); + +describe('#30320 an IPv6 range is judged at both doors as the range it names', () => { + const OFFICE_V6 = '2001:db8:1200::/40'; + + it('the sign-in gate admits an IPv6 client inside an allowed IPv6 range and refuses one outside', async () => { + const { svc, manager } = await boot(); + await svc.set('auth', 'allowed_ip_ranges', OFFICE_V6, {}); + await vi.waitFor(() => expect(manager.isClientIpAllowed('::1')).toBe(false)); + expect(manager.isClientIpAllowed('2001:db8:1234::7')).toBe(true); + expect(manager.isClientIpAllowed('2001:db8:1300::7')).toBe(false); + expect(manager.isClientIpAllowed('203.0.113.7')).toBe(false); + }); + + it('a save from an IPv6 address inside the range is stored, and one from outside is refused', async () => { + const { svc, manager, save } = await boot(); + const outside = await save({ allowed_ip_ranges: OFFICE_V6 }, { remoteAddress: '2001:db8:1300::7' }); + expect(outside.status).toBe(400); + expect(outside.body.error.code).toBe('SETTINGS_VALIDATION'); + expect(outside.body.error.details.fields[0].constraint).toEqual({ clientAddress: '2001:db8:1300::7' }); + expect(await stored(svc)).toBeNull(); + + const inside = await save({ allowed_ip_ranges: OFFICE_V6 }, { remoteAddress: '2001:db8:1234::7' }); + expect(inside.status).toBe(200); + expect(await stored(svc)).toBe(OFFICE_V6); + await vi.waitFor(() => expect(manager.isClientIpAllowed('::1')).toBe(false)); + expect(manager.isClientIpAllowed('2001:db8:1234::7')).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index bf3a6b03f97..486af16e96a 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -5082,6 +5082,58 @@ describe('AuthManager', () => { expect(ipMatchesRange('2001:db8::1', '2001:db8::1')).toBe(true); }); + it('ipMatchesRange reads an IPv6 CIDR as the 128-bit prefix it names', () => { + expect(ipMatchesRange('2001:db8::5', '2001:db8::/32')).toBe(true); + expect(ipMatchesRange('2001:db8:ffff:ffff:ffff:ffff:ffff:ffff', '2001:db8::/32')).toBe(true); + expect(ipMatchesRange('2001:db9::5', '2001:db8::/32')).toBe(false); + expect(ipMatchesRange('2001:0DB8:0:0::5', '2001:db8::/32')).toBe(true); + expect(ipMatchesRange('2001:db8:1234:5600::1', '2001:db8:1234:5600::/56')).toBe(true); + expect(ipMatchesRange('2001:db8:1234:5700::1', '2001:db8:1234:5600::/56')).toBe(false); + 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::5', '::/0')).toBe(true); + expect(ipMatchesRange('::1', '::1/128')).toBe(true); + expect(ipMatchesRange('::2', '::1/128')).toBe(false); + // A zone identifier names an interface, not an address: the address is judged. + expect(ipMatchesRange('fe80::1%eth0', 'fe80::/10')).toBe(true); + }); + + it('ipMatchesRange compares an exact IPv6 entry by the address it denotes, not by its spelling', () => { + expect(ipMatchesRange('2001:db8::1', '2001:DB8:0:0:0:0:0:1')).toBe(true); + expect(ipMatchesRange('::1', '0:0:0:0:0:0:0:1')).toBe(true); + expect(ipMatchesRange('2001:db8::2', '2001:db8::1')).toBe(false); + }); + + it('ipMatchesRange never matches across address families', () => { + // An IPv4 range never admits a pure IPv6 address, an IPv6 range never an IPv4 one. + expect(ipMatchesRange('::1', '0.0.0.0/0')).toBe(false); + expect(ipMatchesRange('2001:db8::5', '0.0.0.0/0')).toBe(false); + expect(ipMatchesRange('203.0.113.5', '::/0')).toBe(false); + expect(ipMatchesRange('::ffff:203.0.113.5', '::/0')).toBe(false); + // The IPv4-mapped form, dotted or hex, is still the IPv4 address it maps. + expect(ipMatchesRange('::ffff:cb00:7105', '203.0.113.0/24')).toBe(true); + expect(ipMatchesRange('203.0.113.5', '::ffff:203.0.113.0/24')).toBe(true); + 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); + }); + + it('isClientIpAllowed admits an IPv6 client inside an allowed IPv6 range', () => { + const m = mgr({ allowedIpRanges: ['2001:db8::/32', '::1'] }); + expect(m.isClientIpAllowed('2001:db8:abcd::1')).toBe(true); + expect(m.isClientIpAllowed('::1')).toBe(true); + expect(m.isClientIpAllowed('2001:db9::1')).toBe(false); + expect(m.isClientIpAllowed('203.0.113.5')).toBe(false); + }); + it('resolveClientAddress believes forwarded headers only under trustProxy', () => { const headers = { 'x-forwarded-for': '203.0.113.9, 10.0.0.2' }; expect(mgr({}).resolveClientAddress({ headers, remoteAddress: '10.0.0.2' })).toBe('10.0.0.2'); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 844e0dfa47a..e83cb87f9db 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1174,40 +1174,76 @@ function ipv4ToInt(ip: string): number | null { } /** - * `::ffff:203.0.113.7` → `203.0.113.7`; anything else is returned trimmed. - * - * A dual-stack listener reports an IPv4 peer in this IPv4-mapped IPv6 form, and - * the ADR-0069 D5 allow-list judges the transport's peer address when no proxy - * is trusted. So both sides of a comparison are read as the IPv4 address they - * denote, or an IPv4 range could never admit an IPv4 client on such a socket. + * An address as the ADR-0069 D5 matcher compares it: an IPv4 address as a + * uint32, any other IPv6 address as its eight 16-bit groups, or `null` when + * the text is neither. An IPv4-mapped IPv6 address (`::ffff:0:0/96`, dotted + * or hex) is the IPv4 address it maps: a dual-stack listener reports an IPv4 + * peer in that form, and the allow-list judges the transport's peer address + * when no proxy is trusted, so an IPv4 range must admit an IPv4 client on such + * a socket. An IPv6 zone identifier (`%eth0`) is dropped: it names the + * interface the address was reached on, not the address. */ -function unmapIpv4(address: string): string { - const trimmed = address.trim(); - const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(trimmed); - return mapped ? mapped[1] : trimmed; +type MatchedAddress = { v4: number } | { v6: number[] }; + +function readMatchedAddress(text: string): MatchedAddress | null { + const trimmed = text.trim(); + const v4 = ipv4ToInt(trimmed); + if (v4 !== null) return { v4 }; + const zoneAt = trimmed.indexOf('%'); + const groups = parseIpv6Literal((zoneAt === -1 ? trimmed : trimmed.slice(0, zoneAt)).toLowerCase()); + if (!groups) return null; + if (groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff) { + return { v4: ((groups[6]! << 16) >>> 0) + groups[7]! }; + } + return { v6: groups }; +} + +/** Do the first `bits` bits of two eight-group IPv6 addresses agree? */ +function ipv6PrefixEqual(a: readonly number[], b: readonly number[], bits: number): boolean { + for (let i = 0; i < 8 && bits > 0; i++, bits -= 16) { + const mask = bits >= 16 ? 0xffff : (0xffff << (16 - bits)) & 0xffff; + if ((a[i]! & mask) !== (b[i]! & mask)) return false; + } + return true; } /** - * ADR-0069 D5 — does `ip` match `range` (IPv4 CIDR `a.b.c.d/n`, or exact IP)? - * An IPv4-mapped IPv6 address on either side compares as its IPv4 address. + * 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. + * + * 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). + * + * 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 = unmapIpv4(range || ''); + const r = (range || '').trim(); if (!r) return false; - const address = unmapIpv4(ip); - if (r.includes('/')) { - const [rawBase, bitsStr] = r.split('/'); - const base = unmapIpv4(rawBase); - const bits = Number(bitsStr); - const ipInt = ipv4ToInt(address); - const baseInt = ipv4ToInt(base); - if (ipInt === null || baseInt === null || !(bits >= 0 && bits <= 32)) { - return address === base; // non-IPv4-CIDR → exact-match fallback - } + 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 (!('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 ((ipInt & mask) >>> 0) === ((baseInt & mask) >>> 0); + return ((address.v4 & mask) >>> 0) === ((base.v4 & mask) >>> 0); } - return address === r; + 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); } /** @@ -5893,7 +5929,7 @@ export class AuthManager { * only for an address inside one of them, and FALSE for an undetermined * address (`undefined` / empty): an allow-list is a gate, and a gate that * opens for "unknown" is open to anyone who can make themselves unknown. - * Supports IPv4 CIDR + exact IPv4/IPv6 (IPv4-mapped IPv6 compares as IPv4). + * Supports IPv4 and IPv6 CIDR + exact IPv4/IPv6 ({@link ipMatchesRange}). * * `ip` must be the address the server established * ({@link resolveClientAddress}), never a header read by the caller. diff --git a/packages/plugins/plugin-auth/src/client-address-canonical.ts b/packages/plugins/plugin-auth/src/client-address-canonical.ts index 7d9f7802f51..30cc34fca6d 100644 --- a/packages/plugins/plugin-auth/src/client-address-canonical.ts +++ b/packages/plugins/plugin-auth/src/client-address-canonical.ts @@ -31,9 +31,9 @@ * * That module is a published entry the runtime's inbound rate limiter loads, * and it is kept free of imports; this one needs `node:net`. The trust rule - * also feeds the ADR-0069 D5 allow-list, which compares exact IPv6 entries as - * text, so rewriting the rule's own answer would change which configured - * entries match. This module changes only what is stored. + * also feeds the ADR-0069 D5 allow-list (`ipMatchesRange`), which reads each + * entry and the address as the address they denote, whatever their spelling. + * This module changes only what is stored. * * ## The form *