Files
time_to_leave/apps/web/src/proxy.ts
T
fegger 09b5e7725d Refactor departure time calculation to account for walk duration
Introduce `trainWalkDurationSeconds` in `useDepartureTime` hooks for both
mobile and web apps to filter train journeys based on total arrival time
including walking.

Add default origin station constants in core package and use them in mobile
store instead of returning null when no origin is saved.

Normalize HAFAS coordinates in destination station hooks to handle
large integer values.

Update `cleanLocation` to preserve full addresses with commas and remove
the 10KB request body limit on ICS parsing to support larger calendar
files.

Make rate limiting configurable via environment variables to handle
higher API fan-out from calendar event pages.
2026-05-14 12:17:09 +02:00

129 lines
4.2 KiB
TypeScript

// ============================================================================
// Next.js Middleware — Strict CORS + Per-IP Rate Limiting
// ============================================================================
//
// Replaces the previous wildcard-CORS middleware. Only origins listed in the
// `CORS_ALLOWED_ORIGINS` environment variable are permitted. An in-memory
// sliding-window rate limiter enforces a global per-IP cap on /api/ routes.
// ============================================================================
import { NextRequest, NextResponse } from "next/server";
import { RateLimiter } from "@/lib/rate-limiter";
// ---------- Configuration ----------
// Comma-separated list of allowed origins from env; falls back to localhost
// for local development when the variable is not set.
const rawOrigins =
typeof process.env.CORS_ALLOWED_ORIGINS === "string"
? process.env.CORS_ALLOWED_ORIGINS
: "";
const allowedOrigins: Set<string> = new Set(
rawOrigins
? rawOrigins.split(",").map((s) => s.trim()).filter(Boolean)
: ["http://localhost:3000"], // dev fallback
);
// Rate limiter: tune through env for deployment. Calendar pages legitimately
// fan out across geocode, routing, transit, and nearby-stop APIs per event.
const rateLimitMaxRequests = Number.parseInt(process.env.API_RATE_LIMIT_MAX_REQUESTS ?? "120", 10);
const rateLimitWindowMs = Number.parseInt(process.env.API_RATE_LIMIT_WINDOW_MS ?? "60000", 10);
const limiter = new RateLimiter({
maxRequests: Number.isFinite(rateLimitMaxRequests) ? rateLimitMaxRequests : 120,
windowMs: Number.isFinite(rateLimitWindowMs) ? rateLimitWindowMs : 60_000,
});
// ---------- Helpers ----------
/** Resolve the real client IP from common proxy headers. */
function getClientIp(request: NextRequest): string {
// X-Forwarded-For may contain a chain: "client, proxy1, proxy2"
const xff = request.headers.get("x-forwarded-for");
if (xff) {
return xff.split(",")[0].trim();
}
const xri = request.headers.get("x-real-ip");
if (xri) {
return xri.trim();
}
// Fallback — works when there is no reverse proxy
return (request as NextRequest & { ip?: string }).ip ?? "unknown";
}
function buildCorsHeaders(origin: string | null): HeadersInit {
if (!origin || !allowedOrigins.has(origin)) {
return { Vary: "Origin" };
}
return {
"Access-Control-Allow-Origin": origin,
Vary: "Origin",
};
}
// ---------- Middleware ----------
export default function proxy(request: NextRequest) {
// Only apply CORS + rate-limit headers to API routes
if (!request.nextUrl.pathname.startsWith("/api/")) {
return NextResponse.next();
}
const origin = request.headers.get("origin") ?? null;
// Preflight
if (request.method === "OPTIONS") {
const response = new NextResponse(null, { status: 200 });
Object.entries(buildCorsHeaders(origin)).forEach(([k, v]) =>
response.headers.set(k, v),
);
response.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, OPTIONS",
);
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
response.headers.set("Access-Control-Max-Age", "86400");
return response;
}
// Build the downstream response
const response = NextResponse.next();
// Attach CORS headers (only for allowed origins)
Object.entries(buildCorsHeaders(origin)).forEach(([k, v]) =>
response.headers.set(k, v),
);
// Rate limiting (skip for OPTIONS — already handled above)
if (request.method !== "OPTIONS") {
const clientIp = getClientIp(request);
const result = limiter.check(`ip:${clientIp}`);
if (!result.allowed) {
const denied = NextResponse.json(
{ error: "Rate limit exceeded. Try again later." },
{
status: 429,
headers: {
"X-RateLimit-Limit": String(result.limit),
"X-RateLimit-Remaining": "0",
"Retry-After": String(Math.ceil(result.resetAt / 1000)),
Vary: "Origin",
},
},
);
return denied;
}
response.headers.set("X-RateLimit-Limit", String(result.limit));
response.headers.set("X-RateLimit-Remaining", String(result.remaining));
}
return response;
}
export const config = {
matcher: "/api/:path*",
};