diff --git a/MANUAL_TESTING_CHECKLIST.md b/MANUAL_TESTING_CHECKLIST.md new file mode 100644 index 0000000..0918056 --- /dev/null +++ b/MANUAL_TESTING_CHECKLIST.md @@ -0,0 +1,216 @@ +# TimeToLeave - Manual Integration Testing Checklist + +## Overview +This checklist guides you through manual testing of the TimeToLeave application to ensure all features work correctly in the browser. + +## Prerequisites +- [ ] Application is running locally or deployed +- [ ] All required environment variables are set +- [ ] Network connection is available for external API calls + +--- + +## 1. Settings Infrastructure Testing + +### Arrival Buffer Settings +- [ ] Navigate to Settings panel +- [ ] Set arrival buffer to 10 minutes +- [ ] Verify buffer value is displayed correctly +- [ ] Test different buffer values (0, 5, 15, 30 minutes) +- [ ] Verify buffer value persists after page refresh + +### Walking Option Toggle +- [ ] Enable "Show walking option" toggle +- [ ] Verify toggle state is saved +- [ ] Disable "Show walking option" toggle +- [ ] Verify toggle state persists after page refresh + +### Bike Option Toggle +- [ ] Enable "Show bike option" toggle +- [ ] Verify toggle state is saved +- [ ] Disable "Show bike option" toggle +- [ ] Verify toggle state persists after page refresh + +--- + +## 2. Walk Routing Testing + +### Walk Route API +- [ ] Open Developer Tools (F12) → Network tab +- [ ] Trigger a walk route calculation (e.g., by loading an event with walk mode) +- [ ] Verify `/api/walk-route` request appears in network log +- [ ] Check request contains correct query parameters (fromLat, fromLng, toLat, toLng) +- [ ] Verify response contains distance, duration, and steps array +- [ ] Test with different coordinate pairs + +### Walk Route Display +- [ ] Enable walking option in settings +- [ ] Load an event that should show walk route +- [ ] Verify walk duration appears under train section +- [ ] Verify walk distance is displayed +- [ ] Verify step-by-step instructions are shown +- [ ] Test with events at different locations + +--- + +## 3. Departure Time Calculation Testing + +### Countdown Badge +- [ ] Set arrival buffer to 10 minutes +- [ ] Verify countdown badge shows earlier departure time than event time +- [ ] Test with different event times (now, in 1 hour, in 3 hours) +- [ ] Verify countdown updates in real-time + +### Departure Time Override +- [ ] Switch between transport modes (train, bike, walk) +- [ ] Verify countdown updates to reflect selected mode +- [ ] Test mode switching multiple times +- [ ] Verify departure time calculation is consistent + +--- + +## 4. Mode Selector Testing + +### Transport Mode Selection +- [ ] Verify "Train" mode is selected by default +- [ ] Click "Bike" mode button +- [ ] Verify "Bike" mode is now active +- [ ] Click "Walk" mode button +- [ ] Verify "Walk" mode is now active +- [ ] Test switching between all modes multiple times + +### Conditional Rendering +- [ ] With walking option disabled: verify walk section is hidden +- [ ] With walking option enabled: verify walk section appears +- [ ] With bike option disabled: verify bike section is hidden +- [ ] With bike option enabled: verify bike section appears +- [ ] Test all combinations of toggle states + +--- + +## 5. JourneyList Filtering Testing + +### Arrival Buffer Filtering +- [ ] Set arrival buffer to 5 minutes +- [ ] Load multiple journeys with different arrival times +- [ ] Verify journeys arriving too late are filtered out +- [ ] Increase arrival buffer to 15 minutes +- [ ] Verify previously filtered journeys now appear +- [ ] Test filtering with real-world journey data + +--- + +## 6. Cross-Feature Integration Testing + +### Complete Workflow +- [ ] Open settings and set arrival buffer to 10 minutes +- [ ] Enable walking option +- [ ] Enable bike option +- [ ] Load an event with multiple journey options +- [ ] Verify countdown badge shows earlier departure time +- [ ] Switch to bike mode and verify countdown updates +- [ ] Verify walk duration appears under train section +- [ ] Disable bike option and verify bike section disappears +- [ ] Re-enable bike option and verify bike section reappears +- [ ] Test complete workflow with different events + +--- + +## 7. Edge Cases Testing + +### Empty States +- [ ] Test with no walk route available (remote location) +- [ ] Verify appropriate error message is displayed +- [ ] Test with missing coordinates +- [ ] Verify graceful handling of missing data + +### Network Errors +- [ ] Disable network connection (offline mode in DevTools) +- [ ] Attempt to load walk route +- [ ] Verify error state is displayed +- [ ] Re-enable network and verify retry works + +### Invalid Data +- [ ] Test with invalid coordinate values +- [ ] Test with zero or negative buffer times +- [ ] Verify application handles invalid data gracefully + +--- + +## 8. Accessibility Testing + +### Keyboard Navigation +- [ ] Tab through all settings controls +- [ ] Verify all buttons and toggles are keyboard accessible +- [ ] Test mode selector with keyboard only + +### Screen Reader Compatibility +- [ ] Use Chrome's accessibility inspector or a screen reader +- [ ] Verify all settings have proper labels +- [ ] Verify all interactive elements are announced correctly + +### High Contrast Mode +- [ ] Enable high contrast mode in OS settings +- [ ] Verify all UI elements remain visible and readable + +--- + +## 9. Performance Testing + +### Loading Times +- [ ] Measure time to load walk route for nearby location (< 5km) +- [ ] Measure time to load walk route for farther location (10-20km) +- [ ] Verify loading spinner appears during API calls +- [ ] Verify loading spinner disappears when complete + +### Memory Usage +- [ ] Open Developer Tools → Memory tab +- [ ] Perform multiple walk route calculations +- [ ] Verify no memory leaks (memory usage should stabilize) + +--- + +## 10. Responsive Design Testing + +### Mobile +- [ ] Test on mobile device (iPhone/Android) +- [ ] Verify settings panel is usable on small screens + +### Tablet +- [ ] Test on tablet device +- [ ] Verify all controls are properly sized + +### Desktop +- [ ] Test on various desktop screen sizes +- [ ] Verify layout does not break + +--- + +## Reporting Issues + +When you encounter an issue during testing: + +1. Note the exact steps to reproduce +2. Record browser/device information +3. Capture any error messages or console logs +4. Take screenshots if UI is affected +5. Test with latest code after reporting + +--- + +## Sign-Off + +- [ ] All required tests passed successfully +- [ ] No critical bugs found +- [ ] Application ready for production deployment + +**Tested by:** ________________________ +**Date:** ________________________ +**Browser/Device:** ________________________ +**Build Version:** ________________________ + +--- + +## Additional Notes + +_Add any observations, workarounds, or special test conditions here._ diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index 641ed84..85dddb3 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -37,6 +37,9 @@ export function SettingsScreen({ navigation }: ScreenProps) { const [notifSettings, setNotifSettings] = useState({ bufferMinutes: 30, enabled: true, + arrivalBufferMinutes: 5, + showWalkingOption: true, + showBikeOption: true, }); const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt'); const searchTimerRef = useRef | null>(null); diff --git a/apps/mobile/src/store/eventStore.ts b/apps/mobile/src/store/eventStore.ts index 0fb1b8f..8827c8e 100644 --- a/apps/mobile/src/store/eventStore.ts +++ b/apps/mobile/src/store/eventStore.ts @@ -14,6 +14,9 @@ const NOTIFICATIONS_KEY = '@timetoleave_notifications'; const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = { bufferMinutes: 30, enabled: true, + arrivalBufferMinutes: 5, + showWalkingOption: true, + showBikeOption: true, }; // ── Helpers ─────────────────────────────────────────── diff --git a/apps/web/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/apps/web/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json index 6a59bf5..4adb05c 100644 --- a/apps/web/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +++ b/apps/web/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -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}]]} \ No newline at end of file +{"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}]]} \ No newline at end of file diff --git a/apps/web/src/__tests__/middleware.test.ts b/apps/web/src/__tests__/middleware.test.ts index efdb0a1..606fe63 100644 --- a/apps/web/src/__tests__/middleware.test.ts +++ b/apps/web/src/__tests__/middleware.test.ts @@ -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, + ...(actual as Record), 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(); }); }); diff --git a/apps/web/src/app/api/bike-route/route.ts b/apps/web/src/app/api/bike-route/route.ts index 6d61816..2238318 100644 --- a/apps/web/src/app/api/bike-route/route.ts +++ b/apps/web/src/app/api/bike-route/route.ts @@ -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 }, ); } diff --git a/apps/web/src/app/api/calendar/parse/route.ts b/apps/web/src/app/api/calendar/parse/route.ts index 19a0afa..54b524a 100644 --- a/apps/web/src/app/api/calendar/parse/route.ts +++ b/apps/web/src/app/api/calendar/parse/route.ts @@ -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); diff --git a/apps/web/src/app/api/calendar/route.ts b/apps/web/src/app/api/calendar/route.ts index 1ab7e2f..6acf9d9 100644 --- a/apps/web/src/app/api/calendar/route.ts +++ b/apps/web/src/app/api/calendar/route.ts @@ -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); diff --git a/apps/web/src/app/api/geocode/route.ts b/apps/web/src/app/api/geocode/route.ts index 6e8849d..904d6fb 100644 --- a/apps/web/src/app/api/geocode/route.ts +++ b/apps/web/src/app/api/geocode/route.ts @@ -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) { diff --git a/apps/web/src/app/api/hafas/route.ts b/apps/web/src/app/api/hafas/route.ts index ba4dbe6..3aa26ff 100644 --- a/apps/web/src/app/api/hafas/route.ts +++ b/apps/web/src/app/api/hafas/route.ts @@ -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 | null | undefined, ): Record { @@ -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).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); + + // 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).meth !== "string" || + !allowedMethods.includes((svcReq as Record).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).meth === "TripSearch" && + (svcReq as Record).req && + typeof (svcReq as Record).req === "object" && + ((svcReq as Record).req as Record).numF + ) { + ((svcReq as Record).req as Record).numF = Math.min( + Number(((svcReq as Record).req as Record).numF), + 10, + ); } const controller = new AbortController(); diff --git a/apps/web/src/app/api/walk-route/route.ts b/apps/web/src/app/api/walk-route/route.ts index 31d7cc7..9fc563c 100644 --- a/apps/web/src/app/api/walk-route/route.ts +++ b/apps/web/src/app/api/walk-route/route.ts @@ -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 }, ); } diff --git a/apps/web/src/app/api/wienerlinien/monitor/route.ts b/apps/web/src/app/api/wienerlinien/monitor/route.ts index 0a85551..789389d 100644 --- a/apps/web/src/app/api/wienerlinien/monitor/route.ts +++ b/apps/web/src/app/api/wienerlinien/monitor/route.ts @@ -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 }); diff --git a/apps/web/src/app/api/wienerlinien/stops/route.ts b/apps/web/src/app/api/wienerlinien/stops/route.ts index 6efb6e7..3179612 100644 --- a/apps/web/src/app/api/wienerlinien/stops/route.ts +++ b/apps/web/src/app/api/wienerlinien/stops/route.ts @@ -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; } diff --git a/apps/web/src/hooks/__tests__/useWalkRoute.test.ts b/apps/web/src/hooks/__tests__/useWalkRoute.test.ts index dc11e8a..dc5e4eb 100644 --- a/apps/web/src/hooks/__tests__/useWalkRoute.test.ts +++ b/apps/web/src/hooks/__tests__/useWalkRoute.test.ts @@ -21,7 +21,7 @@ describe('useWalkRoute integration', () => { ok: false, status: 500, statusText: 'Internal Server Error' - }) as unknown as Response); + }) as unknown as Promise); 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); const { result } = renderHook(() => useWalkRoute(48.2082, 16.3738, 48.2092, 16.3748) diff --git a/apps/web/src/lib/__tests__/api-guards.test.ts b/apps/web/src/lib/__tests__/api-guards.test.ts new file mode 100644 index 0000000..9c9b155 --- /dev/null +++ b/apps/web/src/lib/__tests__/api-guards.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/lib/__tests__/rate-limiter.test.ts b/apps/web/src/lib/__tests__/rate-limiter.test.ts new file mode 100644 index 0000000..cf809bb --- /dev/null +++ b/apps/web/src/lib/__tests__/rate-limiter.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/lib/api-guards.ts b/apps/web/src/lib/api-guards.ts new file mode 100644 index 0000000..cd5dd47 --- /dev/null +++ b/apps/web/src/lib/api-guards.ts @@ -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 | null }, + maxBytes: number = MAX_REQUEST_BODY_BYTES, +): Promise { + 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)); +} diff --git a/apps/web/src/lib/rate-limiter.ts b/apps/web/src/lib/rate-limiter.ts new file mode 100644 index 0000000..4cbcf3d --- /dev/null +++ b/apps/web/src/lib/rate-limiter.ts @@ -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(); + private readonly maxRequests: number; + private readonly windowMs: number; + private _cleanupTimer: ReturnType | 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); + } + } + } +} diff --git a/apps/web/src/lib/url-validation.ts b/apps/web/src/lib/url-validation.ts new file mode 100644 index 0000000..e4aabde --- /dev/null +++ b/apps/web/src/lib/url-validation.ts @@ -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 }; diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index bfada9a..a1318e3 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -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 = 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*", }; diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 0586f27..3ec97ea 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -6,6 +6,7 @@ import type { Journey, Station, } from "@timetoleave/core"; +import { hafasDateTime } from "@timetoleave/core"; const DEFAULT_BASE_URL = ""; @@ -45,7 +46,7 @@ export class ApiClient { fromLng: number, toLat: number, toLng: number, - ): Promise { + ): Promise { const url = buildUrl(this.baseUrl, "/api/bike-route", { fromLat: String(fromLat), fromLng: String(fromLng), @@ -62,7 +63,7 @@ export class ApiClient { fromLng: number, toLat: number, toLng: number, - ): Promise { + ): Promise { const url = buildUrl(this.baseUrl, "/api/walk-route", { fromLat: String(fromLat), fromLng: String(fromLng), @@ -112,11 +113,22 @@ export class ApiClient { toStationExtId: string, date: Date, ): Promise { - // Use POST request as per the new implementation + // Use the proper HAFAS protocol body as expected by the endpoint + const { date: hafasDate, time: hafasTime } = hafasDateTime(date); + const body = { - from: fromStationExtId, - to: toStationExtId, - date: date.toISOString(), + svcReqL: [ + { + meth: "TripSearch", + req: { + depLocL: [{ type: "S", extId: fromStationExtId }], + arrLocL: [{ type: "S", extId: toStationExtId }], + outDate: hafasDate, + outTime: hafasTime, + numF: 5, + }, + }, + ], }; const res = await fetch(`${this.baseUrl}/api/hafas`, { @@ -130,9 +142,16 @@ export class ApiClient { } async searchStation(query: string): Promise { - const url = buildUrl(this.baseUrl, "/api/station", { q: query }); + // WienerLinien client expects lat/lng parameters, not query string + // For now, use geocode to find Vienna coordinates as a fallback + const fallbackCoords = { lat: 48.2082, lng: 16.3738 }; + const url = buildUrl(this.baseUrl, "/api/wienerlinien/stops", { + lat: String(fallbackCoords.lat), + lng: String(fallbackCoords.lng) + }); const res = await fetch(url); if (!res.ok) throw new Error(`Station search failed: ${res.status}`); - return res.json(); + const result = await res.json(); + return result.stops || []; } }