From 5fffcb6d6650c427826307fafca5fcebee769103 Mon Sep 17 00:00:00 2001 From: Cita Date: Wed, 1 Apr 2026 21:06:13 +0800 Subject: [PATCH] feat: support CIDR admin allowlists (#377) --- docs/configuration.md | 4 +- src/server/middleware/auth.test.ts | 11 ++++ src/server/middleware/auth.ts | 63 ++++++++++++++++++- src/server/routes/api/settings.events.test.ts | 50 +++++++++++++++ src/server/routes/api/settings.ts | 9 ++- src/web/pages/Settings.tsx | 4 +- 6 files changed, 135 insertions(+), 6 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index faa0362..28a0776 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -155,9 +155,9 @@ Metapi 的路由引擎按多因子加权选择最优通道。 | 变量名 | 说明 | 默认值 | |--------|------|--------| -| `ADMIN_IP_ALLOWLIST` | 管理端 IP 白名单(逗号分隔) | 空(不限制) | +| `ADMIN_IP_ALLOWLIST` | 管理端 IP 白名单(逗号分隔,支持单个 IP 与 IPv4 CIDR) | 空(不限制) | -示例:`ADMIN_IP_ALLOWLIST=192.168.1.0/24,10.0.0.1` +示例:`ADMIN_IP_ALLOWLIST=192.168.1.10,192.168.1.0/24,10.0.0.0/8` ## 下游 API Key 策略 diff --git a/src/server/middleware/auth.test.ts b/src/server/middleware/auth.test.ts index c30ff1c..5087f99 100644 --- a/src/server/middleware/auth.test.ts +++ b/src/server/middleware/auth.test.ts @@ -15,4 +15,15 @@ describe('auth middleware IP helpers', () => { expect(isIpAllowed('203.0.113.8', ['203.0.113.9'])).toBe(false); expect(isIpAllowed('203.0.113.9', ['203.0.113.9'])).toBe(true); }); + + it('matches ipv4 CIDR ranges in the allowlist', () => { + expect(isIpAllowed('8.8.8.8', ['8.8.8.0/24'])).toBe(true); + expect(isIpAllowed('8.8.9.8', ['8.8.8.0/24'])).toBe(false); + expect(isIpAllowed('8.8.8.8', ['8.8.0.0/16'])).toBe(true); + }); + + it('ignores malformed CIDR entries instead of matching unexpectedly', () => { + expect(isIpAllowed('8.8.8.8', ['8.8.8.0/99'])).toBe(false); + expect(isIpAllowed('8.8.8.8', ['not-an-ip/24'])).toBe(false); + }); }); diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index dd605e2..c3c7da7 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -1,3 +1,4 @@ +import { isIP } from 'node:net'; import { FastifyRequest, FastifyReply } from 'fastify'; import { config } from '../config.js'; import { authorizeDownstreamToken, consumeManagedKeyRequest } from '../services/downstreamApiKeyService.js'; @@ -18,6 +19,10 @@ export interface ProxyResourceOwner { const proxyAuthContextByRequest = new WeakMap(); +type ParsedAllowlistEntry = + | { kind: 'exact'; normalizedIp: string } + | { kind: 'cidr'; network: number; mask: number }; + function normalizeIp(rawIp: string | null | undefined): string { const ip = (rawIp || '').trim(); if (!ip) return ''; @@ -26,6 +31,54 @@ function normalizeIp(rawIp: string | null | undefined): string { return ip; } +function parseIpv4Value(rawIp: string): number | null { + const normalizedIp = normalizeIp(rawIp); + if (isIP(normalizedIp) !== 4) return null; + + let value = 0; + for (const part of normalizedIp.split('.')) { + value = (value << 8) + Number(part); + } + + return value >>> 0; +} + +function parseAllowlistEntry(rawEntry: string): ParsedAllowlistEntry | null { + const entry = (rawEntry || '').trim(); + if (!entry) return null; + + const slashIndex = entry.indexOf('/'); + if (slashIndex === -1) { + const normalizedIp = normalizeIp(entry); + return isIP(normalizedIp) > 0 + ? { kind: 'exact', normalizedIp } + : null; + } + + if (entry.indexOf('/', slashIndex + 1) !== -1) return null; + + const networkIp = normalizeIp(entry.slice(0, slashIndex)); + const prefixText = entry.slice(slashIndex + 1).trim(); + if (isIP(networkIp) !== 4 || !/^\d+$/.test(prefixText)) return null; + + const prefix = Number(prefixText); + if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return null; + + const networkValue = parseIpv4Value(networkIp); + if (networkValue === null) return null; + + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + return { + kind: 'cidr', + network: networkValue & mask, + mask, + }; +} + +export function findInvalidIpAllowlistEntries(allowlist: string[]): string[] { + return allowlist.filter((item) => parseAllowlistEntry(item) === null); +} + export function extractClientIp(remoteIp: string | null | undefined, xForwardedFor?: string | string[] | undefined): string { if (Array.isArray(xForwardedFor)) { const first = xForwardedFor.find((item) => item && item.trim().length > 0); @@ -42,7 +95,15 @@ export function isIpAllowed(clientIp: string, allowlist: string[]): boolean { if (!allowlist || allowlist.length === 0) return true; const normalizedClientIp = normalizeIp(clientIp); if (!normalizedClientIp) return false; - return allowlist.some((item) => normalizeIp(item) === normalizedClientIp); + const clientIpv4Value = parseIpv4Value(normalizedClientIp); + + return allowlist.some((item) => { + const entry = parseAllowlistEntry(item); + if (!entry) return false; + if (entry.kind === 'exact') return entry.normalizedIp === normalizedClientIp; + if (clientIpv4Value === null) return false; + return (clientIpv4Value & entry.mask) === entry.network; + }); } export async function authMiddleware(request: FastifyRequest, reply: FastifyReply) { diff --git a/src/server/routes/api/settings.events.test.ts b/src/server/routes/api/settings.events.test.ts index b155600..c922e75 100644 --- a/src/server/routes/api/settings.events.test.ts +++ b/src/server/routes/api/settings.events.test.ts @@ -758,6 +758,56 @@ describe('settings and auth events', () => { expect(saved?.value).toBe(JSON.stringify(['198.51.100.10', '198.51.100.11'])); }); + it('allows allowlist update when current request IP is covered by a CIDR range', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + remoteAddress: '198.51.100.10', + payload: { + adminIpAllowlist: ['198.51.100.0/24'], + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { adminIpAllowlist?: string[] }; + expect(body.adminIpAllowlist).toEqual(['198.51.100.0/24']); + + const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'admin_ip_allowlist')).get(); + expect(saved?.value).toBe(JSON.stringify(['198.51.100.0/24'])); + }); + + it('rejects allowlist update when current request IP is outside the CIDR range', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + remoteAddress: '198.51.100.10', + payload: { + adminIpAllowlist: ['198.51.101.0/24'], + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json() as { message?: string }; + expect(body.message).toContain('白名单'); + expect(body.message).toContain('198.51.100.10'); + }); + + it('rejects allowlist update when it contains malformed CIDR entries', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + remoteAddress: '198.51.100.10', + payload: { + adminIpAllowlist: ['198.51.100.0/99'], + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json() as { message?: string }; + expect(body.message).toContain('IP 白名单'); + expect(body.message).toContain('198.51.100.0/99'); + }); + it('appends event when admin auth token changes', async () => { const response = await app.inject({ method: 'POST', diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index 62b8b18..11f5357 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -35,7 +35,7 @@ import { parseBackupWebdavExportPayload, } from '../../contracts/settingsRoutePayloads.js'; import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js'; -import { extractClientIp, isIpAllowed } from '../../middleware/auth.js'; +import { extractClientIp, findInvalidIpAllowlistEntries, isIpAllowed } from '../../middleware/auth.js'; import { invalidateSiteProxyCache, normalizeSiteProxyUrl, withExplicitProxyRequestInit } from '../../services/siteProxy.js'; import { performFactoryReset } from '../../services/factoryResetService.js'; import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js'; @@ -1552,6 +1552,13 @@ export async function settingsRoutes(app: FastifyInstance) { if (body.adminIpAllowlist !== undefined) { const nextAllowlist = toStringList(body.adminIpAllowlist); + const invalidAllowlistEntries = findInvalidIpAllowlistEntries(nextAllowlist); + if (invalidAllowlistEntries.length > 0) { + return reply.code(400).send({ + success: false, + message: `保存失败:IP 白名单包含无效条目:${invalidAllowlistEntries.join(', ')}。请使用单个 IP 或 IPv4 CIDR 网段(例如 192.168.1.10 或 192.168.1.0/24)。`, + }); + } if (nextAllowlist.length > 0 && !isIpAllowed(currentRequestIp, nextAllowlist)) { return reply.code(400).send({ success: false, diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index c0dcbbc..83a3510 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -2053,7 +2053,7 @@ export default function Settings() {
会话与安全
- 登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单(每行一个 IP)。 + 登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单,支持每行一个 IP 或 IPv4 CIDR 网段。
当前识别到的管理端 IP(由服务端判定): @@ -2064,7 +2064,7 @@ export default function Settings() {