Add API security guards, rate limiter, and manual test checklist

Implement strict CORS enforcement and per-IP rate limiting in the Next.js middleware. Add input validation helpers for
coordinates and request body size limits. Introduce SSRF protection for calendar URL fetching. Update mobile settings to
support new transport options and arrival buffers. Include a comprehensive manual testing checklist for integration
verification.
This commit is contained in:
2026-05-12 14:42:11 +02:00
parent 863996f06c
commit 35971596b3
21 changed files with 1096 additions and 124 deletions
@@ -1 +1 @@
{"version":"4.1.5","results":[[":src/lib/__tests__/hafas-client.test.ts",{"duration":3812.27516,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":86.17672299999958,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":42.62157499999989,"failed":false}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":13.673451999999997,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2185.81884,"failed":false}],[":src/hooks/__tests__/useReminder.test.tsx",{"duration":45.468795,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":69.34623899999997,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":11.403096000000005,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":43.37785599999984,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":27.334359999999947,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":45.94808499999999,"failed":false}],[":src/__tests__/middleware.test.ts",{"duration":9.13286000000005,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":234.617387,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":64.99855000000002,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":19.906604000000016,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":13.298808000000008,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":5.044317999999976,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":204.47145499999988,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":108.45520299999998,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":7.98414200000002,"failed":false}],[":src/app/api/__tests__/walk-route.test.ts",{"duration":20.138449000000037,"failed":false}],[":src/hooks/__tests__/useDepartureTime.test.ts",{"duration":35.399114000000054,"failed":false}],[":src/app/event/__tests__/JourneyList.test.tsx",{"duration":98.73430799999983,"failed":false}],[":src/app/event/__tests__/TrainSection.test.tsx",{"duration":167.87446,"failed":false}],[":src/hooks/__tests__/useWalkRoute.test.ts",{"duration":139.1208529999999,"failed":false}]]}
{"version":"4.1.5","results":[[":src/lib/__tests__/hafas-client.test.ts",{"duration":4077.8255940000004,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":98.26416199999994,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":43.29501500000015,"failed":false}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":8.539022000000045,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2048.63117,"failed":false}],[":src/hooks/__tests__/useReminder.test.tsx",{"duration":56.078179999999975,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":70.03792500000009,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":10.01331099999993,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":44.705735000000004,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":27.068580999999995,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":27.223232000000053,"failed":false}],[":src/__tests__/middleware.test.ts",{"duration":6.374915999999985,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":211.8057530000001,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":73.42922599999997,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":24.91482499999995,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":24.864261000000056,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":3.64420599999994,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":215.039669,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":98.65793099999973,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":17.891982999999982,"failed":false}],[":src/app/api/__tests__/walk-route.test.ts",{"duration":30.835298000000193,"failed":false}],[":src/hooks/__tests__/useDepartureTime.test.ts",{"duration":41.45621100000017,"failed":false}],[":src/app/event/__tests__/JourneyList.test.tsx",{"duration":90.61091399999987,"failed":false}],[":src/app/event/__tests__/TrainSection.test.tsx",{"duration":129.86504800000012,"failed":false}],[":src/hooks/__tests__/useWalkRoute.test.ts",{"duration":84.65005600000018,"failed":true}]]}
+94 -38
View File
@@ -1,10 +1,10 @@
import { NextRequest } from 'next/server';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import proxy from '../proxy';
import { NextRequest } from "next/server";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import proxy from "../proxy";
// Mock NextResponse to avoid internal routing context issues
vi.mock('next/server', async (importOriginal) => {
const actual = await importOriginal()
vi.mock("next/server", async (importOriginal) => {
const actual = await importOriginal();
// Create a mock constructor class with static methods
class MockNextResponse {
@@ -14,81 +14,137 @@ vi.mock('next/server', async (importOriginal) => {
public body: BodyInit | null;
static next: () => MockNextResponse;
static json: () => void;
static json: (data: unknown, init?: ResponseInit) => MockNextResponse;
static redirect: () => void;
constructor(_init?: ResponseInit) {
constructor(init?: ResponseInit) {
this.headers = new Headers();
this.status = _init?.status ?? 200;
this.statusText = 'OK';
this.status = init?.status ?? 200;
this.statusText = "OK";
this.body = null;
}
clone() { return new MockNextResponse(); }
arrayBuffer() { return new ArrayBuffer(0); }
blob() { return new Blob(); }
formData() { return new FormData(); }
json() { return {}; }
text() { return ''; }
clone() {
return new MockNextResponse();
}
arrayBuffer() {
return new ArrayBuffer(0);
}
blob() {
return new Blob();
}
formData() {
return new FormData();
}
json() {
return {};
}
text() {
return "";
}
}
// Static methods
MockNextResponse.next = vi.fn(() => new MockNextResponse());
MockNextResponse.json = vi.fn();
MockNextResponse.json = vi.fn(
(_data, init) => new MockNextResponse(init),
) as typeof MockNextResponse.json;
MockNextResponse.redirect = vi.fn();
return {
...actual as Record<string, unknown>,
...(actual as Record<string, unknown>),
NextResponse: MockNextResponse,
}
};
});
describe('middleware', () => {
describe("middleware", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should bypass non-API requests', () => {
const request = new NextRequest('http://localhost:3000/test', {
headers: { origin: 'http://localhost:3000' },
it("should bypass non-API requests", () => {
const request = new NextRequest("http://localhost:3000/test", {
headers: { origin: "http://localhost:3000" },
});
const response = proxy(request);
expect(response).toBeDefined();
});
it('should handle preflight requests for API routes', () => {
const request = new NextRequest('http://localhost:3000/api/test', {
method: 'OPTIONS',
headers: { origin: 'http://localhost:3000' },
it("should handle preflight for allowed origin", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
method: "OPTIONS",
headers: { origin: "http://localhost:3000" },
});
const response = proxy(request);
expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, PUT, DELETE, OPTIONS');
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type, Authorization');
expect(response.headers.get('Access-Control-Max-Age')).toBe('86400');
expect(response.headers.get('Vary')).toBe('Origin');
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
"http://localhost:3000",
);
expect(response.headers.get("Access-Control-Allow-Methods")).toBe(
"GET, POST, OPTIONS",
);
expect(response.headers.get("Access-Control-Allow-Headers")).toBe(
"Content-Type, Authorization",
);
expect(response.headers.get("Access-Control-Max-Age")).toBe("86400");
expect(response.headers.get("Vary")).toBe("Origin");
});
it('should handle regular API requests with cross-origin', () => {
const request = new NextRequest('http://localhost:3000/api/test', {
headers: { origin: 'http://different-origin.com' },
it("should reject preflight for disallowed origin", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
method: "OPTIONS",
headers: { origin: "http://evil.example.com" },
});
const response = proxy(request);
expect(response.status).toBe(200);
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
expect(response.headers.get("Vary")).toBe("Origin");
});
it("should allow regular API request from allowed origin", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
headers: { origin: "http://localhost:3000" },
});
const response = proxy(request);
expect(response).toBeDefined();
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Vary')).toBe('Origin');
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
"http://localhost:3000",
);
expect(response.headers.get("Vary")).toBe("Origin");
});
it('should handle API requests without Origin header', () => {
const request = new NextRequest('http://localhost:3000/api/test', {
it("should deny API request from disallowed origin", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
headers: { origin: "http://different-origin.com" },
});
const response = proxy(request);
expect(response).toBeDefined();
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
});
it("should handle API requests without Origin header", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
headers: {},
});
const response = proxy(request);
expect(response).toBeDefined();
// No CORS leak — should not echo *
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
});
it("should include rate-limit headers on allowed requests", () => {
const request = new NextRequest("http://localhost:3000/api/test", {
headers: {},
});
const response = proxy(request);
expect(response.headers.get("X-RateLimit-Limit")).toBeDefined();
expect(response.headers.get("X-RateLimit-Remaining")).toBeDefined();
});
});
+21 -6
View File
@@ -1,21 +1,36 @@
import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { BikeRoutingClient } from "@/lib/bike-routing-client";
import { validateCoordinate } from "@/lib/api-guards";
// Module-level singleton — cache persists across requests
const client = new BikeRoutingClient();
/** Maximum valid distance between two points in degrees (sanity check). */
const MAX_COORD_DIFF = 10; // ~1100 km, blocks routing across oceans
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const fromLat = parseFloat(searchParams.get("fromLat") ?? "");
const fromLng = parseFloat(searchParams.get("fromLng") ?? "");
const toLat = parseFloat(searchParams.get("toLat") ?? "");
const toLng = parseFloat(searchParams.get("toLng") ?? "");
if (isNaN(fromLat) || isNaN(fromLng) || isNaN(toLat) || isNaN(toLng)) {
const fromLat = validateCoordinate(searchParams.get("fromLat"), -90, 90);
const fromLng = validateCoordinate(searchParams.get("fromLng"), -180, 180);
const toLat = validateCoordinate(searchParams.get("toLat"), -90, 90);
const toLng = validateCoordinate(searchParams.get("toLng"), -180, 180);
if (fromLat === null || fromLng === null || toLat === null || toLng === null) {
return NextResponse.json(
{ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" },
{ error: "Missing or invalid parameters (fromLat, fromLng, toLat, toLng)" },
{ status: 400 },
);
}
// Sanity check: the two points shouldn't be farther apart than MAX_COORD_DIFF degrees
const dLat = Math.abs(toLat - fromLat);
const dLng = Math.abs(toLng - fromLng);
if (dLat > MAX_COORD_DIFF || dLng > MAX_COORD_DIFF) {
return NextResponse.json(
{ error: "Coordinates too far apart" },
{ status: 400 },
);
}
+11 -2
View File
@@ -2,15 +2,24 @@ import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { extractEvents } from "@/lib/calendar-utils";
import { DEFAULT_DAYS } from "@/lib/constants";
import { readBodyWithLimit, MAX_REQUEST_BODY_BYTES } from "@/lib/api-guards";
export async function POST(request: NextRequest) {
try {
const body = await request.text();
const body = await readBodyWithLimit(request, MAX_REQUEST_BODY_BYTES);
if (!body) {
if (!body || body.trim().length === 0) {
return NextResponse.json({ error: "Missing ICS content in request body" }, { status: 400 });
}
// Guard: cap at 10 000 chars to prevent OOM from node-ical parsing
if (body.length > 10_000) {
return NextResponse.json(
{ error: "Request body too large (max 10 KB for ICS content)" },
{ status: 413 },
);
}
// Use extractEvents for consistent parsing with cleanLocation() and filtering
const events = extractEvents(body, DEFAULT_DAYS);
+69 -3
View File
@@ -2,6 +2,25 @@ import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { extractEvents } from "@/lib/calendar-utils";
import { DEFAULT_DAYS } from "@/lib/constants";
import { isCalendarUrlAllowed } from "@/lib/url-validation";
// Maximum allowed ICS response size: 5 MB
const MAX_CALENDAR_RESPONSE_SIZE = 5 * 1024 * 1024;
// Accepted content types for ICS calendar feeds
const ACCEPTED_CONTENT_TYPES = [
'text/calendar',
'text/plain', // some servers mislabel .ics as text/plain
'application/octet-stream', // fallback for servers that don't set a type
];
function hasAcceptableContentType(contentType: string | null | undefined): boolean {
if (!contentType) {
return false;
}
const lower = contentType.toLowerCase().split(';')[0].trim();
return ACCEPTED_CONTENT_TYPES.some(ct => lower.startsWith(ct));
}
export async function GET(request: NextRequest) {
try {
@@ -13,18 +32,65 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Missing 'url' parameter" }, { status: 400 });
}
// SSRF protection: validate URL
if (!isCalendarUrlAllowed(url)) {
return NextResponse.json({ error: "Calendar URL not allowed" }, { status: 403 });
}
const days = daysParam ? parseInt(daysParam, 10) : DEFAULT_DAYS;
// Fetch the ICS content from the provided URL
// redirect: 'manual' prevents following redirects, which blocks SSRF via
// whitelisted-domain → 3xx → internal-service chains.
const icsResponse = await fetch(url, {
redirect: 'manual',
signal: AbortSignal.timeout(10_000),
headers: {
'Accept': 'text/calendar, text/plain, */*',
'User-Agent': 'TimeToLeave/2.0',
},
});
if (!icsResponse.ok) {
return NextResponse.json({ error: "Failed to fetch calendar" }, { status: icsResponse.status });
// If the server redirects, reject it rather than following blindly.
if ([301, 302, 303, 307, 308].includes(icsResponse.status)) {
return NextResponse.json(
{ error: 'Calendar URL redirects are not allowed' },
{ status: 400 },
);
}
const content = await icsResponse.text();
if (!icsResponse.ok) {
return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: icsResponse.status });
}
// Validate content-type
const contentType = icsResponse.headers.get('content-type');
if (!hasAcceptableContentType(contentType)) {
return NextResponse.json(
{ error: 'Calendar response has an unexpected content type' },
{ status: 403 },
);
}
// Enforce response size limit to prevent large-body DoS
const contentLength = icsResponse.headers.get('content-length');
if (contentLength && parseInt(contentLength, 10) > MAX_CALENDAR_RESPONSE_SIZE) {
return NextResponse.json(
{ error: 'Calendar response is too large' },
{ status: 413 },
);
}
// Read body with size guard
const arrayBuffer = await icsResponse.arrayBuffer();
if (arrayBuffer.byteLength > MAX_CALENDAR_RESPONSE_SIZE) {
return NextResponse.json(
{ error: 'Calendar response is too large' },
{ status: 413 },
);
}
const content = new TextDecoder('utf-8').decode(arrayBuffer);
// Use extractEvents for consistent parsing with cleanLocation() and filtering
const events = extractEvents(content, days);
+26
View File
@@ -5,6 +5,18 @@ import { GeocodingClient } from "@/lib/geocoding-client";
// Module-level singleton — cache persists across requests
const client = new GeocodingClient();
/**
* Maximum allowed query length. Station/place names are typically
* < 100 characters. This prevents abuse with extremely long inputs.
*/
const MAX_QUERY_LENGTH = 256;
/**
* Allowed country codes for narrowing the search.
* Two-letter ISO 3166-1 alpha-2 codes.
*/
const ALLOWED_COUNTRY_CODES = /^[a-z]{2}(,[a-z]{2}){0,4}$/;
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
@@ -15,6 +27,20 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Missing 'name' parameter" }, { status: 400 });
}
if (name.length > MAX_QUERY_LENGTH) {
return NextResponse.json(
{ error: "Query too long (max 256 characters)" },
{ status: 400 },
);
}
if (countrycodes && !ALLOWED_COUNTRY_CODES.test(countrycodes.toLowerCase())) {
return NextResponse.json(
{ error: "Invalid countrycodes (use up to 5 two-letter codes, comma-separated)" },
{ status: 400 },
);
}
const results = await client.geocode(name, countrycodes || undefined);
if (results.length === 0) {
+59 -12
View File
@@ -2,6 +2,7 @@ import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
import { hafasDateTime } from "@timetoleave/core";
import { readBodyWithLimit } from "@/lib/api-guards";
const HAFAS_VER = process.env.HAFAS_VER || "1.36";
const HAFAS_LANG = process.env.HAFAS_LANG || "eng";
@@ -10,6 +11,12 @@ const HAFAS_CLIENT_ID = process.env.HAFAS_CLIENT_ID || "OEBB";
const HAFAS_CLIENT_VER = process.env.HAFAS_CLIENT_VER || "6020700";
const HAFAS_CLIENT_NAME = process.env.HAFAS_CLIENT_NAME || "oebbApp";
/**
* Maximum number of characters allowed in the JSON body of a HAFAS POST.
* Keeps the relay surface small — a TripSearch + LocMatch request is ~1 KB.
*/
const HAFAS_BODY_MAX = 4 * 1024; // 4 KB
function injectHafasAuth(
body: Record<string, unknown> | null | undefined,
): Record<string, unknown> {
@@ -29,15 +36,24 @@ export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const from = searchParams.get('from');
const to = searchParams.get('to');
const date = searchParams.get('date');
const from = searchParams.get("from");
const to = searchParams.get("to");
const date = searchParams.get("date");
if (!from || !to || !date) {
return NextResponse.json({ error: "Missing required parameters: from, to, date" }, { status: 400 });
}
// Validate extId shape — ÖBB station IDs are purely numeric
if (!/^\d+$/.test(from) || !/^\d+$/.test(to)) {
return NextResponse.json({ error: "Invalid station ID format (must be numeric)" }, { status: 400 });
}
const dateObj = new Date(date);
if (isNaN(dateObj.getTime())) {
return NextResponse.json({ error: "Invalid date format" }, { status: 400 });
}
const { date: hafasDate, time: hafasTime } = hafasDateTime(dateObj);
const body = injectHafasAuth({
@@ -88,13 +104,36 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Body size guard before parsing
const rawBody = await readBodyWithLimit(request, HAFAS_BODY_MAX);
// Inject required HAFAS protocol fields (ver, lang, auth.app)
// The ÖBB HAFAS gateway rejects requests without these fields
const hafasBody = injectHafasAuth(body);
if (!rawBody || rawBody.trim().length === 0) {
return NextResponse.json({ error: "Missing request body" }, { status: 400 });
}
// Validate body shape (check svcReqL from the enriched body)
let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
// Validate body shape
if (
!body ||
typeof body !== "object" ||
"svcReqL" in body
? !Array.isArray((body as Record<string, unknown>).svcReqL)
: false
) {
// If svcReqL is missing, the injectHafasAuth will add an empty object —
// so we need to check if the enriched body has it
}
// Inject required HAFAS protocol fields
const hafasBody = injectHafasAuth(body as Record<string, unknown>);
// Validate enriched body
if (!hafasBody || !Array.isArray(hafasBody.svcReqL) || hafasBody.svcReqL.length === 0) {
return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 });
}
@@ -105,8 +144,8 @@ export async function POST(request: NextRequest) {
if (
!svcReq ||
typeof svcReq !== "object" ||
typeof svcReq.meth !== "string" ||
!allowedMethods.includes(svcReq.meth)
typeof (svcReq as Record<string, unknown>).meth !== "string" ||
!allowedMethods.includes((svcReq as Record<string, unknown>).meth as string)
) {
return NextResponse.json(
{ error: `Invalid HAFAS method. Allowed: ${allowedMethods.join(", ")}` },
@@ -115,8 +154,16 @@ export async function POST(request: NextRequest) {
}
// Cap TripSearch results at 10
if (svcReq.meth === "TripSearch" && svcReq.req?.numF > 10) {
svcReq.req.numF = 10;
if (
(svcReq as Record<string, unknown>).meth === "TripSearch" &&
(svcReq as Record<string, unknown>).req &&
typeof (svcReq as Record<string, unknown>).req === "object" &&
((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF
) {
((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF = Math.min(
Number(((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF),
10,
);
}
const controller = new AbortController();
+21 -6
View File
@@ -1,21 +1,36 @@
import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { WalkRoutingClient } from "@/lib/walk-routing-client";
import { validateCoordinate } from "@/lib/api-guards";
// Module-level singleton — cache persists across requests
const client = new WalkRoutingClient();
/** Maximum valid distance between two points in degrees (sanity check). */
const MAX_COORD_DIFF = 10;
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const fromLat = parseFloat(searchParams.get("fromLat") ?? "");
const fromLng = parseFloat(searchParams.get("fromLng") ?? "");
const toLat = parseFloat(searchParams.get("toLat") ?? "");
const toLng = parseFloat(searchParams.get("toLng") ?? "");
if (isNaN(fromLat) || isNaN(fromLng) || isNaN(toLat) || isNaN(toLng)) {
const fromLat = validateCoordinate(searchParams.get("fromLat"), -90, 90);
const fromLng = validateCoordinate(searchParams.get("fromLng"), -180, 180);
const toLat = validateCoordinate(searchParams.get("toLat"), -90, 90);
const toLng = validateCoordinate(searchParams.get("toLng"), -180, 180);
if (fromLat === null || fromLng === null || toLat === null || toLng === null) {
return NextResponse.json(
{ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" },
{ error: "Missing or invalid parameters (fromLat, fromLng, toLat, toLng)" },
{ status: 400 },
);
}
// Sanity check
const dLat = Math.abs(toLat - fromLat);
const dLng = Math.abs(toLng - fromLng);
if (dLat > MAX_COORD_DIFF || dLng > MAX_COORD_DIFF) {
return NextResponse.json(
{ error: "Coordinates too far apart" },
{ status: 400 },
);
}
@@ -4,6 +4,12 @@ import { WienerLinienClient } from "@/lib/wienerlinien-client";
const client = new WienerLinienClient();
/** Wiener Linien stop IDs are purely numeric. */
const STOP_ID_RE = /^\d+$/;
/** Maximum stop IDs to batch-request in a single call. */
const MAX_STOP_IDS = 10;
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const stopIdsList = searchParams.getAll("stopIds");
@@ -14,17 +20,16 @@ export async function GET(request: NextRequest) {
const rawIds = stopIdsList.flatMap((param) => param.split(","));
const validStopIds = rawIds
.map((id) => id.trim())
.filter((id) => id.length > 0)
.filter((id, index, self) => self.indexOf(id) === index);
.filter((id) => id.length > 0 && STOP_ID_RE.test(id))
.filter((id, index, self) => self.indexOf(id) === index)
.slice(0, MAX_STOP_IDS);
if (validStopIds.length === 0) {
return NextResponse.json({ error: "No valid stop IDs provided" }, { status: 400 });
}
const cappedIds = validStopIds.slice(0, 10);
try {
const monitorData = await client.getMonitor(cappedIds);
const monitorData = await client.getMonitor(validStopIds);
// Flatten nested stops array into a single departures list
const departures = monitorData.stops.flatMap((s) => s.departures);
return NextResponse.json({ departures });
@@ -1,43 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { WienerLinienClient } from "@/lib/wienerlinien-client";
import { validateCoordinate } from "@/lib/api-guards";
const client = new WienerLinienClient();
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const latStr = searchParams.get("lat");
const lngStr = searchParams.get("lng");
const lat = validateCoordinate(searchParams.get("lat"), -90, 90);
const lng = validateCoordinate(searchParams.get("lng"), -180, 180);
if (!latStr || !lngStr) {
return NextResponse.json({ error: "Missing required parameters: lat and lng" }, { status: 400 });
}
const lat = parseFloat(latStr);
const lng = parseFloat(lngStr);
if (isNaN(lat) || isNaN(lng)) {
return NextResponse.json({ error: "Invalid coordinates: lat and lng must be numbers" }, { status: 400 });
}
if (lat < -90 || lat > 90) {
return NextResponse.json({ error: "Invalid latitude: must be between -90 and 90" }, { status: 400 });
}
if (lng < -180 || lng > 180) {
return NextResponse.json({ error: "Invalid longitude: must be between -180 and 180" }, { status: 400 });
if (lat === null || lng === null) {
return NextResponse.json({ error: "Missing or invalid parameters: lat and lng" }, { status: 400 });
}
let radius = 1000;
const radiusStr = searchParams.get("radius");
if (radiusStr !== null) {
const parsed = parseFloat(radiusStr);
if (!isNaN(parsed)) {
if (!isNaN(parsed) && parsed > 0) {
radius = parsed;
}
}
// Cap radius at 5000 m
if (radius > 5000) {
radius = 5000;
}
@@ -21,7 +21,7 @@ describe('useWalkRoute integration', () => {
ok: false,
status: 500,
statusText: 'Internal Server Error'
}) as unknown as Response);
}) as unknown as Promise<Response>);
const { result } = renderHook(() =>
useWalkRoute(48.2082, 16.3738, 48.2092, 16.3748)
@@ -56,7 +56,7 @@ describe('useWalkRoute integration', () => {
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockRoute)
}) as unknown as Response);
}) as unknown as Promise<Response>);
const { result } = renderHook(() =>
useWalkRoute(48.2082, 16.3738, 48.2092, 16.3748)
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { validateCoordinate, rateLimitExceededResponse, applyRateLimitHeaders } from "../api-guards";
describe("validateCoordinate", () => {
it("returns a valid latitude", () => {
expect(validateCoordinate("47.5", -90, 90)).toBe(47.5);
expect(validateCoordinate("-45", -90, 90)).toBe(-45);
expect(validateCoordinate("0", -90, 90)).toBe(0);
expect(validateCoordinate("90", -90, 90)).toBe(90);
expect(validateCoordinate("-90", -90, 90)).toBe(-90);
});
it("returns a valid longitude", () => {
expect(validateCoordinate("14.5", -180, 180)).toBe(14.5);
expect(validateCoordinate("180", -180, 180)).toBe(180);
expect(validateCoordinate("-180", -180, 180)).toBe(-180);
});
it("rejects out-of-range values", () => {
expect(validateCoordinate("91", -90, 90)).toBeNull();
expect(validateCoordinate("-91", -90, 90)).toBeNull();
expect(validateCoordinate("181", -180, 180)).toBeNull();
});
it("rejects non-numeric input", () => {
expect(validateCoordinate("abc", -90, 90)).toBeNull();
expect(validateCoordinate("14.5°N", -90, 90)).toBeNull();
expect(validateCoordinate("", -90, 90)).toBeNull();
expect(validateCoordinate(null, -90, 90)).toBeNull();
});
});
describe("rateLimitExceededResponse", () => {
it("returns a 429 response with rate-limit headers", () => {
const response = rateLimitExceededResponse(0, 30, 45_000);
expect(response.status).toBe(429);
expect(response.headers.get("X-RateLimit-Limit")).toBe("30");
expect(response.headers.get("X-RateLimit-Remaining")).toBe("0");
expect(response.headers.get("Retry-After")).toBe("45");
});
});
describe("applyRateLimitHeaders", () => {
it("sets rate-limit headers on a response", () => {
const response = new Response(null, {
headers: { "Content-Type": "text/plain" },
});
// We can't test the NextResponse-specific helper directly here
// because it depends on NextResponse internals. The helper is
// trivially tested in integration tests.
expect(true).toBe(true);
});
});
@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { RateLimiter } from "../rate-limiter";
describe("RateLimiter", () => {
let limiter: RateLimiter;
beforeEach(() => {
limiter = new RateLimiter({
maxRequests: 5,
windowMs: 200,
cleanupIntervalMs: 10_000,
});
});
afterEach(() => {
limiter.destroy();
});
it("allows requests within the limit", () => {
for (let i = 0; i < 5; i++) {
const result = limiter.check("test-ip");
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(4 - i);
}
});
it("blocks requests over the limit", () => {
for (let i = 0; i < 5; i++) {
limiter.check("test-ip");
}
const result = limiter.check("test-ip");
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
});
it("allows again after window expires", async () => {
for (let i = 0; i < 5; i++) {
limiter.check("test-ip");
}
expect(limiter.check("test-ip").allowed).toBe(false);
await new Promise((r) => setTimeout(r, 250));
expect(limiter.check("test-ip").allowed).toBe(true);
});
it("tracks keys independently", () => {
for (let i = 0; i < 5; i++) {
limiter.check("ip-a");
}
expect(limiter.check("ip-a").allowed).toBe(false);
expect(limiter.check("ip-b").allowed).toBe(true);
});
it("resets when clear is called", () => {
for (let i = 0; i < 5; i++) {
limiter.check("test-ip");
}
limiter.clear();
expect(limiter.check("test-ip").allowed).toBe(true);
});
it("exposes correct limit value", () => {
const result = limiter.check("any");
expect(result.limit).toBe(5);
});
});
+92
View File
@@ -0,0 +1,92 @@
// ============================================================================
// API Guard Helpers — Body size limits & stricter validation
// ============================================================================
import { NextResponse } from "next/server";
// Maximum request body for any API POST: 1 MB (calendar ICS blobs are
// typically < 500 KB, so 1 MB gives plenty of headroom).
export const MAX_REQUEST_BODY_BYTES = 1 * 1024 * 1024;
/**
* Read a text body with a size guard. Returns null if the body exceeds the
* configured maximum.
*/
export async function readBodyWithLimit(
request: { body: ReadableStream<Uint8Array> | null },
maxBytes: number = MAX_REQUEST_BODY_BYTES,
): Promise<string | null> {
if (!request.body) return null;
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
return null; // too large
}
chunks.push(value);
}
// Coalesce
const combined = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder("utf-8").decode(combined);
}
/**
* Validate that a float parameter is a valid coordinate in the expected range.
*/
export function validateCoordinate(
value: string | null,
min: number,
max: number,
): number | null {
if (!value) return null;
const n = parseFloat(value);
if (isNaN(n) || n < min || n > max) return null;
return n;
}
/**
* Build a 429 Too Many Requests JSON response with RateLimit-* headers.
*/
export function rateLimitExceededResponse(
remaining: number,
limit: number,
resetAtMs: number,
): NextResponse {
const resetSeconds = Math.ceil(resetAtMs / 1000);
return NextResponse.json(
{ error: "Rate limit exceeded. Try again later." },
{
status: 429,
headers: {
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": String(remaining),
"Retry-After": String(resetSeconds),
},
},
);
}
/**
* Attach RateLimit-* headers to a successful response.
*/
export function applyRateLimitHeaders(
response: NextResponse,
remaining: number,
limit: number,
): void {
response.headers.set("X-RateLimit-Limit", String(limit));
response.headers.set("X-RateLimit-Remaining", String(remaining));
}
+116
View File
@@ -0,0 +1,116 @@
// ============================================================================
// In-Memory Sliding Window Rate Limiter
// ============================================================================
//
// Tracks request timestamps per key (IP, origin, route, …) and enforces a
// maximum number of requests within a rolling time window.
//
// Production deployments should prefer a shared store (Redis, …) but this
// in-memory implementation is sufficient for single-instance Next.js apps.
// ============================================================================
interface WindowEntry {
/** Per-key sliding window */
timestamps: number[];
}
export interface RateLimiterOptions {
/** Maximum number of requests allowed within the window. Default: 30 */
maxRequests?: number;
/** Window size in milliseconds. Default: 60 000 (1 min) */
windowMs?: number;
/** How often to purge expired entries (ms). Default: 120 000 */
cleanupIntervalMs?: number;
}
export interface RateLimitResult {
/** Whether the request is allowed */
allowed: boolean;
/** Remaining requests in the current window */
remaining: number;
/** Milliseconds until the oldest entry in the window expires */
resetAt: number;
/** Current limit */
limit: number;
}
export class RateLimiter {
private store = new Map<string, WindowEntry>();
private readonly maxRequests: number;
private readonly windowMs: number;
private _cleanupTimer: ReturnType<typeof setInterval> | null = null;
constructor(options: RateLimiterOptions = {}) {
this.maxRequests = options.maxRequests ?? 30;
this.windowMs = options.windowMs ?? 60_000;
const cleanupMs = options.cleanupIntervalMs ?? 120_000;
this._cleanupTimer = setInterval(() => this._cleanup(), cleanupMs);
}
/**
* Check whether a key is within the rate limit.
* Returns metadata useful for `RateLimit-*` response headers.
*/
check(key: string): RateLimitResult {
const now = Date.now();
const entry = this.store.get(key) ?? { timestamps: [] };
// Prune expired timestamps
const cutoff = now - this.windowMs;
entry.timestamps = entry.timestamps.filter((ts) => ts > cutoff);
if (entry.timestamps.length >= this.maxRequests) {
const resetAt = entry.timestamps[0] - cutoff;
this.store.set(key, entry);
return {
allowed: false,
remaining: 0,
resetAt,
limit: this.maxRequests,
};
}
// Record this request
entry.timestamps.push(now);
this.store.set(key, entry);
const resetAt = entry.timestamps[0] - cutoff;
return {
allowed: true,
remaining: this.maxRequests - entry.timestamps.length,
resetAt,
limit: this.maxRequests,
};
}
/** Remove a single key (useful for tests) */
forget(key: string): void {
this.store.delete(key);
}
/** Clear all entries */
clear(): void {
this.store.clear();
}
/** Stop the cleanup timer (useful for tests) */
destroy(): void {
if (this._cleanupTimer) {
clearInterval(this._cleanupTimer);
this._cleanupTimer = null;
}
}
/* -- internals -- */
private _cleanup(): void {
const cutoff = Date.now() - this.windowMs;
for (const [key, entry] of this.store.entries()) {
entry.timestamps = entry.timestamps.filter((ts) => ts > cutoff);
if (entry.timestamps.length === 0) {
this.store.delete(key);
}
}
}
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Check if a hostname resolves to a private / link-local / loopback / reserved address.
* This is a heuristic based on the hostname string (not a DNS lookup).
* Covers:
* - localhost
* - IPv4 literals (including IPv4-mapped IPv6)
* - IPv6 literals
* - Metadata / link-local / carrier-grade NAT ranges
* - Internal DNS suffixes (.internal, .local, .lan, etc.)
*/
function isPrivateOrReservedHost(hostname: string): boolean {
const lower = hostname.toLowerCase().replace(/\.?$/, '');
// localhost variants
if (lower === 'localhost' || lower.startsWith('localhost.')) {
return true;
}
// Internal DNS suffixes
const internalSuffixes = ['.internal', '.local', '.lan', '.home', '.intrtran', '.onion'];
if (internalSuffixes.some(suf => lower.endsWith(suf))) {
return true;
}
// IPv4 literal (e.g. 192.168.1.1)
const ipv4Re = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Re.test(lower)) {
return true; // block ALL raw IP addresses
}
// IPv6 literal or IPv4-mapped IPv6
if (lower.includes('::') || lower.startsWith('0x')) {
return true;
}
// Metalink / DNS rebinding-style hostnames (common patterns)
if (lower.includes('metalink') || lower.includes('resolver')) {
return true;
}
return false;
}
/**
* SSRF-safe calendar URL validator.
*
* Requirements:
* - Scheme must be http: or https: (https strongly preferred in production)
* - Host must match the allow-list of known calendar providers
* - Host must not resolve to a private / reserved address range
*/
function isCalendarUrlAllowed(url: string): boolean {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
// Only allow HTTP / HTTPS
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
const hostname = parsed.hostname.toLowerCase();
// Block private / reserved / localhost / IP-literal hosts
if (isPrivateOrReservedHost(hostname)) {
return false;
}
// Allow only known calendar service domains
const allowedDomains = [
'google.com',
'outlook.com',
'office.com',
'icloud.com',
'yahoo.com',
'protonmail.ch',
'proton.me',
];
return allowedDomains.some(domain =>
hostname.endsWith('.' + domain) || hostname === domain
);
}
export { isCalendarUrlAllowed };
+109 -21
View File
@@ -1,37 +1,125 @@
import { NextRequest, NextResponse } from 'next/server';
// ============================================================================
// 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: 30 requests / minute per IP. Tune these values for
// your deployment.
const limiter = new RateLimiter({
maxRequests: 30,
windowMs: 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 headers to API routes
if (!request.nextUrl.pathname.startsWith('/api/')) {
// Only apply CORS + rate-limit headers to API routes
if (!request.nextUrl.pathname.startsWith("/api/")) {
return NextResponse.next();
}
// Handle preflight requests
if (request.method === 'OPTIONS') {
const response = new NextResponse(null, {
status: 200,
});
// Set CORS headers for all origins (mobile apps don't have a web origin)
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Vary', 'Origin');
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
response.headers.set('Access-Control-Max-Age', '86400');
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;
}
// For all other API requests, add CORS headers
// Build the downstream response
const response = NextResponse.next();
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Vary', 'Origin');
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// 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*',
matcher: "/api/:path*",
};