Skip to content

Commit d285ae0

Browse files
authored
Merge pull request InsurNiffy#1064 from kceeglo345-dev/feat/claim-timeline-admin-allowlist
feat(backend): add claim timeline API and admin IP allowlist middleware
2 parents e09b00e + b3fa643 commit d285ae0

9 files changed

Lines changed: 940 additions & 4 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { AllowlistMiddleware } from '../middleware/allowlist.middleware';
2+
import { ForbiddenException } from '@nestjs/common';
3+
import type { Request, Response, NextFunction } from 'express';
4+
5+
function mockConfig(cidrs: string) {
6+
return { get: jest.fn((key: string) => (key === 'ADMIN_ALLOWED_CIDRS' ? cidrs : undefined)) };
7+
}
8+
9+
function makeReq(ip: string, forwarded?: string): Request {
10+
return {
11+
ip,
12+
get: jest.fn((header: string) => {
13+
if (header === 'X-Forwarded-For') return forwarded ?? undefined;
14+
return undefined;
15+
}),
16+
socket: { remoteAddress: ip },
17+
} as unknown as Request;
18+
}
19+
20+
function makeRes(): Response {
21+
return { status: jest.fn().mockReturnThis(), json: jest.fn() } as unknown as Response;
22+
}
23+
24+
describe('AllowlistMiddleware', () => {
25+
describe('empty allowlist (backward compatible)', () => {
26+
it('allows any IP when ADMIN_ALLOWED_CIDRS is empty', () => {
27+
const mw = new AllowlistMiddleware(mockConfig('') as never);
28+
const next: NextFunction = jest.fn();
29+
mw.use(makeReq('10.0.0.1'), makeRes(), next);
30+
expect(next).toHaveBeenCalled();
31+
});
32+
33+
it('allows any IP when ADMIN_ALLOWED_CIDRS is unset', () => {
34+
const mw = new AllowlistMiddleware(mockConfig('') as never);
35+
const next: NextFunction = jest.fn();
36+
mw.use(makeReq('::1'), makeRes(), next);
37+
expect(next).toHaveBeenCalled();
38+
});
39+
});
40+
41+
describe('single CIDR rule', () => {
42+
it('allows IP within the CIDR range', () => {
43+
const mw = new AllowlistMiddleware(mockConfig('10.0.0.0/24') as never);
44+
const next: NextFunction = jest.fn();
45+
mw.use(makeReq('10.0.0.42'), makeRes(), next);
46+
expect(next).toHaveBeenCalled();
47+
});
48+
49+
it('blocks IP outside the CIDR range with ForbiddenException', () => {
50+
const mw = new AllowlistMiddleware(mockConfig('10.0.0.0/24') as never);
51+
const next: NextFunction = jest.fn();
52+
expect(() => mw.use(makeReq('10.0.1.1'), makeRes(), next)).toThrow(ForbiddenException);
53+
expect(next).not.toHaveBeenCalled();
54+
});
55+
56+
it('blocks IP with 403 — no auth processing happens', () => {
57+
const mw = new AllowlistMiddleware(mockConfig('10.0.0.0/24') as never);
58+
const next: NextFunction = jest.fn();
59+
try {
60+
mw.use(makeReq('192.168.1.1'), makeRes(), next);
61+
} catch (e) {
62+
expect(e).toBeInstanceOf(ForbiddenException);
63+
expect((e as ForbiddenException).getStatus()).toBe(403);
64+
expect((e as ForbiddenException).message).toBe('Access denied');
65+
}
66+
expect(next).not.toHaveBeenCalled();
67+
});
68+
});
69+
70+
describe('multiple CIDR rules', () => {
71+
it('allows IP matching any of the rules', () => {
72+
const mw = new AllowlistMiddleware(mockConfig('10.0.0.0/8,192.168.0.0/16') as never);
73+
const next: NextFunction = jest.fn();
74+
mw.use(makeReq('10.1.2.3'), makeRes(), next);
75+
expect(next).toHaveBeenCalled();
76+
});
77+
78+
it('blocks IP not matching any rule', () => {
79+
const mw = new AllowlistMiddleware(mockConfig('10.0.0.0/8,192.168.0.0/16') as never);
80+
const next: NextFunction = jest.fn();
81+
expect(() => mw.use(makeReq('172.16.0.1'), makeRes(), next)).toThrow(ForbiddenException);
82+
expect(next).not.toHaveBeenCalled();
83+
});
84+
});
85+
86+
describe('IPv6 support', () => {
87+
it('allows IPv6 within the CIDR range', () => {
88+
const mw = new AllowlistMiddleware(mockConfig('2001:db8::/32') as never);
89+
const next: NextFunction = jest.fn();
90+
mw.use(makeReq('2001:db8:dead:beef::1'), makeRes(), next);
91+
expect(next).toHaveBeenCalled();
92+
});
93+
94+
it('blocks IPv6 outside the CIDR range', () => {
95+
const mw = new AllowlistMiddleware(mockConfig('2001:db8::/32') as never);
96+
const next: NextFunction = jest.fn();
97+
expect(() => mw.use(makeReq('2001:db9::1'), makeRes(), next)).toThrow(ForbiddenException);
98+
expect(next).not.toHaveBeenCalled();
99+
});
100+
101+
it('handles IPv6 with :: compression', () => {
102+
const mw = new AllowlistMiddleware(mockConfig('fd00::/8') as never);
103+
const next: NextFunction = jest.fn();
104+
mw.use(makeReq('fd12:3456:789a::1'), makeRes(), next);
105+
expect(next).toHaveBeenCalled();
106+
});
107+
108+
it('handles ::1 loopback', () => {
109+
const mw = new AllowlistMiddleware(mockConfig('::1/128') as never);
110+
const next: NextFunction = jest.fn();
111+
mw.use(makeReq('::1'), makeRes(), next);
112+
expect(next).toHaveBeenCalled();
113+
});
114+
});
115+
116+
describe('IPv4-mapped IPv6', () => {
117+
it('matches IPv4-mapped address against IPv4 CIDR rule', () => {
118+
const mw = new AllowlistMiddleware(mockConfig('10.0.0.0/24') as never);
119+
const next: NextFunction = jest.fn();
120+
mw.use(makeReq('::ffff:10.0.0.5'), makeRes(), next);
121+
expect(next).toHaveBeenCalled();
122+
});
123+
124+
it('blocks IPv4-mapped address outside the IPv4 CIDR', () => {
125+
const mw = new AllowlistMiddleware(mockConfig('10.0.0.0/24') as never);
126+
const next: NextFunction = jest.fn();
127+
expect(() => mw.use(makeReq('::ffff:172.16.0.1'), makeRes(), next)).toThrow(ForbiddenException);
128+
expect(next).not.toHaveBeenCalled();
129+
});
130+
});
131+
132+
describe('X-Forwarded-For', () => {
133+
it('uses X-Forwarded-For header when present', () => {
134+
const mw = new AllowlistMiddleware(mockConfig('10.0.0.0/24') as never);
135+
const next: NextFunction = jest.fn();
136+
// req.ip is blocked but X-Forwarded-For is allowed
137+
mw.use(makeReq('192.168.1.1', '10.0.0.5'), makeRes(), next);
138+
expect(next).toHaveBeenCalled();
139+
});
140+
141+
it('takes first IP from X-Forwarded-For chain', () => {
142+
const mw = new AllowlistMiddleware(mockConfig('10.0.0.0/24') as never);
143+
const next: NextFunction = jest.fn();
144+
mw.use(makeReq('192.168.1.1', '10.0.0.5, 192.168.1.1'), makeRes(), next);
145+
expect(next).toHaveBeenCalled();
146+
});
147+
});
148+
149+
describe('non-leaky error message', () => {
150+
it('does not expose the allowlist config in the error', () => {
151+
const mw = new AllowlistMiddleware(mockConfig('10.0.0.0/24,192.168.0.0/16') as never);
152+
const next: NextFunction = jest.fn();
153+
try {
154+
mw.use(makeReq('1.2.3.4'), makeRes(), next);
155+
} catch (e) {
156+
expect(e).toBeInstanceOf(ForbiddenException);
157+
expect((e as ForbiddenException).message).toBe('Access denied');
158+
// Should not leak CIDR ranges
159+
expect((e as ForbiddenException).message).not.toContain('10.0.0.0');
160+
expect((e as ForbiddenException).message).not.toContain('192.168.0.0');
161+
}
162+
});
163+
});
164+
165+
describe('edge cases', () => {
166+
it('allows exact /32 match', () => {
167+
const mw = new AllowlistMiddleware(mockConfig('10.0.0.42/32') as never);
168+
const next: NextFunction = jest.fn();
169+
mw.use(makeReq('10.0.0.42'), makeRes(), next);
170+
expect(next).toHaveBeenCalled();
171+
});
172+
173+
it('blocks non-matching exact /32', () => {
174+
const mw = new AllowlistMiddleware(mockConfig('10.0.0.42/32') as never);
175+
const next: NextFunction = jest.fn();
176+
expect(() => mw.use(makeReq('10.0.0.43'), makeRes(), next)).toThrow(ForbiddenException);
177+
});
178+
179+
it('allows /0 (all IPs)', () => {
180+
const mw = new AllowlistMiddleware(mockConfig('0.0.0.0/0') as never);
181+
const next: NextFunction = jest.fn();
182+
mw.use(makeReq('8.8.8.8'), makeRes(), next);
183+
expect(next).toHaveBeenCalled();
184+
});
185+
186+
it('skips invalid CIDR entries without crashing', () => {
187+
const mw = new AllowlistMiddleware(mockConfig('not-a-cidr,10.0.0.0/24') as never);
188+
const next: NextFunction = jest.fn();
189+
mw.use(makeReq('10.0.0.1'), makeRes(), next);
190+
expect(next).toHaveBeenCalled();
191+
});
192+
});
193+
});

