From f2ed976282ab34573bb632143705b8236f45cad3 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:15:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20IP=20allow-list=20=E2=80=94=20net?= =?UTF-8?q?work=20gating=20on=20auth=20routes=20(ADR-0069=20D5,=20P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service-settings: new `allowed_ip_ranges` setting (Network group; CIDR/IP list). - plugin-auth: a Hono middleware registered ahead of the better-auth handler rejects auth requests from a client IP outside the ranges with 403 IP_NOT_ALLOWED. Client IP read trust-proxy-aware (x-forwarded-for first hop / cf-connecting-ip / x-real-ip). /config + /bootstrap-status exempt so a blocked client still gets a clean login + error. Fails OPEN when the IP can't be determined (missing proxy header = no-op, not a lockout). IPv4 CIDR + exact match (ipMatchesRange + isClientIpAllowed). Default-off / additive; ADR-0049. Verified live (dogfood, allowed=203.0.113.0/24): sign-in with XFF 8.8.8.8 -> 403 IP_NOT_ALLOWED; XFF 203.0.113.50 -> 200 + token; /config from a disallowed IP -> 200 (login renders); no XFF -> 200 (fail-open, admin not locked out). Unit: plugin-auth 208 + service-settings 129 green; full build incl. strict DTS green. Co-Authored-By: Claude Opus 4.8 --- .changeset/adr-0069-d5-ip-allowlist.md | 15 ++++++ .../plugin-auth/src/auth-manager.test.ts | 41 +++++++++++++++- .../plugins/plugin-auth/src/auth-manager.ts | 49 +++++++++++++++++++ .../plugin-auth/src/auth-plugin.test.ts | 7 +++ .../plugins/plugin-auth/src/auth-plugin.ts | 35 +++++++++++++ .../src/manifests/auth.manifest.ts | 15 ++++++ 6 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 .changeset/adr-0069-d5-ip-allowlist.md diff --git a/.changeset/adr-0069-d5-ip-allowlist.md b/.changeset/adr-0069-d5-ip-allowlist.md new file mode 100644 index 0000000000..15f6190b57 --- /dev/null +++ b/.changeset/adr-0069-d5-ip-allowlist.md @@ -0,0 +1,15 @@ +--- +'@objectstack/service-settings': minor +'@objectstack/plugin-auth': minor +--- + +Auth: IP allow-list — network gating on the auth routes (ADR-0069 D5, P2) + +Adds an `allowed_ip_ranges` auth setting (CIDR ranges or exact IPs; empty = no restriction). A Hono middleware registered ahead of the better-auth handler in the auth-route registration rejects auth requests from a client IP outside the ranges with `403 IP_NOT_ALLOWED`, before they reach better-auth. + +- Client IP is read trust-proxy-aware from `x-forwarded-for` (first hop) / `cf-connecting-ip` / `x-real-ip`. +- The public render helpers (`/config`, `/bootstrap-status`) are exempt so a blocked client still gets a clean login page + a clear error. +- **Fails OPEN** when the client IP can't be determined (no proxy header), so a misconfigured proxy is a no-op rather than a lockout — an admin enabling this must ensure forwarded headers are trusted. +- IPv4 CIDR (`a.b.c.d/n`) + exact IPv4/IPv6 matching. + +Default-off / additive; per ADR-0049 the setting ships with its enforcement. diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 390ceb82aa..44c1eb99be 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { AuthManager } from './auth-manager'; +import { AuthManager, ipMatchesRange } from './auth-manager'; // Mock better-auth so we can control the handler behaviour vi.mock('better-auth', () => ({ @@ -2121,4 +2121,43 @@ describe('AuthManager', () => { expect(engine.find).not.toHaveBeenCalled(); }); }); + + // ADR-0069 D5 — IP allow-list (network gating). + describe('IP allow-list (ADR-0069 D5)', () => { + const SECRET = 'test-secret-at-least-32-chars-long'; + const mgr = (extra: any = {}) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', ...extra }); + warn.mockRestore(); + return m; + }; + + it('ipMatchesRange handles IPv4 CIDR + exact', () => { + expect(ipMatchesRange('203.0.113.5', '203.0.113.0/24')).toBe(true); + expect(ipMatchesRange('203.0.114.5', '203.0.113.0/24')).toBe(false); + expect(ipMatchesRange('10.0.0.1', '10.0.0.1')).toBe(true); + expect(ipMatchesRange('10.0.0.2', '10.0.0.1')).toBe(false); + expect(ipMatchesRange('192.168.1.42', '192.168.1.42/32')).toBe(true); + expect(ipMatchesRange('1.2.3.4', '0.0.0.0/0')).toBe(true); + }); + + it('allows any IP when no ranges are configured', () => { + const m = mgr({}); + expect(m.isClientIpAllowed('8.8.8.8')).toBe(true); + expect(m.isClientIpAllowed(undefined)).toBe(true); + }); + + it('allows an IP inside a configured range, blocks one outside', () => { + const m = mgr({ allowedIpRanges: ['203.0.113.0/24', '10.0.0.5'] }); + expect(m.isClientIpAllowed('203.0.113.99')).toBe(true); + expect(m.isClientIpAllowed('10.0.0.5')).toBe(true); + expect(m.isClientIpAllowed('8.8.8.8')).toBe(false); + }); + + it('fails OPEN when the client IP cannot be determined (no proxy header)', () => { + const m = mgr({ allowedIpRanges: ['203.0.113.0/24'] }); + expect(m.isClientIpAllowed(undefined)).toBe(true); + expect(m.isClientIpAllowed('')).toBe(true); + }); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 66a6708dcc..a83ac7c9ea 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -332,6 +332,15 @@ export interface AuthManagerOptions extends Partial { sessionAbsoluteMaxHours?: number; maxConcurrentSessions?: number; + /** + * ADR-0069 D5 — network gating. When non-empty, auth requests (sign-in, + * session) from a client IP outside these CIDR / exact ranges are rejected + * with `IP_NOT_ALLOWED` at the auth-route middleware. Requires a trusted proxy + * to set `x-forwarded-for` / `cf-connecting-ip`; fails OPEN when the client IP + * can't be determined (so a missing proxy header is a no-op, not a lockout). + */ + allowedIpRanges?: string[]; + /** * ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to * better-auth's core `rateLimit`. The settings bind tightens `customRules` @@ -353,6 +362,33 @@ export interface AuthManagerOptions extends Partial { * - Passkeys * - Organization/teams */ +/** ADR-0069 D5 — parse a dotted-quad IPv4 to a uint32, or null when not IPv4. */ +function ipv4ToInt(ip: string): number | null { + const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip.trim()); + if (!m) return null; + const p = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]; + if (p.some((n) => n > 255)) return null; + return (((p[0] << 24) >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3]) >>> 0; +} + +/** ADR-0069 D5 — does `ip` match `range` (IPv4 CIDR `a.b.c.d/n`, or exact IP)? */ +export function ipMatchesRange(ip: string, range: string): boolean { + const r = (range || '').trim(); + if (!r) return false; + if (r.includes('/')) { + const [base, bitsStr] = r.split('/'); + const bits = Number(bitsStr); + const ipInt = ipv4ToInt(ip); + const baseInt = ipv4ToInt(base); + if (ipInt === null || baseInt === null || !(bits >= 0 && bits <= 32)) { + return ip.trim() === base.trim(); // non-IPv4-CIDR → exact-match fallback + } + const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; + return ((ipInt & mask) >>> 0) === ((baseInt & mask) >>> 0); + } + return ip.trim() === r; +} + export class AuthManager { private auth: Auth | null = null; private config: AuthManagerOptions; @@ -2753,6 +2789,19 @@ export class AuthManager { } } + /** + * ADR-0069 D5 — is `ip` within the configured allow-list? True (allow) when no + * ranges are configured, OR when the IP can't be determined (fail-open so a + * misconfigured proxy never locks everyone out — an admin enabling this must + * ensure forwarded headers are trusted). Supports IPv4 CIDR + exact IPv4/IPv6. + */ + public isClientIpAllowed(ip: string | undefined): boolean { + const ranges = this.config.allowedIpRanges; + if (!ranges || ranges.length === 0) return true; + if (!ip) return true; // undetermined → fail-open + return ranges.some((r) => ipMatchesRange(ip, r)); + } + /** * Returns the data engine wired into this auth manager. Used by route * handlers (e.g. bootstrap-status) that need to query identity tables diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index d5f3cb161d..e9c17289a9 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -707,6 +707,13 @@ describe('AuthPlugin', () => { expect((manager as any).config.mfaGracePeriodDays).toBe(14); }); + it('binds allowed_ip_ranges into a parsed list (ADR-0069 D5)', async () => { + const { manager } = await bootWithAuthSettings({ + allowed_ip_ranges: { value: '203.0.113.0/24, 10.0.0.5\n192.168.1.1', source: 'global' }, + }); + expect((manager as any).config.allowedIpRanges).toEqual(['203.0.113.0/24', '10.0.0.5', '192.168.1.1']); + }); + it('binds session-control settings (ADR-0069 D4)', async () => { const { manager } = await bootWithAuthSettings({ session_idle_timeout_minutes: { value: 15, source: 'global' }, diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index f8dfed955b..8065277b8f 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -598,6 +598,15 @@ export class AuthPlugin implements Plugin { if (n !== undefined) patch.maxConcurrentSessions = n; } + // Network gating (ADR-0069 D5) — parse the CIDR/IP textarea into a list. + if (isExplicit('allowed_ip_ranges')) { + const raw = asTrimmedString(values.allowed_ip_ranges) ?? ''; + patch.allowedIpRanges = raw + .split(/[\n,]+/) + .map((r) => r.trim()) + .filter(Boolean); + } + // Anti-abuse (ADR-0069 D2) — account lockout (custom, per-identity) // and rate-limit tuning (better-auth-native, per-IP). `asPositiveInt` // rejects 0/malformed; lockout_threshold uses a non-negative reader so @@ -796,6 +805,32 @@ export class AuthPlugin implements Plugin { const rawApp = (httpServer as any).getRawApp(); + // ── ADR-0069 D5 — network gating (IP allow-list) ────────────────────── + // Reject auth requests from a client IP outside the configured ranges, + // BEFORE they reach better-auth. Registered first so it runs ahead of the + // routes below. The public render helpers (/config, /bootstrap-status) are + // exempt so a blocked client still gets a clean login page + error. No-op + // (and no IP parse) when no ranges are configured. + if (typeof rawApp.use === 'function') rawApp.use(`${basePath}/*`, async (c: any, next: any) => { + const mgr = this.authManager; + if (!mgr || typeof mgr.isClientIpAllowed !== 'function') return next(); + const path: string = c.req.path || ''; + if (path.endsWith('/config') || path.endsWith('/bootstrap-status')) return next(); + const fwd = c.req.header('x-forwarded-for'); + const ip = + (typeof fwd === 'string' && fwd.split(',')[0].trim()) || + c.req.header('cf-connecting-ip') || + c.req.header('x-real-ip') || + undefined; + if (!mgr.isClientIpAllowed(ip)) { + return c.json( + { success: false, error: { code: 'IP_NOT_ALLOWED', message: 'Sign-in is not allowed from your network.' } }, + 403, + ); + } + return next(); + }); + // Register /config before the wildcard so it takes precedence. // better-auth has no /config endpoint, so without this explicit route // the wildcard below forwards the request and better-auth returns 404. diff --git a/packages/services/service-settings/src/manifests/auth.manifest.ts b/packages/services/service-settings/src/manifests/auth.manifest.ts index c468d7828d..0662d85f68 100644 --- a/packages/services/service-settings/src/manifests/auth.manifest.ts +++ b/packages/services/service-settings/src/manifests/auth.manifest.ts @@ -266,6 +266,21 @@ const manifest = { description: 'Cap simultaneous signed-in sessions per user; the oldest are signed out past the cap. 0 = unlimited.', }, + { + type: 'group', + id: 'network', + label: 'Network', + required: false, + description: 'Restrict where users can authenticate from.', + }, + { + type: 'textarea', + key: 'allowed_ip_ranges', + label: 'Allowed IP ranges', + required: false, + description: + 'CIDR ranges or exact IPs (one per line, or comma-separated), e.g. 203.0.113.0/24. When set, sign-in from outside these ranges is rejected. Empty = no restriction. Requires a trusted proxy to set X-Forwarded-For.', + }, { type: 'group', id: 'social',