From 6bb35d468fcb067935786977f9e97d9337675f1e Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 4 Apr 2026 17:13:01 -0400 Subject: [PATCH] security: add headers, fetch timeouts, Retry-After cap, env validation - next.config.ts: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy - sixflags.ts: cap Retry-After at 5 min; add 15s AbortSignal.timeout() - queuetimes.ts: add 10s AbortSignal.timeout() - rcdb.ts: add 15s AbortSignal.timeout() - lib/env.ts: parseStalenessHours() guards against NaN from invalid env vars - db.ts + park-meta.ts: use parseStalenessHours() for staleness window config Co-Authored-By: Claude Sonnet 4.6 --- lib/db.ts | 4 ++-- lib/env.ts | 13 +++++++++++++ lib/park-meta.ts | 4 ++-- lib/scrapers/queuetimes.ts | 1 + lib/scrapers/rcdb.ts | 2 +- lib/scrapers/sixflags.ts | 4 ++-- next.config.ts | 25 +++++++++++++++++++++++++ 7 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 lib/env.ts diff --git a/lib/db.ts b/lib/db.ts index 7c7052c..32c59cd 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -167,8 +167,8 @@ export function getMonthCalendar( return result; } -const STALE_AFTER_MS = - parseInt(process.env.PARK_HOURS_STALENESS_HOURS ?? "72", 10) * 60 * 60 * 1000; +import { parseStalenessHours } from "./env"; +const STALE_AFTER_MS = parseStalenessHours(process.env.PARK_HOURS_STALENESS_HOURS, 72) * 60 * 60 * 1000; /** * Returns true when the scraper should skip this park+month. diff --git a/lib/env.ts b/lib/env.ts new file mode 100644 index 0000000..9e13b93 --- /dev/null +++ b/lib/env.ts @@ -0,0 +1,13 @@ +/** + * Environment variable helpers. + */ + +/** + * Parse a staleness window from an env var string (interpreted as hours). + * Falls back to `defaultHours` when the value is missing, non-numeric, + * non-finite, or <= 0 — preventing NaN from silently breaking staleness checks. + */ +export function parseStalenessHours(envVar: string | undefined, defaultHours: number): number { + const parsed = parseInt(envVar ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultHours; +} diff --git a/lib/park-meta.ts b/lib/park-meta.ts index 8bc205e..4cdee7f 100644 --- a/lib/park-meta.ts +++ b/lib/park-meta.ts @@ -44,8 +44,7 @@ export function defaultParkMeta(): ParkMeta { return { rcdb_id: null, coasters: [], coasters_scraped_at: null }; } -const COASTER_STALE_MS = - parseInt(process.env.COASTER_STALENESS_HOURS ?? "720", 10) * 60 * 60 * 1000; +const COASTER_STALE_MS = parseStalenessHours(process.env.COASTER_STALENESS_HOURS, 720) * 60 * 60 * 1000; /** Returns true when the coaster list needs to be re-scraped from RCDB. */ export function areCoastersStale(entry: ParkMeta): boolean { @@ -55,6 +54,7 @@ export function areCoastersStale(entry: ParkMeta): boolean { import { normalizeForMatch } from "./coaster-match"; export { normalizeForMatch as normalizeRideName } from "./coaster-match"; +import { parseStalenessHours } from "./env"; /** * Returns a Set of normalized coaster names for fast membership checks. diff --git a/lib/scrapers/queuetimes.ts b/lib/scrapers/queuetimes.ts index 5e6cdda..82829c0 100644 --- a/lib/scrapers/queuetimes.ts +++ b/lib/scrapers/queuetimes.ts @@ -77,6 +77,7 @@ export async function fetchLiveRides( const res = await fetch(url, { headers: HEADERS, next: { revalidate }, + signal: AbortSignal.timeout(10_000), } as RequestInit & { next: { revalidate: number } }); if (!res.ok) return null; diff --git a/lib/scrapers/rcdb.ts b/lib/scrapers/rcdb.ts index 295fd8c..b660ac5 100644 --- a/lib/scrapers/rcdb.ts +++ b/lib/scrapers/rcdb.ts @@ -28,7 +28,7 @@ const HEADERS = { export async function scrapeRcdbCoasters(rcdbId: number): Promise { const url = `${BASE}/${rcdbId}.htm`; try { - const res = await fetch(url, { headers: HEADERS }); + const res = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(15_000) }); if (!res.ok) { console.error(` RCDB ${rcdbId}: HTTP ${res.status}`); return null; diff --git a/lib/scrapers/sixflags.ts b/lib/scrapers/sixflags.ts index 8947c0d..8d00a03 100644 --- a/lib/scrapers/sixflags.ts +++ b/lib/scrapers/sixflags.ts @@ -107,12 +107,12 @@ async function fetchApi( ): Promise { const fetchOpts: RequestInit & { next?: { revalidate: number } } = { headers: HEADERS }; if (revalidate !== undefined) fetchOpts.next = { revalidate }; - const res = await fetch(url, fetchOpts); + const res = await fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(15_000) }); if (res.status === 429 || res.status === 503) { const retryAfter = res.headers.get("Retry-After"); const waitMs = retryAfter - ? parseInt(retryAfter) * 1000 + ? Math.min(parseInt(retryAfter, 10) * 1000, 5 * 60 * 1000) // cap at 5 min : BASE_BACKOFF_MS * Math.pow(2, attempt); console.log( ` [rate-limited] HTTP ${res.status} — waiting ${waitMs / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})` diff --git a/next.config.ts b/next.config.ts index 786427d..ca5a4ce 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,9 +1,34 @@ import type { NextConfig } from "next"; +const CSP = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", // Next.js requires unsafe-inline for hydration + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self' https://queue-times.com", + "frame-ancestors 'none'", +].join("; "); + const nextConfig: NextConfig = { // better-sqlite3 is a native module — must not be bundled by webpack serverExternalPackages: ["better-sqlite3"], output: "standalone", + + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "geolocation=(), microphone=(), camera=()" }, + { key: "Content-Security-Policy", value: CSP }, + ], + }, + ]; + }, }; export default nextConfig;