backend/src/admin/admin.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { MaintenanceModule } from '../maintenance/maintenance.module';
1515
import { RateLimitModule } from '../rate-limit/rate-limit.module';
1616
import { QueueMonitorService } from '../queues/queue-monitor.service';
1717
import { BullBoardMiddleware } from './bull-board.middleware';
18+
import { AllowlistMiddleware } from './middleware/allowlist.middleware';
1819
import { MetricsModule } from '../metrics/metrics.module';
1920
import { CacheModule } from '../cache/cache.module';
2021
import { RpcModule } from '../rpc/rpc.module';
@@ -43,6 +44,11 @@ import { CommentRepository } from '../claims/comments/comment.repository';
4344
})
4445
export class AdminModule implements NestModule {
4546
configure(consumer: MiddlewareConsumer) {
47+
// IP allowlist runs before auth guards to fail closed for blocked IPs
48+
consumer
49+
.apply(AllowlistMiddleware)
50+
.forRoutes({ path: 'admin*', method: RequestMethod.ALL });
51+
4652
consumer
4753
.apply(BullBoardMiddleware)
4854
.forRoutes({ path: 'admin/queues*', method: RequestMethod.ALL });
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { Injectable, NestMiddleware, ForbiddenException, Logger } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
import { Request, Response, NextFunction } from 'express';
4+
import { isIPv4, isIPv6 } from 'net';
5+
6+
interface CidrRule {
7+
network: bigint;
8+
mask: bigint;
9+
family: 4 | 6;
10+
}
11+
12+
@Injectable()
13+
export class AllowlistMiddleware implements NestMiddleware {
14+
private readonly logger = new Logger(AllowlistMiddleware.name);
15+
private readonly rules: CidrRule[] = [];
16+
private readonly isEmpty: boolean;
17+
18+
constructor(private readonly configService: ConfigService) {
19+
const raw = this.configService.get<string>('ADMIN_ALLOWED_CIDRS') ?? '';
20+
const parts = raw
21+
.split(',')
22+
.map((s) => s.trim())
23+
.filter(Boolean);
24+
25+
for (const part of parts) {
26+
const rule = parseCidr(part);
27+
if (rule) {
28+
this.rules.push(rule);
29+
} else {
30+
this.logger.warn(`Invalid CIDR in ADMIN_ALLOWED_CIDRS: "${part}" — skipping`);
31+
}
32+
}
33+
34+
this.isEmpty = this.rules.length === 0;
35+
if (this.isEmpty) {
36+
this.logger.warn(
37+
'ADMIN_ALLOWED_CIDRS is empty/unset — all IPs are allowed. ' +
38+
'Set this variable to restrict admin endpoints to trusted CIDR ranges.',
39+
);
40+
}
41+
}
42+
43+
use(req: Request, res: Response, next: NextFunction): void {
44+
if (this.isEmpty) {
45+
next();
46+
return;
47+
}
48+
49+
const clientIp = getClientIp(req);
50+
const allowed = this.rules.some((rule) => ipMatchesCidr(clientIp, rule));
51+
52+
if (!allowed) {
53+
this.logger.warn(`Admin access denied for IP: ${clientIp}`);
54+
throw new ForbiddenException('Access denied');
55+
}
56+
57+
next();
58+
}
59+
}
60+
61+
/**
62+
* Extract the client IP from a request, respecting X-Forwarded-For.
63+
*/
64+
function getClientIp(req: Request): string {
65+
const forwarded = req.get('X-Forwarded-For');
66+
if (forwarded) {
67+
const first = forwarded.split(',')[0].trim();
68+
if (first) return first;
69+
}
70+
return req.ip || req.socket?.remoteAddress || 'unknown';
71+
}
72+
73+
/**
74+
* Parse a CIDR notation string into a rule object.
75+
* Supports both IPv4 (e.g. "10.0.0.0/8") and IPv6 (e.g. "2001:db8::/32").
76+
*/
77+
function parseCidr(input: string): CidrRule | null {
78+
const parts = input.split('/');
79+
const addr = parts[0];
80+
const prefixBits = parts[1] ? parseInt(parts[1], 10) : null;
81+
82+
if (isIPv4(addr)) {
83+
const bits = prefixBits ?? 32;
84+
if (bits < 0 || bits > 32) return null;
85+
const ipInt = ipv4ToBigInt(addr);
86+
const mask = bits === 0 ? BigInt(0) : (BigInt(1) << BigInt(32)) - (BigInt(1) << BigInt(32 - bits));
87+
return { network: ipInt & mask, mask, family: 4 };
88+
}
89+
90+
if (isIPv6(addr)) {
91+
const bits = prefixBits ?? 128;
92+
if (bits < 0 || bits > 128) return null;
93+
const ipBig = ipv6ToBigInt(addr);
94+
const mask = bits === 0 ? BigInt(0) : (BigInt(1) << BigInt(128)) - (BigInt(1) << BigInt(128 - bits));
95+
return { network: ipBig & mask, mask, family: 6 };
96+
}
97+
98+
return null;
99+
}
100+
101+
/**
102+
* Check whether an IP string falls within a CIDR rule.
103+
* Handles IPv4-mapped IPv6 addresses (e.g. ::ffff:10.0.0.1).
104+
*/
105+
function ipMatchesCidr(ip: string, rule: CidrRule): boolean {
106+
if (isIPv4(ip)) {
107+
if (rule.family === 6) return false; // different families
108+
const ipInt = ipv4ToBigInt(ip);
109+
return (ipInt & rule.mask) === rule.network;
110+
}
111+
112+
if (isIPv6(ip)) {
113+
if (rule.family === 4) {
114+
// Check if the IPv6 address is an IPv4-mapped address
115+
const mapped = extractV4FromMappedV6(ip);
116+
if (mapped) {
117+
const ipInt = ipv4ToBigInt(mapped);
118+
return (ipInt & rule.mask) === rule.network;
119+
}
120+
return false;
121+
}
122+
const ipBig = ipv6ToBigInt(ip);
123+
return (ipBig & rule.mask) === rule.network;
124+
}
125+
126+
return false;
127+
}
128+
129+
function ipv4ToBigInt(ip: string): bigint {
130+
const octets = ip.split('.').map((o) => parseInt(o, 10));
131+
return BigInt(octets[0]) << BigInt(24) |
132+
BigInt(octets[1]) << BigInt(16) |
133+
BigInt(octets[2]) << BigInt(8) |
134+
BigInt(octets[3]);
135+
}
136+
137+
function ipv6ToBigInt(ip: string): bigint {
138+
const hextets = expandV6(ip);
139+
let result = BigInt(0);
140+
for (const h of hextets) {
141+
result = (result << BigInt(16)) | BigInt(parseInt(h, 16));
142+
}
143+
return result;
144+
}
145+
146+
/**
147+
* Expand an IPv6 address into 8 hextets, handling :: compression.
148+
*/
149+
function expandV6(ip: string): string[] {
150+
const lower = ip.toLowerCase();
151+
const parts = lower.split('::');
152+
153+
if (parts.length === 2) {
154+
const left = parts[0] ? parts[0].split(':').filter(Boolean) : [];
155+
const right = parts[1] ? parts[1].split(':').filter(Boolean) : [];
156+
const fill = 8 - left.length - right.length;
157+
return [...left, ...new Array(fill).fill('0'), ...right];
158+
}
159+
160+
return lower.split(':').filter(Boolean);
161+
}
162+
163+
/**
164+
* If an IPv6 address is an IPv4-mapped IPv6 address (::ffff:x.x.x.x),
165+
* extract and return the embedded IPv4 string. Otherwise return null.
166+
*/
167+
function extractV4FromMappedV6(ip: string): string | null {
168+
const lower = ip.toLowerCase();
169+
if (!lower.startsWith('::ffff:')) return null;
170+
const candidate = lower.slice(7); // strip "::ffff:"
171+
if (isIPv4(candidate)) return candidate;
172+
return null;
173+
}

0 commit comments

Comments
 (0)