1
0
forked from boos/BoosAPI

feat: support CIDR admin allowlists (#377)

This commit is contained in:
Cita
2026-04-01 21:06:13 +08:00
committed by GitHub
parent 76c85d0a13
commit 5fffcb6d66
6 changed files with 135 additions and 6 deletions
+2 -2
View File
@@ -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 策略
+11
View File
@@ -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);
});
});
+62 -1
View File
@@ -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<FastifyRequest, ProxyAuthContext>();
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) {
@@ -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',
+8 -1
View File
@@ -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,
+2 -2
View File
@@ -2053,7 +2053,7 @@ export default function Settings() {
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>会话与安全</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单(每行一个 IP)。
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单,支持每行一个 IP 或 IPv4 CIDR 网段。
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
当前识别到的管理端 IP(由服务端判定):
@@ -2064,7 +2064,7 @@ export default function Settings() {
<textarea
value={adminIpAllowlistText}
onChange={(e) => setAdminIpAllowlistText(e.target.value)}
placeholder={'例如:\n127.0.0.1\n192.168.1.10'}
placeholder={'例如:\n127.0.0.1\n192.168.1.10\n192.168.1.0/24'}
rows={4}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', resize: 'vertical', marginBottom: 10 }}
/>