mirror of
https://github.com/cita-777/metapi.git
synced 2026-10-31 03:02:19 +08:00
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 策略
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user