Skip to content

Commit 086a645

Browse files
committed
Fix geolocation and network info display for CIDR ranges
1 parent 2afdcb2 commit 086a645

File tree

7 files changed

+139
-12
lines changed

7 files changed

+139
-12
lines changed

dashboard/app/api/ip-info/[ip]/route.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,38 @@ import { NextRequest, NextResponse } from 'next/server';
22
import { isIP } from 'net';
33
import { getApiConfig, getApiHeaders } from '@/lib/api-config';
44

5+
/**
6+
* Extract the base IP from a value that may be an IP or CIDR notation.
7+
* Handles both decoded ("/") and URL-encoded ("%2F") slashes.
8+
* For example: "185.226.196.0/24" -> "185.226.196.0"
9+
*/
10+
function extractIpFromValue(value: string): string {
11+
// First try to decode in case Next.js didn't decode %2F
12+
let decoded = value;
13+
try {
14+
decoded = decodeURIComponent(value);
15+
} catch {
16+
// Already decoded or invalid encoding, use as-is
17+
}
18+
const slashIndex = decoded.indexOf('/');
19+
return slashIndex !== -1 ? decoded.substring(0, slashIndex) : decoded;
20+
}
21+
522
export async function GET(request: NextRequest, { params }: { params: Promise<{ ip: string }> }) {
623
const { apiBase } = getApiConfig();
7-
const { ip } = await params;
24+
const { ip: rawIp } = await params;
25+
26+
// Decode the IP in case it contains URL-encoded characters
27+
let ip = rawIp;
28+
try {
29+
ip = decodeURIComponent(rawIp);
30+
} catch {
31+
// Already decoded or invalid encoding
32+
}
833

9-
// Validate IP address format before forwarding to backend
10-
if (!isIP(ip)) {
34+
// Extract base IP if this is a CIDR range, then validate
35+
const baseIp = extractIpFromValue(ip);
36+
if (!isIP(baseIp)) {
1137
return NextResponse.json({ error: 'Invalid IP address format' }, { status: 400 });
1238
}
1339

dashboard/app/api/ip-info/route.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { isIP } from 'net';
3+
import { getApiConfig, getApiHeaders } from '@/lib/api-config';
4+
5+
/**
6+
* Extract the base IP from a value that may be an IP or CIDR notation.
7+
* For example: "185.226.196.0/24" -> "185.226.196.0"
8+
*/
9+
function extractIpFromValue(value: string): string {
10+
const slashIndex = value.indexOf('/');
11+
return slashIndex !== -1 ? value.substring(0, slashIndex) : value;
12+
}
13+
14+
/**
15+
* GET /api/ip-info?ip=...
16+
* Accepts IP addresses or CIDR notation via query parameter.
17+
* This avoids URL routing issues with %2F in path segments.
18+
*/
19+
export async function GET(request: NextRequest) {
20+
const { apiBase } = getApiConfig();
21+
const ip = request.nextUrl.searchParams.get('ip');
22+
23+
if (!ip) {
24+
return NextResponse.json({ error: 'Missing ip parameter' }, { status: 400 });
25+
}
26+
27+
// Extract base IP if this is a CIDR range, then validate
28+
const baseIp = extractIpFromValue(ip);
29+
if (!isIP(baseIp)) {
30+
return NextResponse.json({ error: 'Invalid IP address format' }, { status: 400 });
31+
}
32+
33+
try {
34+
const res = await fetch(`${apiBase}/api/ip-info/${encodeURIComponent(ip)}`, {
35+
cache: 'no-store',
36+
headers: getApiHeaders(),
37+
});
38+
39+
if (!res.ok) {
40+
const error = await res.json().catch(() => ({ error: 'Failed to fetch IP info' }));
41+
return NextResponse.json(error, { status: res.status });
42+
}
43+
44+
const data = await res.json();
45+
return NextResponse.json(data);
46+
} catch {
47+
return NextResponse.json({ error: 'Failed to fetch IP info' }, { status: 500 });
48+
}
49+
}

dashboard/components/IPInfoPanel.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ export function IPInfoPanel({ ip }: IPInfoPanelProps) {
2525
setLoading(true);
2626
setError(null);
2727

28-
// Authentication is handled server-side by /api/ip-info/[ip]/route.ts
29-
const res = await fetchWithAuth(`/api/ip-info/${encodeURIComponent(ip)}`);
28+
// Use query parameter to avoid URL routing issues with CIDR notation (%2F)
29+
const res = await fetchWithAuth(`/api/ip-info?ip=${encodeURIComponent(ip)}`);
3030

3131
if (!res.ok) {
3232
throw new Error('Failed to fetch IP info');

src/ipinfo/index.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ export interface WhoisSummary {
1818
abuse?: string;
1919
}
2020

21+
/**
22+
* Extract the IP address from a value that may be an IP or CIDR notation.
23+
* For example: "192.168.1.0/24" -> "192.168.1.0", "10.0.0.1" -> "10.0.0.1"
24+
*/
25+
export function extractIpFromValue(value: string): string {
26+
const slashIndex = value.indexOf('/');
27+
return slashIndex !== -1 ? value.substring(0, slashIndex) : value;
28+
}
29+
2130
// WHOIS servers by registry
2231
const WHOIS_SERVERS: Record<string, string> = {
2332
ARIN: 'whois.arin.net',
@@ -285,8 +294,11 @@ export async function whoisLookup(ip: string): Promise<WhoisSummary | null> {
285294
* Results are cached for 1 hour to reduce load on WHOIS servers
286295
*/
287296
export async function getIPInfo(ip: string): Promise<IPInfo> {
297+
// Extract base IP if this is a CIDR range
298+
const baseIp = extractIpFromValue(ip);
299+
288300
// Validate IP address
289-
if (!net.isIP(ip)) {
301+
if (!net.isIP(baseIp)) {
290302
return {
291303
ip,
292304
reverseDns: [],
@@ -295,14 +307,14 @@ export async function getIPInfo(ip: string): Promise<IPInfo> {
295307
};
296308
}
297309

298-
// Check cache first
310+
// Check cache first (use original input as cache key to differentiate)
299311
const cached = ipInfoCache.get(ip);
300312
if (cached) {
301313
return cached;
302314
}
303315

304-
// Run lookups in parallel
305-
const [reverseDns, whois] = await Promise.all([reverseDnsLookup(ip), whoisLookup(ip)]);
316+
// Run lookups in parallel using the base IP
317+
const [reverseDns, whois] = await Promise.all([reverseDnsLookup(baseIp), whoisLookup(baseIp)]);
306318

307319
const result: IPInfo = {
308320
ip,

src/proxy/routes/api.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
22
import { timingSafeEqual, createHash } from 'crypto';
33
import net from 'net';
4-
import { getIPInfo } from '../../ipinfo/index.js';
4+
import { getIPInfo, extractIpFromValue } from '../../ipinfo/index.js';
55
import type { LapiServer } from '../../config/index.js';
66
import { getAnalyzerEngine } from '../../analyzers/index.js';
77

@@ -411,8 +411,11 @@ const apiRoutes: FastifyPluginAsync = async (fastify) => {
411411
try {
412412
const { ip } = request.params;
413413

414+
// Extract base IP if this is a CIDR range
415+
const baseIp = extractIpFromValue(ip);
416+
414417
// Validate IP address using Node's net module (handles IPv4 and IPv6 correctly)
415-
if (!net.isIP(ip)) {
418+
if (!net.isIP(baseIp)) {
416419
return reply.code(400).send({ error: 'Invalid IP address format' });
417420
}
418421

src/storage/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Alert } from '../models/alert.js';
44
import type { FilterEngineResult } from '../filters/types.js';
55
import { getDatabaseContext } from '../db/index.js';
66
import type { GeoIPInfo } from '../models/alert.js';
7+
import { extractIpFromValue } from '../ipinfo/index.js';
78

89
/**
910
* Escape SQL LIKE wildcards to prevent injection.
@@ -87,7 +88,8 @@ export function createStorage(): AlertStorage {
8788
const alert = alerts[i];
8889
const detail = filterDetails[i];
8990
// Validate IP before GeoIP lookup to avoid silent failures
90-
const ipToLookup = alert.source.ip || alert.source.value || '';
91+
const rawIpValue = alert.source.ip || alert.source.value || '';
92+
const ipToLookup = extractIpFromValue(rawIpValue);
9193
const geoip = net.isIP(ipToLookup) ? geoipLookup?.(ipToLookup) || null : null;
9294

9395
const insertQuery = db

tests/ipinfo.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
reverseDnsLookup,
55
parseWhoisResponse,
66
detectRir,
7+
extractIpFromValue,
78
} from '../src/ipinfo/index.js';
89

910
describe('IP Info Module', () => {
@@ -164,6 +165,26 @@ netname: RIPE-NCC
164165
});
165166
});
166167

168+
describe('extractIpFromValue', () => {
169+
it('should extract IP from CIDR notation', () => {
170+
expect(extractIpFromValue('192.168.1.0/24')).toBe('192.168.1.0');
171+
expect(extractIpFromValue('10.0.0.0/8')).toBe('10.0.0.0');
172+
expect(extractIpFromValue('2001:db8::/32')).toBe('2001:db8::');
173+
});
174+
175+
it('should return IP as-is when no CIDR', () => {
176+
expect(extractIpFromValue('192.168.1.1')).toBe('192.168.1.1');
177+
expect(extractIpFromValue('10.0.0.1')).toBe('10.0.0.1');
178+
expect(extractIpFromValue('2001:db8::1')).toBe('2001:db8::1');
179+
});
180+
181+
it('should handle edge cases', () => {
182+
expect(extractIpFromValue('')).toBe('');
183+
expect(extractIpFromValue('/')).toBe('');
184+
expect(extractIpFromValue('invalid')).toBe('invalid');
185+
});
186+
});
187+
167188
describe('getIPInfo', () => {
168189
it('should return error for invalid IP address', async () => {
169190
const result = await getIPInfo('not-an-ip');
@@ -195,6 +216,20 @@ netname: RIPE-NCC
195216
expect(result.ip).toBe('::1');
196217
expect(result.error).toBeUndefined();
197218
});
219+
220+
it('should accept CIDR notation and extract base IP', async () => {
221+
const result = await getIPInfo('192.168.1.0/24');
222+
223+
expect(result.ip).toBe('192.168.1.0/24');
224+
expect(result.error).toBeUndefined();
225+
});
226+
227+
it('should return error for invalid CIDR notation', async () => {
228+
const result = await getIPInfo('not-an-ip/24');
229+
230+
expect(result.ip).toBe('not-an-ip/24');
231+
expect(result.error).toBe('Invalid IP address');
232+
});
198233
});
199234

200235
describe('reverseDnsLookup', () => {

0 commit comments

Comments
 (0)