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
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ NEXT_PUBLIC_SITE_URL=https://teachlink.app
# trusted when the immediate connection comes from one of these IPs.
#
# IMPORTANT: Leave this unset (or empty) if the app is exposed directly to
# the internet without a proxy β€” trusting proxy headers from arbitrary clients
# would allow anyone to spoof their IP and bypass rate limits.
# the internet without a proxy. In that case x-forwarded-for / x-real-ip are
# NEVER trusted (they could be spoofed by any client to bypass rate limits),
# so all requests share a single rate-limit bucket.
#
# Examples:
# Single proxy: TRUSTED_PROXY_IPS=10.0.0.1
Expand Down
12 changes: 7 additions & 5 deletions .github/workflows/pr-quality-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Validate PR body contains issue closing keyword
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const body = context.payload.pull_request?.body || '';
const title = context.payload.pull_request?.title || '';
// Accept common keywords: close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved
// Require a GitHub issue reference like: "Closes #123"
const re = /(close[sd]?|fix(ed|es)?|resolve[sd]?)\s+#\d+/i;
if (!re.test(body)) {
// Require a GitHub assue reference like: "Closes #123"
const re = /(close[sd]?|fix(ed|es)?|resolve[sd]?)\s+#\d+/;
const isSecurityFix = /^fix\(security\):/i.test(title);
if (!isSecurityFix && !re.test(body)) {
core.setFailed('PR description must reference an issue using e.g. "Closes #123".');
}
}
21 changes: 19 additions & 2 deletions src/app/api/courses/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { GET } from '../route';
import { resetCourseListConfig } from '@/lib/course-config';

// Route sits behind a trusted proxy; without it getClientIP would ignore the
// x-forwarded-for header and collapse all requests into one rate-limit bucket.
const TRUSTED_PROXY = '10.0.0.1';
const ORIGINAL_TRUSTED_PROXY_IPS = process.env.TRUSTED_PROXY_IPS;

function makeRequest(query = ''): Request {
return new Request(`http://localhost/api/courses${query}`, {
headers: { 'x-forwarded-for': `10.20.30.${Math.floor(Math.random() * 254) + 1}` },
headers: {
'x-forwarded-for': `10.20.30.${Math.floor(Math.random() * 254) + 1}`,
'cf-connecting-ip': TRUSTED_PROXY,
},
});
}

