Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions utils/getClientIp.empty-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, expect, it } from 'vitest';
import { getClientIp } from './getClientIp';

describe('getClientIp - Edge Cases & Empty/Missing Inputs Verification', () => {
it('1. returns fallback IP when request object is null or undefined', () => {
expect(getClientIp(null as any)).toBe('127.0.0.1');
expect(getClientIp(undefined as any)).toBe('127.0.0.1');
});

it('2. returns fallback IP when request has no headers property or headers is malformed', () => {
expect(getClientIp({} as any)).toBe('127.0.0.1');
expect(getClientIp({ headers: null } as any)).toBe('127.0.0.1');
expect(getClientIp({ headers: {} } as any)).toBe('127.0.0.1');
expect(getClientIp({ headers: { get: 'not-a-function' } } as any)).toBe('127.0.0.1');
});

it('3. returns fallback IP when options object is null or undefined', () => {
const req = new Request('http://localhost:3000/api/streak');
expect(getClientIp(req, null as any)).toBe('127.0.0.1');
expect(getClientIp(req, undefined as any)).toBe('127.0.0.1');
});

it('4. behaves normally when options is empty object', () => {
const req = new Request('http://localhost:3000/api/streak');
expect(getClientIp(req, {})).toBe('127.0.0.1');
});

it('5. changes fallback behavior based on NODE_ENV (development/test vs production)', () => {
const originalEnv = process.env.NODE_ENV;
try {
(process.env as any).NODE_ENV = 'production';
expect(getClientIp(null as any)).toBe('unknown');
expect(getClientIp({} as any)).toBe('unknown');
} finally {
(process.env as any).NODE_ENV = originalEnv;
}
});

it('6. handles malformed request and headers without throwing even if headersPriority is custom', () => {
expect(
getClientIp(null as any, {
headersPriority: ['x-custom-ip'],
})
).toBe('127.0.0.1');
});
});
20 changes: 16 additions & 4 deletions utils/getClientIp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,20 @@ export function getClientIp(
request: Request | NextRequest,
options: GetClientIpOptions = {}
): string {
const config = options.proxyConfig || loadTrustedProxyConfig();
const opt = options || {};
const isDevOrTest = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test';
const defaultIp = isDevOrTest ? '127.0.0.1' : 'unknown';

if (!request) {
return defaultIp;
}

const headers = request.headers;
if (!headers || typeof headers.get !== 'function') {
return defaultIp;
}

const config = opt.proxyConfig || loadTrustedProxyConfig();

// 1. NextRequest has a secure, platform-populated request.ip property on Vercel/Next.js
const requestIp = (request as unknown as { ip?: string }).ip;
Expand All @@ -77,10 +89,10 @@ export function getClientIp(
return requestIp;
}

const directIp = options.directIp?.trim();
const directIp = opt.directIp?.trim();
const forwardedHeaders = [
'x-forwarded-for',
...(options.headersPriority || ['x-vercel-proxied-for', 'cf-connecting-ip', 'x-real-ip']),
...(opt.headersPriority || ['x-vercel-proxied-for', 'cf-connecting-ip', 'x-real-ip']),
];

// Forwarded headers cannot establish their own trust boundary. Without a
Expand Down Expand Up @@ -159,7 +171,7 @@ export function getClientIp(
}

// 3. Custom/platform headers are accepted only behind a trusted direct peer.
const priorityHeaders = options.headersPriority || [
const priorityHeaders = opt.headersPriority || [
'x-vercel-proxied-for',
'cf-connecting-ip',
'x-real-ip',
Expand Down
Loading