feat: support CIDR admin allowlists (#377)
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user