feat: support CIDR admin allowlists (#377)
This commit is contained in:
@@ -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 策略
|
## 下游 API Key 策略
|
||||||
|
|
||||||
|
|||||||
@@ -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.8', ['203.0.113.9'])).toBe(false);
|
||||||
expect(isIpAllowed('203.0.113.9', ['203.0.113.9'])).toBe(true);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isIP } from 'node:net';
|
||||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { authorizeDownstreamToken, consumeManagedKeyRequest } from '../services/downstreamApiKeyService.js';
|
import { authorizeDownstreamToken, consumeManagedKeyRequest } from '../services/downstreamApiKeyService.js';
|
||||||
@@ -18,6 +19,10 @@ export interface ProxyResourceOwner {
|
|||||||
|
|
||||||
const proxyAuthContextByRequest = new WeakMap<FastifyRequest, ProxyAuthContext>();
|
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 {
|
function normalizeIp(rawIp: string | null | undefined): string {
|
||||||
const ip = (rawIp || '').trim();
|
const ip = (rawIp || '').trim();
|
||||||
if (!ip) return '';
|
if (!ip) return '';
|
||||||
@@ -26,6 +31,54 @@ function normalizeIp(rawIp: string | null | undefined): string {
|
|||||||
return ip;
|
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 {
|
export function extractClientIp(remoteIp: string | null | undefined, xForwardedFor?: string | string[] | undefined): string {
|
||||||
if (Array.isArray(xForwardedFor)) {
|
if (Array.isArray(xForwardedFor)) {
|
||||||
const first = xForwardedFor.find((item) => item && item.trim().length > 0);
|
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;
|
if (!allowlist || allowlist.length === 0) return true;
|
||||||
const normalizedClientIp = normalizeIp(clientIp);
|
const normalizedClientIp = normalizeIp(clientIp);
|
||||||
if (!normalizedClientIp) return false;
|
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) {
|
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']));
|
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 () => {
|
it('appends event when admin auth token changes', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
parseBackupWebdavExportPayload,
|
parseBackupWebdavExportPayload,
|
||||||
} from '../../contracts/settingsRoutePayloads.js';
|
} from '../../contracts/settingsRoutePayloads.js';
|
||||||
import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.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 { invalidateSiteProxyCache, normalizeSiteProxyUrl, withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import { performFactoryReset } from '../../services/factoryResetService.js';
|
import { performFactoryReset } from '../../services/factoryResetService.js';
|
||||||
import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
|
import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
|
||||||
@@ -1552,6 +1552,13 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
if (body.adminIpAllowlist !== undefined) {
|
if (body.adminIpAllowlist !== undefined) {
|
||||||
const nextAllowlist = toStringList(body.adminIpAllowlist);
|
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)) {
|
if (nextAllowlist.length > 0 && !isIpAllowed(currentRequestIp, nextAllowlist)) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -2053,7 +2053,7 @@ export default function Settings() {
|
|||||||
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}>
|
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>会话与安全</div>
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>会话与安全</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||||
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单(每行一个 IP)。
|
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单,支持每行一个 IP 或 IPv4 CIDR 网段。
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||||
当前识别到的管理端 IP(由服务端判定):
|
当前识别到的管理端 IP(由服务端判定):
|
||||||
@@ -2064,7 +2064,7 @@ export default function Settings() {
|
|||||||
<textarea
|
<textarea
|
||||||
value={adminIpAllowlistText}
|
value={adminIpAllowlistText}
|
||||||
onChange={(e) => setAdminIpAllowlistText(e.target.value)}
|
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}
|
rows={4}
|
||||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', resize: 'vertical', marginBottom: 10 }}
|
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', resize: 'vertical', marginBottom: 10 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user