Skip to content

Commit 8dff9ee

Browse files
authored
Merge pull request #264 from StreetSupport/fix/security-hardening
fix: address security vulnerabilities across CSP, sanitisation, rate limiting, and env exposure
2 parents b7e57b6 + 4adb687 commit 8dff9ee

File tree

6 files changed

+212
-7
lines changed

6 files changed

+212
-7
lines changed

next.config.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,29 @@ import {withSentryConfig} from '@sentry/nextjs';
22
import dotenv from 'dotenv';
33
dotenv.config({ path: '.env.local' });
44

5+
const cspDirectives = [
6+
"default-src 'self'",
7+
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://maps.googleapis.com https://www.googletagmanager.com https://www.google-analytics.com https://*.sentry-cdn.com",
8+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
9+
"img-src 'self' data: blob: https://streetsupportstorageprod.blob.core.windows.net https://maps.googleapis.com https://maps.gstatic.com https://*.ggpht.com https://www.google-analytics.com https://www.googletagmanager.com",
10+
"font-src 'self' https://fonts.gstatic.com",
11+
"connect-src 'self' https://maps.googleapis.com https://www.google-analytics.com https://www.googletagmanager.com https://*.ingest.sentry.io https://*.sentry.io",
12+
"frame-src https://www.youtube.com https://www.youtube-nocookie.com",
13+
"frame-ancestors 'none'",
14+
"object-src 'none'",
15+
"base-uri 'self'",
16+
"form-action 'self'",
17+
];
18+
19+
const contentSecurityPolicy = cspDirectives.join('; ');
20+
521
const nextConfig = {
622
async headers() {
723
return [
824
{
925
source: '/(.*)',
1026
headers: [
27+
{ key: 'Content-Security-Policy', value: contentSecurityPolicy },
1128
{ key: 'X-Content-Type-Options', value: 'nosniff' },
1229
{ key: 'X-Frame-Options', value: 'DENY' },
1330
{ key: 'X-XSS-Protection', value: '1; mode=block' },
@@ -39,7 +56,6 @@ const nextConfig = {
3956
reactStrictMode: true,
4057
env: {
4158
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY,
42-
MONGODB_URI: process.env.MONGODB_URI,
4359
},
4460
// Enable response compression for better performance
4561
compress: true,

src/app/api/geocode/route.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,29 @@
11
import { NextRequest } from 'next/server';
2+
import { checkRateLimit } from '@/utils/rateLimit';
3+
4+
const GEOCODE_RATE_LIMIT = {
5+
maxRequests: 30,
6+
windowMs: 60_000,
7+
};
28

39
export async function GET(req: NextRequest) {
10+
const { allowed, remaining, resetTime } = checkRateLimit(req, GEOCODE_RATE_LIMIT);
11+
12+
if (!allowed) {
13+
return new Response(
14+
JSON.stringify({ error: 'Too many requests. Please try again later.' }),
15+
{
16+
status: 429,
17+
headers: {
18+
'Content-Type': 'application/json',
19+
'Retry-After': String(Math.ceil((resetTime - Date.now()) / 1000)),
20+
'X-RateLimit-Limit': String(GEOCODE_RATE_LIMIT.maxRequests),
21+
'X-RateLimit-Remaining': '0',
22+
},
23+
}
24+
);
25+
}
26+
427
const { searchParams } = new URL(req.url);
528
const postcode = searchParams.get('postcode');
629

@@ -31,7 +54,11 @@ export async function GET(req: NextRequest) {
3154
const location = data.results[0].geometry.location;
3255
return new Response(JSON.stringify({ location }), {
3356
status: 200,
34-
headers: { 'Content-Type': 'application/json' },
57+
headers: {
58+
'Content-Type': 'application/json',
59+
'X-RateLimit-Limit': String(GEOCODE_RATE_LIMIT.maxRequests),
60+
'X-RateLimit-Remaining': String(remaining),
61+
},
3562
});
3663
} else {
3764
return new Response(

src/components/ui/MarkdownContent.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
import React from 'react';
44
import { decodeHtmlEntities } from '@/utils/htmlDecode';
5+
import { sanitiseHtml } from '@/utils/sanitiseHtml';
6+
7+
const MARKDOWN_ALLOWED_TAGS = [
8+
'p', 'br',
9+
'strong', 'em', 'b', 'i',
10+
'ul', 'ol', 'li',
11+
'a',
12+
];
513

614
interface MarkdownContentProps {
715
content: string;
@@ -41,7 +49,7 @@ function processSimpleMarkdown(text: string): string {
4149
// Output the collected bullet items as a list
4250
if (currentBulletItems.length > 0) {
4351
const listItems = currentBulletItems.map(item => `<li>${item}</li>`).join('');
44-
result.push(`<ul style="list-style-type: disc; padding-left: 1.5rem; margin: 0.5rem 0;">${listItems}</ul>`);
52+
result.push(`<ul>${listItems}</ul>`);
4553
currentBulletItems = [];
4654
}
4755
inBulletSection = false;
@@ -58,7 +66,7 @@ function processSimpleMarkdown(text: string): string {
5866
// Handle any remaining bullet items at the end
5967
if (currentBulletItems.length > 0) {
6068
const listItems = currentBulletItems.map(item => `<li>${item}</li>`).join('');
61-
result.push(`<ul style="list-style-type: disc; padding-left: 1.5rem; margin: 0.5rem 0;">${listItems}</ul>`);
69+
result.push(`<ul>${listItems}</ul>`);
6270
}
6371

6472
const finalResult = result.join('\n')
@@ -81,7 +89,7 @@ function processSimpleMarkdown(text: string): string {
8189
looseLiItems.push(line.trim());
8290
} else {
8391
if (looseLiItems.length > 0) {
84-
wrappedResult.push(`<ul style="list-style-type: disc; padding-left: 1.5rem; margin: 0.5rem 0;">${looseLiItems.join('')}</ul>`);
92+
wrappedResult.push(`<ul>${looseLiItems.join('')}</ul>`);
8593
looseLiItems = [];
8694
}
8795
wrappedResult.push(line);
@@ -90,7 +98,7 @@ function processSimpleMarkdown(text: string): string {
9098

9199
// Handle any remaining loose items
92100
if (looseLiItems.length > 0) {
93-
wrappedResult.push(`<ul style="list-style-type: disc; padding-left: 1.5rem; margin: 0.5rem 0;">${looseLiItems.join('')}</ul>`);
101+
wrappedResult.push(`<ul>${looseLiItems.join('')}</ul>`);
94102
}
95103

96104
return wrappedResult.join('\n')
@@ -110,11 +118,12 @@ function processSimpleMarkdown(text: string): string {
110118
export default function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
111119
const decodedContent = decodeHtmlEntities(content);
112120
const processedContent = processSimpleMarkdown(decodedContent);
121+
const sanitisedContent = sanitiseHtml(processedContent, MARKDOWN_ALLOWED_TAGS);
113122

114123
return (
115124
<div
116125
className={`prose prose-gray max-w-none leading-relaxed prose-sm prose-p:text-sm prose-p:leading-normal prose-p:text-gray-800 ${className}`}
117-
dangerouslySetInnerHTML={{ __html: processedContent }}
126+
dangerouslySetInnerHTML={{ __html: sanitisedContent }}
118127
/>
119128
);
120129
}

src/utils/rateLimit.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { NextRequest } from 'next/server';
2+
3+
interface RateLimitEntry {
4+
count: number;
5+
resetTime: number;
6+
}
7+
8+
const store = new Map<string, RateLimitEntry>();
9+
10+
const CLEANUP_INTERVAL_MS = 60_000;
11+
let lastCleanup = Date.now();
12+
13+
function cleanup() {
14+
const now = Date.now();
15+
if (now - lastCleanup < CLEANUP_INTERVAL_MS) return;
16+
lastCleanup = now;
17+
for (const [key, entry] of store) {
18+
if (now > entry.resetTime) {
19+
store.delete(key);
20+
}
21+
}
22+
}
23+
24+
interface RateLimitOptions {
25+
maxRequests: number;
26+
windowMs: number;
27+
}
28+
29+
interface RateLimitResult {
30+
allowed: boolean;
31+
remaining: number;
32+
resetTime: number;
33+
}
34+
35+
export function checkRateLimit(
36+
req: NextRequest,
37+
{ maxRequests, windowMs }: RateLimitOptions
38+
): RateLimitResult {
39+
cleanup();
40+
41+
const forwarded = req.headers.get('x-forwarded-for');
42+
const ip = forwarded?.split(',')[0]?.trim() || 'unknown';
43+
const now = Date.now();
44+
45+
const entry = store.get(ip);
46+
47+
if (!entry || now > entry.resetTime) {
48+
store.set(ip, { count: 1, resetTime: now + windowMs });
49+
return { allowed: true, remaining: maxRequests - 1, resetTime: now + windowMs };
50+
}
51+
52+
entry.count += 1;
53+
54+
if (entry.count > maxRequests) {
55+
return { allowed: false, remaining: 0, resetTime: entry.resetTime };
56+
}
57+
58+
return { allowed: true, remaining: maxRequests - entry.count, resetTime: entry.resetTime };
59+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { checkRateLimit } from '../../../src/utils/rateLimit';
2+
import { NextRequest } from 'next/server';
3+
4+
function makeRequest(ip: string = '127.0.0.1'): NextRequest {
5+
const req = new NextRequest('http://localhost/api/geocode?postcode=M1+1AA', {
6+
headers: { 'x-forwarded-for': ip },
7+
});
8+
return req;
9+
}
10+
11+
describe('checkRateLimit', () => {
12+
const options = { maxRequests: 3, windowMs: 60_000 };
13+
14+
it('should allow requests within the limit', () => {
15+
const ip = '10.0.0.1';
16+
const result1 = checkRateLimit(makeRequest(ip), options);
17+
expect(result1.allowed).toBe(true);
18+
expect(result1.remaining).toBe(2);
19+
20+
const result2 = checkRateLimit(makeRequest(ip), options);
21+
expect(result2.allowed).toBe(true);
22+
expect(result2.remaining).toBe(1);
23+
24+
const result3 = checkRateLimit(makeRequest(ip), options);
25+
expect(result3.allowed).toBe(true);
26+
expect(result3.remaining).toBe(0);
27+
});
28+
29+
it('should block requests exceeding the limit', () => {
30+
const ip = '10.0.0.2';
31+
for (let i = 0; i < 3; i++) {
32+
checkRateLimit(makeRequest(ip), options);
33+
}
34+
35+
const blocked = checkRateLimit(makeRequest(ip), options);
36+
expect(blocked.allowed).toBe(false);
37+
expect(blocked.remaining).toBe(0);
38+
});
39+
40+
it('should track different IPs independently', () => {
41+
const ipA = '10.0.0.3';
42+
const ipB = '10.0.0.4';
43+
44+
for (let i = 0; i < 3; i++) {
45+
checkRateLimit(makeRequest(ipA), options);
46+
}
47+
48+
const blockedA = checkRateLimit(makeRequest(ipA), options);
49+
expect(blockedA.allowed).toBe(false);
50+
51+
const allowedB = checkRateLimit(makeRequest(ipB), options);
52+
expect(allowedB.allowed).toBe(true);
53+
expect(allowedB.remaining).toBe(2);
54+
});
55+
56+
it('should reset after the window expires', () => {
57+
const ip = '10.0.0.5';
58+
const shortWindow = { maxRequests: 1, windowMs: 50 };
59+
60+
const result1 = checkRateLimit(makeRequest(ip), shortWindow);
61+
expect(result1.allowed).toBe(true);
62+
63+
const blocked = checkRateLimit(makeRequest(ip), shortWindow);
64+
expect(blocked.allowed).toBe(false);
65+
66+
return new Promise<void>((resolve) => {
67+
setTimeout(() => {
68+
const result2 = checkRateLimit(makeRequest(ip), shortWindow);
69+
expect(result2.allowed).toBe(true);
70+
resolve();
71+
}, 60);
72+
});
73+
});
74+
75+
it('should include a resetTime in the result', () => {
76+
const ip = '10.0.0.6';
77+
const before = Date.now();
78+
const result = checkRateLimit(makeRequest(ip), options);
79+
const after = Date.now();
80+
81+
expect(result.resetTime).toBeGreaterThanOrEqual(before + options.windowMs);
82+
expect(result.resetTime).toBeLessThanOrEqual(after + options.windowMs);
83+
});
84+
85+
it('should handle missing x-forwarded-for header', () => {
86+
const req = new NextRequest('http://localhost/api/geocode?postcode=M1+1AA');
87+
const result = checkRateLimit(req, { maxRequests: 100, windowMs: 60_000 });
88+
expect(result.allowed).toBe(true);
89+
});
90+
});

vercel.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
{
1919
"source": "/(.*)",
2020
"headers": [
21+
{
22+
"key": "Content-Security-Policy",
23+
"value": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://maps.googleapis.com https://www.googletagmanager.com https://www.google-analytics.com https://*.sentry-cdn.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://streetsupportstorageprod.blob.core.windows.net https://maps.googleapis.com https://maps.gstatic.com https://*.ggpht.com https://www.google-analytics.com https://www.googletagmanager.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://maps.googleapis.com https://www.google-analytics.com https://www.googletagmanager.com https://*.ingest.sentry.io https://*.sentry.io; frame-src https://www.youtube.com https://www.youtube-nocookie.com; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'"
24+
},
2125
{
2226
"key": "X-Content-Type-Options",
2327
"value": "nosniff"

0 commit comments

Comments
 (0)