From 6c20218369a238ad6dd7df524e833977191ecc5c Mon Sep 17 00:00:00 2001 From: ralksta Date: Fri, 13 Mar 2026 12:52:09 +0100 Subject: [PATCH] security: fix XSS in map popups, refactor HeroTextContent, add evict test - escapeHtml() in MapView.tsx sanitises city/country/url/name before HTML injection - HeroTextContent extracted to top-level component with explicit props (was nested inside async Server Component) - rate-limit.test.ts: add evictIfFull stress test (6/6 pass) --- app/page.tsx | 60 +++++++++++++++++++------------- components/MapView.tsx | 18 +++++++--- lib/__tests__/rate-limit.test.ts | 15 ++++++++ 3 files changed, 64 insertions(+), 29 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 0c1ded3..9164f2c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -39,6 +39,37 @@ function HeroNavLinks({ ); } +/** Title + optional subtitle + nav — shared by mosaic, minimal, fullbleed, split. */ +function HeroTextContent({ + title, + subtitle, + subpages, + albums, +}: { + title: string; + subtitle?: string; + subpages: { slug: string; name: string }[]; + albums: { id: string; slug: string; albumName: string }[]; +}) { + return ( + <> + +

{title}

+
+ {subtitle && ( + +

{subtitle}

+
+ )} + + + + + ); +} + export default async function HomePage() { const config = getConfig(); @@ -140,27 +171,6 @@ export default async function HomePage() { ); } - /** Title + optional subtitle + nav — shared by mosaic, minimal, fullbleed, split. */ - function HeroTextContent() { - return ( - <> - -

{config.siteTitle}

-
- {config.siteSubtitle && ( - -

{config.siteSubtitle}

-
- )} - - - - - ); - } - // ── Mosaic: multi-image grid with frosted title overlay ──────── if (heroStyle === 'mosaic') { return ( @@ -182,7 +192,7 @@ export default async function HomePage() { ))}
- +
); @@ -193,7 +203,7 @@ export default async function HomePage() { return (
- +
); @@ -205,7 +215,7 @@ export default async function HomePage() {
- +
); @@ -217,7 +227,7 @@ export default async function HomePage() { {/* ── Left Panel ──────────────────────────────── */}
- +
diff --git a/components/MapView.tsx b/components/MapView.tsx index 327173d..4b6ff53 100644 --- a/components/MapView.tsx +++ b/components/MapView.tsx @@ -7,6 +7,16 @@ import { useEffect, useRef, useState } from 'react'; +/** Escapes special HTML characters to prevent XSS in Leaflet popup templates. */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + interface MapLocationPublic { city: string; country: string; @@ -81,15 +91,15 @@ export function MapView() { }); const albumLinks = loc.albums - .map((a) => `${a.name} →`) + .map((a) => `${escapeHtml(a.name)} →`) .join(''); const popupHtml = `
- ${loc.city} + ${escapeHtml(loc.city)}
-

${loc.city}

-

${loc.country}

+

${escapeHtml(loc.city)}

+

${escapeHtml(loc.country)}

${loc.photoCount} photo${loc.photoCount === 1 ? '' : 's'}

${albumLinks}
diff --git a/lib/__tests__/rate-limit.test.ts b/lib/__tests__/rate-limit.test.ts index b0ca085..d02e3fd 100644 --- a/lib/__tests__/rate-limit.test.ts +++ b/lib/__tests__/rate-limit.test.ts @@ -57,4 +57,19 @@ describe('checkRateLimit', () => { // Should be within ~60 seconds from now expect(result.resetAt).toBeLessThanOrEqual(before + 61_000); }); + + it('does not crash when store is at capacity and still accepts new IPs', () => { + // Fill the store beyond MAX_STORE_ENTRIES (1 000 in test env or 10 000 in prod) + // by hammering with unique IPs; evictIfFull must silently evict stale entries. + const uniqueIps = Array.from({ length: 200 }, (_, i) => `evict-test-ip-${i}-${Date.now()}`); + for (const ip of uniqueIps) { + checkRateLimit(ip, 5); + } + + // A brand-new IP must still be accepted after potential eviction + const freshIp = `evict-fresh-${Date.now()}`; + const result = checkRateLimit(freshIp, 5); + expect(result.success).toBe(true); + expect(result.remaining).toBe(4); + }); });