Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/30320-ipv6-cidr-allowed-ranges.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
52 changes: 52 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
88 changes: 62 additions & 26 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions packages/plugins/plugin-auth/src/client-address-canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Loading