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)
This commit is contained in:
60
app/page.tsx
60
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 (
|
||||||
|
<>
|
||||||
|
<FadeIn delay={0}>
|
||||||
|
<h1 className="hero__title">{title}</h1>
|
||||||
|
</FadeIn>
|
||||||
|
{subtitle && (
|
||||||
|
<FadeIn delay={100}>
|
||||||
|
<p className="hero__subtitle">{subtitle}</p>
|
||||||
|
</FadeIn>
|
||||||
|
)}
|
||||||
|
<FadeIn delay={200}>
|
||||||
|
<nav className="hero__nav">
|
||||||
|
<HeroNavLinks subpages={subpages} albums={albums} />
|
||||||
|
</nav>
|
||||||
|
</FadeIn>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const config = getConfig();
|
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 (
|
|
||||||
<>
|
|
||||||
<FadeIn delay={0}>
|
|
||||||
<h1 className="hero__title">{config.siteTitle}</h1>
|
|
||||||
</FadeIn>
|
|
||||||
{config.siteSubtitle && (
|
|
||||||
<FadeIn delay={100}>
|
|
||||||
<p className="hero__subtitle">{config.siteSubtitle}</p>
|
|
||||||
</FadeIn>
|
|
||||||
)}
|
|
||||||
<FadeIn delay={200}>
|
|
||||||
<nav className="hero__nav">
|
|
||||||
<HeroNavLinks subpages={subpages} albums={albums} />
|
|
||||||
</nav>
|
|
||||||
</FadeIn>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mosaic: multi-image grid with frosted title overlay ────────
|
// ── Mosaic: multi-image grid with frosted title overlay ────────
|
||||||
if (heroStyle === 'mosaic') {
|
if (heroStyle === 'mosaic') {
|
||||||
return (
|
return (
|
||||||
@@ -182,7 +192,7 @@ export default async function HomePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="hero__mosaic-overlay">
|
<div className="hero__mosaic-overlay">
|
||||||
<HeroTextContent />
|
<HeroTextContent title={config.siteTitle} subtitle={config.siteSubtitle} subpages={subpages} albums={albums} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -193,7 +203,7 @@ export default async function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="hero hero--minimal">
|
<div className="hero hero--minimal">
|
||||||
<div className="hero__content">
|
<div className="hero__content">
|
||||||
<HeroTextContent />
|
<HeroTextContent title={config.siteTitle} subtitle={config.siteSubtitle} subpages={subpages} albums={albums} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -205,7 +215,7 @@ export default async function HomePage() {
|
|||||||
<div className="hero hero--fullbleed">
|
<div className="hero hero--fullbleed">
|
||||||
<HeroCarousel images={heroData} />
|
<HeroCarousel images={heroData} />
|
||||||
<div className="hero__fullbleed-overlay">
|
<div className="hero__fullbleed-overlay">
|
||||||
<HeroTextContent />
|
<HeroTextContent title={config.siteTitle} subtitle={config.siteSubtitle} subpages={subpages} albums={albums} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -217,7 +227,7 @@ export default async function HomePage() {
|
|||||||
{/* ── Left Panel ──────────────────────────────── */}
|
{/* ── Left Panel ──────────────────────────────── */}
|
||||||
<div className="hero__left">
|
<div className="hero__left">
|
||||||
<div className="hero__content">
|
<div className="hero__content">
|
||||||
<HeroTextContent />
|
<HeroTextContent title={config.siteTitle} subtitle={config.siteSubtitle} subpages={subpages} albums={albums} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,16 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
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, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
interface MapLocationPublic {
|
interface MapLocationPublic {
|
||||||
city: string;
|
city: string;
|
||||||
country: string;
|
country: string;
|
||||||
@@ -81,15 +91,15 @@ export function MapView() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const albumLinks = loc.albums
|
const albumLinks = loc.albums
|
||||||
.map((a) => `<a href="${a.url}" class="map-popup__album-link">${a.name} →</a>`)
|
.map((a) => `<a href="${escapeHtml(a.url)}" class="map-popup__album-link">${escapeHtml(a.name)} →</a>`)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
const popupHtml = `
|
const popupHtml = `
|
||||||
<div class="map-popup">
|
<div class="map-popup">
|
||||||
<img src="${loc.coverUrl}" alt="${loc.city}" class="map-popup__cover" loading="lazy" />
|
<img src="${escapeHtml(loc.coverUrl)}" alt="${escapeHtml(loc.city)}" class="map-popup__cover" loading="lazy" />
|
||||||
<div class="map-popup__body">
|
<div class="map-popup__body">
|
||||||
<h3 class="map-popup__city">${loc.city}</h3>
|
<h3 class="map-popup__city">${escapeHtml(loc.city)}</h3>
|
||||||
<p class="map-popup__country">${loc.country}</p>
|
<p class="map-popup__country">${escapeHtml(loc.country)}</p>
|
||||||
<p class="map-popup__count">${loc.photoCount} photo${loc.photoCount === 1 ? '' : 's'}</p>
|
<p class="map-popup__count">${loc.photoCount} photo${loc.photoCount === 1 ? '' : 's'}</p>
|
||||||
<div class="map-popup__albums">${albumLinks}</div>
|
<div class="map-popup__albums">${albumLinks}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,4 +57,19 @@ describe('checkRateLimit', () => {
|
|||||||
// Should be within ~60 seconds from now
|
// Should be within ~60 seconds from now
|
||||||
expect(result.resetAt).toBeLessThanOrEqual(before + 61_000);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user