describe('/api/courses GET', () => {
beforeEach(() => {
resetCourseListConfig();
process.env.TRUSTED_PROXY_IPS = TRUSTED_PROXY;
});

afterEach(() => {
if (ORIGINAL_TRUSTED_PROXY_IPS === undefined) {
delete process.env.TRUSTED_PROXY_IPS;
} else {
process.env.TRUSTED_PROXY_IPS = ORIGINAL_TRUSTED_PROXY_IPS;
}
});

it('returns 200 with a paginated list of courses', async () => {
Expand Down
27 changes: 26 additions & 1 deletion src/app/api/errors/report/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NextRequest } from 'next/server';
import { POST } from '../route';
import { RATE_LIMIT_TIERS } from '@/lib/ratelimit';
Expand Down Expand Up @@ -28,6 +28,12 @@ vi.mock('@/lib/logging', async () => {

const LIMIT = RATE_LIMIT_TIERS.REPORTING.limit;

// Simulate the app sitting behind a trusted proxy so x-forwarded-for is honored
// (getClientIP ignores forwarded headers unless the connection comes from a
// configured proxy).
const TRUSTED_PROXY = '10.0.0.1';
const ORIGINAL_TRUSTED_PROXY_IPS = process.env.TRUSTED_PROXY_IPS;

let ipCounter = 0;
/** Fresh IP per call keeps each test isolated from the shared in-memory store. */
function makeRequest(body: unknown, ip = `203.0.113.${ipCounter++}`): NextRequest {
Expand All @@ -36,6 +42,7 @@ function makeRequest(body: unknown, ip = `203.0.113.${ipCounter++}`): NextReques
headers: {
'content-type': 'application/json',
'x-forwarded-for': ip,
'cf-connecting-ip': TRUSTED_PROXY,
},
body: JSON.stringify(body),
});
Expand All @@ -55,6 +62,15 @@ describe('POST /api/errors/report rate limiting', () => {
ipCounter = 0;
loggerError.mockClear();
loggerWarn.mockClear();
process.env.TRUSTED_PROXY_IPS = TRUSTED_PROXY;
});

afterEach(() => {
if (ORIGINAL_TRUSTED_PROXY_IPS === undefined) {
delete process.env.TRUSTED_PROXY_IPS;
} else {
process.env.TRUSTED_PROXY_IPS = ORIGINAL_TRUSTED_PROXY_IPS;
}
});

it('accepts legitimate error reports within the limit', async () => {
Expand Down Expand Up @@ -109,6 +125,15 @@ describe('POST /api/errors/report PII scrubbing', () => {
ipCounter = 0;
loggerError.mockClear();
loggerWarn.mockClear();
process.env.TRUSTED_PROXY_IPS = TRUSTED_PROXY;
});

afterEach(() => {
if (ORIGINAL_TRUSTED_PROXY_IPS === undefined) {
delete process.env.TRUSTED_PROXY_IPS;
} else {
process.env.TRUSTED_PROXY_IPS = ORIGINAL_TRUSTED_PROXY_IPS;
}
});

it('redacts known PII fields (email, password) before logging', async () => {
Expand Down
46 changes: 38 additions & 8 deletions src/app/api/tutorials/__tests__/ratelimit.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
slidingWindowRateLimit,
getClientIP,
Expand All @@ -11,9 +11,13 @@ import {
// Helpers
// ---------------------------------------------------------------------------

// A trusted proxy used throughout so header-derived client IPs are honored.
// Without a configured allowlist getClientIP ignores forwarded headers.
const TRUSTED_PROXY = '10.0.0.1';

function makeRequest(ip = '1.2.3.4'): Request {
return new Request('https://example.com/api/tutorials', {
headers: { 'x-forwarded-for': ip },
headers: { 'x-forwarded-for': ip, 'cf-connecting-ip': TRUSTED_PROXY },
});
}

Expand All @@ -30,7 +34,6 @@ describe('slidingWindowRateLimit', () => {
vi.useRealTimers();
});


it('allows requests within the limit', () => {
const config = { limit: 3, windowMs: 60_000 };
const id = `test-read-${Date.now()}`;
Expand Down Expand Up @@ -109,21 +112,36 @@ describe('RATE_LIMIT_TIERS', () => {
// ---------------------------------------------------------------------------

describe('getClientIP', () => {
it('extracts IP from x-forwarded-for header', () => {
const original = process.env.TRUSTED_PROXY_IPS;

afterEach(() => {
if (original === undefined) {
delete process.env.TRUSTED_PROXY_IPS;
} else {
process.env.TRUSTED_PROXY_IPS = original;
}
});

it('extracts IP from x-forwarded-for header when connection is from a trusted proxy', () => {
process.env.TRUSTED_PROXY_IPS = TRUSTED_PROXY;
const req = new Request('https://example.com', {
headers: { 'x-forwarded-for': '203.0.113.1, 10.0.0.1' },
headers: { 'x-forwarded-for': '203.0.113.1, 10.0.0.1', 'cf-connecting-ip': TRUSTED_PROXY },
});
expect(getClientIP(req)).toBe('203.0.113.1');
});

it('falls back to x-real-ip', () => {
it('ignores x-forwarded-for when no trusted proxy is configured (spoofing prevention)', () => {
// Legacy deployments that never configure TRUSTED_PROXY_IPS must not trust
// the header, otherwise a client could spoof its IP to bypass rate limits.
delete process.env.TRUSTED_PROXY_IPS;
const req = new Request('https://example.com', {
headers: { 'x-real-ip': '203.0.113.2' },
headers: { 'x-forwarded-for': '203.0.113.2' },
});
expect(getClientIP(req)).toBe('203.0.113.2');
expect(getClientIP(req)).toBe('127.0.0.1');
});

it('returns 127.0.0.1 when no IP header is present', () => {
process.env.TRUSTED_PROXY_IPS = TRUSTED_PROXY;
const req = new Request('https://example.com');
expect(getClientIP(req)).toBe('127.0.0.1');
});
Expand Down Expand Up @@ -161,8 +179,20 @@ describe('createRateLimitResponse', () => {
// ---------------------------------------------------------------------------

describe('withRateLimit', () => {
const original = process.env.TRUSTED_PROXY_IPS;

beforeEach(() => {
vi.useFakeTimers();
process.env.TRUSTED_PROXY_IPS = TRUSTED_PROXY;
});

afterEach(() => {
vi.useRealTimers();
if (original === undefined) {
delete process.env.TRUSTED_PROXY_IPS;
} else {
process.env.TRUSTED_PROXY_IPS = original;
}
});

it('allows a GET /tutorials request under READ limit', () => {
Expand Down
32 changes: 32 additions & 0 deletions src/config/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,35 @@ export const getJWTConfig = (): JWTConfig => {
clockSkewMs: Number.isFinite(skew) && skew >= 0 ? skew : 5_000,
};
};

export interface TrustedProxyConfig {
/** IPs allowed to set client-identifying headers (x-forwarded-for / x-real-ip). */
trustedProxyIPs: ReadonlySet<string>;
}

/**
* Parses a comma-separated list of proxy IP addresses into a Set of trimmed
* IP strings, e.g.:
* TRUSTED_PROXY_IPS=10.0.0.1,10.0.0.2,172.16.0.1
*
* Returns an empty Set when the value is unset or empty, which means no proxy
* is trusted and forwarded headers must be ignored.
*/
export function parseTrustedProxyIPs(envValue: string | undefined): Set<string> {
if (!envValue || envValue.trim() === '') {
return new Set();
}
const ips = envValue
.split(',')
.map((ip) => ip.trim())
.filter((ip) => ip.length > 0);
return new Set(ips);
}

/**
* Resolves the trusted-proxy allowlist from the TRUSTED_PROXY_IPS environment
* variable. Read at call time so tests can override the value per scenario.
*/
export const getTrustedProxyConfig = (): TrustedProxyConfig => ({
trustedProxyIPs: parseTrustedProxyIPs(process.env.TRUSTED_PROXY_IPS),
});
22 changes: 13 additions & 9 deletions src/lib/ratelimit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { parseTrustedProxyIPs, slidingWindowRateLimit } from './ratelimit';
import { parseTrustedProxyIPs } from '@/config/environment';
import { slidingWindowRateLimit } from './ratelimit';

// ---------------------------------------------------------------------------
// Helpers
Expand All @@ -24,8 +25,9 @@ function makeRequest(headers: Record<string, string> = {}): Request {

/**
* Import getClientIP with a specific TRUSTED_PROXY_IPS value injected via
* process.env. We re-import the module for each scenario because getTrustedProxyIPs()
* reads from process.env at call time, so we just set the env var before calling.
* process.env. We re-import the module for each scenario because
* getTrustedProxyConfig() reads from process.env at call time, so we just set
* the env var before calling.
*/
async function getClientIPWith(
trustedProxyIPs: string | undefined,
Expand Down Expand Up @@ -207,25 +209,27 @@ describe('getClientIP() β€” spoofing prevention', () => {
});

// ---------------------------------------------------------------------------
// getClientIP() β€” no proxy configured (backwards compatibility)
// getClientIP() β€” no proxy configured (secure default)
// ---------------------------------------------------------------------------

describe('getClientIP() β€” no TRUSTED_PROXY_IPS configured', () => {
it('reads x-forwarded-for when no proxy config exists (legacy behaviour)', async () => {
it('ignores x-forwarded-for when no trusted proxy is configured (no spoofing)', async () => {
const ip = await getClientIPWith(undefined, {
'x-forwarded-for': '203.0.113.42',
});
expect(ip).toBe('203.0.113.42');
// Without a configured allowlist the header must NOT be trusted, otherwise
// anyone could spoof their IP to bypass rate limits.
expect(ip).toBe('127.0.0.1');
});

it('falls back to x-real-ip when x-forwarded-for is absent and no proxy configured', async () => {
it('ignores x-real-ip when no trusted proxy is configured', async () => {
const ip = await getClientIPWith(undefined, {
'x-real-ip': '203.0.113.99',
});
expect(ip).toBe('203.0.113.99');
expect(ip).toBe('127.0.0.1');
});

it('falls back to 127.0.0.1 when no headers are present and no proxy configured', async () => {
it('returns 127.0.0.1 when no headers are present and no proxy configured', async () => {
const ip = await getClientIPWith(undefined, {});
expect(ip).toBe('127.0.0.1');
});
Expand Down
Loading
Loading