security: add headers, fetch timeouts, Retry-After cap, env validation
All checks were successful
Build and Deploy / Build & Push (push) Successful in 3m50s
All checks were successful
Build and Deploy / Build & Push (push) Successful in 3m50s
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -167,8 +167,8 @@ export function getMonthCalendar(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STALE_AFTER_MS =
|
import { parseStalenessHours } from "./env";
|
||||||
parseInt(process.env.PARK_HOURS_STALENESS_HOURS ?? "72", 10) * 60 * 60 * 1000;
|
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.
|
* Returns true when the scraper should skip this park+month.
|
||||||
|
|||||||
13
lib/env.ts
Normal file
13
lib/env.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -44,8 +44,7 @@ export function defaultParkMeta(): ParkMeta {
|
|||||||
return { rcdb_id: null, coasters: [], coasters_scraped_at: null };
|
return { rcdb_id: null, coasters: [], coasters_scraped_at: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const COASTER_STALE_MS =
|
const COASTER_STALE_MS = parseStalenessHours(process.env.COASTER_STALENESS_HOURS, 720) * 60 * 60 * 1000;
|
||||||
parseInt(process.env.COASTER_STALENESS_HOURS ?? "720", 10) * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
/** Returns true when the coaster list needs to be re-scraped from RCDB. */
|
/** Returns true when the coaster list needs to be re-scraped from RCDB. */
|
||||||
export function areCoastersStale(entry: ParkMeta): boolean {
|
export function areCoastersStale(entry: ParkMeta): boolean {
|
||||||
@@ -55,6 +54,7 @@ export function areCoastersStale(entry: ParkMeta): boolean {
|
|||||||
|
|
||||||
import { normalizeForMatch } from "./coaster-match";
|
import { normalizeForMatch } from "./coaster-match";
|
||||||
export { normalizeForMatch as normalizeRideName } 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.
|
* Returns a Set of normalized coaster names for fast membership checks.
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export async function fetchLiveRides(
|
|||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: HEADERS,
|
headers: HEADERS,
|
||||||
next: { revalidate },
|
next: { revalidate },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
} as RequestInit & { next: { revalidate: number } });
|
} as RequestInit & { next: { revalidate: number } });
|
||||||
|
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const HEADERS = {
|
|||||||
export async function scrapeRcdbCoasters(rcdbId: number): Promise<string[] | null> {
|
export async function scrapeRcdbCoasters(rcdbId: number): Promise<string[] | null> {
|
||||||
const url = `${BASE}/${rcdbId}.htm`;
|
const url = `${BASE}/${rcdbId}.htm`;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: HEADERS });
|
const res = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(15_000) });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(` RCDB ${rcdbId}: HTTP ${res.status}`);
|
console.error(` RCDB ${rcdbId}: HTTP ${res.status}`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -107,12 +107,12 @@ async function fetchApi(
|
|||||||
): Promise<ApiResponse> {
|
): Promise<ApiResponse> {
|
||||||
const fetchOpts: RequestInit & { next?: { revalidate: number } } = { headers: HEADERS };
|
const fetchOpts: RequestInit & { next?: { revalidate: number } } = { headers: HEADERS };
|
||||||
if (revalidate !== undefined) fetchOpts.next = { revalidate };
|
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) {
|
if (res.status === 429 || res.status === 503) {
|
||||||
const retryAfter = res.headers.get("Retry-After");
|
const retryAfter = res.headers.get("Retry-After");
|
||||||
const waitMs = retryAfter
|
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);
|
: BASE_BACKOFF_MS * Math.pow(2, attempt);
|
||||||
console.log(
|
console.log(
|
||||||
` [rate-limited] HTTP ${res.status} — waiting ${waitMs / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})`
|
` [rate-limited] HTTP ${res.status} — waiting ${waitMs / 1000}s (attempt ${attempt + 1}/${MAX_RETRIES})`
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
import type { NextConfig } from "next";
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
// better-sqlite3 is a native module — must not be bundled by webpack
|
// better-sqlite3 is a native module — must not be bundled by webpack
|
||||||
serverExternalPackages: ["better-sqlite3"],
|
serverExternalPackages: ["better-sqlite3"],
|
||||||
output: "standalone",
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
Reference in New Issue
Block a user