diff --git a/apps/journal/app/lib/demo-bot.server.ts b/apps/journal/app/lib/demo-bot.server.ts index 8fa1cf6..f99f952 100644 --- a/apps/journal/app/lib/demo-bot.server.ts +++ b/apps/journal/app/lib/demo-bot.server.ts @@ -433,10 +433,37 @@ export type BrouterError = | { kind: "timeout" } | { kind: "upstream-error"; status?: number }; +/** + * Create a throwaway planner session we can cite as the auth on the + * subsequent `/api/route` call. The planner session-binds its proxies + * so anonymous traffic doesn't pile up behind our Origin; bot traffic + * needs to look the same as browser traffic. Returned ids are cleaned + * up by the planner's `expire-sessions` cron (7d window) — we don't + * close them ourselves. + */ +async function createPlannerSession(signal?: AbortSignal): Promise { + try { + const resp = await fetch(`${PLANNER_URL}/api/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + signal, + }); + if (!resp.ok) return null; + const payload = (await resp.json()) as { sessionId?: string }; + return payload.sessionId ?? null; + } catch { + return null; + } +} + export async function requestBrouterGpx( endpoints: Endpoints, { signal }: { signal?: AbortSignal } = {}, ): Promise { + const sessionId = await createPlannerSession(signal); + if (!sessionId) return { kind: "upstream-error" }; + const url = `${PLANNER_URL}/api/route`; const body = { waypoints: [ @@ -445,6 +472,7 @@ export async function requestBrouterGpx( ], profile: "trekking", format: "gpx" as const, + sessionId, }; try { const resp = await fetch(url, { diff --git a/apps/journal/app/routes/api.v1.routes.compute.ts b/apps/journal/app/routes/api.v1.routes.compute.ts index cd111ff..25b623b 100644 --- a/apps/journal/app/routes/api.v1.routes.compute.ts +++ b/apps/journal/app/routes/api.v1.routes.compute.ts @@ -16,11 +16,29 @@ export async function action({ request }: Route.ActionArgs) { zodIssuesToFieldErrors(parsed.error)); } + // The planner session-binds its /api/route proxy. Mint a throwaway + // session we can cite as the auth on the forwarded call; planner's + // expire-sessions cron tidies it up. + let sessionId: string | null = null; + try { + const sessResp = await fetch(`${PLANNER_URL}/api/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + if (sessResp.ok) { + const payload = (await sessResp.json()) as { sessionId?: string }; + sessionId = payload.sessionId ?? null; + } + } catch { + /* fall through — sessionId stays null and the fetch below will 401 */ + } + try { const resp = await fetch(`${PLANNER_URL}/api/route`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(parsed.data), + body: JSON.stringify({ ...parsed.data, sessionId }), }); if (!resp.ok) { return apiError(resp.status === 422 ? 422 : 502, ERROR_CODES.INTERNAL_ERROR, `Planner returned ${resp.status}`); diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index 059abbb..bb64215 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -88,6 +88,7 @@ function getWaypointsFromYjs(waypoints: Y.Array>): WaypointData[] interface PlannerMapProps { yjs: YjsState; + sessionId: string; onRouteRequest?: (waypoints: WaypointData[]) => void; onImportError?: (message: string) => void; highlightPosition?: [number, number] | null; @@ -362,10 +363,10 @@ function PoiRefresher({ poiState }: { poiState: ReturnType }) { return null; } -export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlightedWaypoint, onRouteHover, onImportError, days }: PlannerMapProps) { +export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, highlightedWaypoint, onRouteHover, onImportError, days }: PlannerMapProps) { const { t } = useTranslation("planner"); const [waypoints, setWaypoints] = useState([]); - const poiState = usePois(); + const poiState = usePois(sessionId); useProfileDefaults(yjs, poiState); useYjsPoiSync(yjs, poiState); const [enabledOverlays, setEnabledOverlays] = useState([]); diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 0d51754..98783ee 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -160,7 +160,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, const { t } = useTranslation("planner"); useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]); const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas, initialNotes); - const { computing, routeError, routeStats, requestRoute } = useRouting(yjs); + const { computing, routeError, routeStats, requestRoute } = useRouting(yjs, sessionId); const { canUndo, canRedo, undo, redo } = useUndo(yjs?.undoManager ?? null); useUndoShortcuts(yjs?.undoManager ?? null); const days = useDays(yjs); @@ -286,7 +286,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, } > - addToast(msg, "error")} days={days} /> + addToast(msg, "error")} days={days} /> @@ -305,7 +305,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, - + {toasts.length > 0 && (
{toasts.map((toast) => ( diff --git a/apps/planner/app/components/YjsDebugPanel.tsx b/apps/planner/app/components/YjsDebugPanel.tsx index 98a7b06..d788b95 100644 --- a/apps/planner/app/components/YjsDebugPanel.tsx +++ b/apps/planner/app/components/YjsDebugPanel.tsx @@ -76,7 +76,7 @@ const dangerBtnClass = * - Actions: reset session, refetch route, become host, kick users * - State inspector: awareness, waypoints, route data, doc stats */ -export function YjsDebugPanel({ yjs }: { yjs: YjsState }) { +export function YjsDebugPanel({ yjs, sessionId }: { yjs: YjsState; sessionId: string }) { const [visible, setVisible] = useState(() => loadBool("trails:debug", import.meta.env.DEV)); const [expanded, setExpanded] = useState(() => loadBool("trails:debug:expanded", false)); const [state, setState] = useState(null); @@ -142,14 +142,14 @@ export function YjsDebugPanel({ yjs }: { yjs: YjsState }) { const response = await fetch("/api/route", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ waypoints, profile }), + body: JSON.stringify({ waypoints, profile, sessionId }), }); if (response.ok) { const geojson = await response.json(); yjs.routeData.set("geojson", JSON.stringify(geojson)); } - }, [yjs]); + }, [yjs, sessionId]); const kickUser = useCallback( (clientId: number) => { diff --git a/apps/planner/app/lib/overpass.ts b/apps/planner/app/lib/overpass.ts index ac04eaa..d6bc543 100644 --- a/apps/planner/app/lib/overpass.ts +++ b/apps/planner/app/lib/overpass.ts @@ -134,6 +134,7 @@ export function deduplicateById(pois: Poi[]): Poi[] { export async function queryPois( bbox: BBox, categories: PoiCategory[], + sessionId: string, signal?: AbortSignal, ): Promise { if (categories.length === 0) return []; @@ -142,7 +143,12 @@ export async function queryPois( const response = await fetch(OVERPASS_PROXY, { method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + // Bind this call to the active planner session so the proxy + // isn't anonymously reachable. + "X-Trails-Session": sessionId, + }, body: `data=${encodeURIComponent(query)}`, signal, }); diff --git a/apps/planner/app/lib/require-session.test.ts b/apps/planner/app/lib/require-session.test.ts new file mode 100644 index 0000000..ffd909d --- /dev/null +++ b/apps/planner/app/lib/require-session.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("./sessions.ts", () => ({ + getSession: vi.fn(), +})); + +import { requireSession } from "./require-session.ts"; +import { getSession } from "./sessions.ts"; + +const mockedGetSession = vi.mocked(getSession); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("requireSession", () => { + it("returns the session when it exists and is open", async () => { + mockedGetSession.mockResolvedValueOnce({ id: "abc" } as never); + const result = await requireSession("abc"); + expect(result).toEqual({ id: "abc" }); + expect(mockedGetSession).toHaveBeenCalledWith("abc"); + }); + + it("returns 401 when the id is missing", async () => { + const result = await requireSession(undefined); + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(401); + expect(mockedGetSession).not.toHaveBeenCalled(); + }); + + it("returns 401 for empty-string id (doesn't look it up)", async () => { + const result = await requireSession(""); + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(401); + expect(mockedGetSession).not.toHaveBeenCalled(); + }); + + it("returns 401 for a non-string id", async () => { + const result = await requireSession(null); + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(401); + expect(mockedGetSession).not.toHaveBeenCalled(); + }); + + it("returns 401 when the session doesn't exist (or is closed)", async () => { + mockedGetSession.mockResolvedValueOnce(undefined); + const result = await requireSession("nonexistent"); + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(401); + }); +}); diff --git a/apps/planner/app/lib/require-session.ts b/apps/planner/app/lib/require-session.ts new file mode 100644 index 0000000..49e8ba7 --- /dev/null +++ b/apps/planner/app/lib/require-session.ts @@ -0,0 +1,25 @@ +import { getSession, type SessionMetadata } from "./sessions.ts"; + +/** + * Gate a proxy endpoint on the presence of a valid planner session. + * Returns the session row if `id` names an open session, or a 401 + * Response for the caller to return otherwise. Callers: + * + * const session = await requireSession(sessionId); + * if (session instanceof Response) return session; + * + * The session timeout is handled by the `expire-sessions` cron; a + * missing or closed row is treated the same way here. + */ +export async function requireSession( + id: string | null | undefined, +): Promise { + if (!id || typeof id !== "string") { + return new Response("Missing session", { status: 401 }); + } + const session = await getSession(id); + if (!session) { + return new Response("Unknown or closed session", { status: 401 }); + } + return session; +} diff --git a/apps/planner/app/lib/use-pois.ts b/apps/planner/app/lib/use-pois.ts index 295133c..b016a0a 100644 --- a/apps/planner/app/lib/use-pois.ts +++ b/apps/planner/app/lib/use-pois.ts @@ -20,7 +20,7 @@ export interface PoiState { refresh: (bbox: BBox, zoom: number) => void; } -export function usePois(): PoiState { +export function usePois(sessionId: string): PoiState { const [pois, setPois] = useState([]); const [status, setStatus] = useState("idle"); const [enabledCategories, setEnabledCategories] = useState([]); @@ -76,7 +76,7 @@ export function usePois(): PoiState { lastRequestRef.current = Date.now(); try { - const result = await queryPois(bbox, categories, controller.signal); + const result = await queryPois(bbox, categories, sessionId, controller.signal); if (controller.signal.aborted) return; setCached(bbox, categoriesKey, result); @@ -98,7 +98,7 @@ export function usePois(): PoiState { } }, delay); }, - [enabledCategories], + [enabledCategories, sessionId], ); // Cleanup on unmount diff --git a/apps/planner/app/lib/use-routing.ts b/apps/planner/app/lib/use-routing.ts index 61ad504..bb4eb37 100644 --- a/apps/planner/app/lib/use-routing.ts +++ b/apps/planner/app/lib/use-routing.ts @@ -43,7 +43,7 @@ function restoreWaypoints(yjs: YjsState, snapshot: WaypointData[], restoringRef: export type RouteError = "no_route" | "failed" | "rate_limit" | null; -export function useRouting(yjs: YjsState | null) { +export function useRouting(yjs: YjsState | null, sessionId: string) { const [isHost, setIsHost] = useState(false); const [computing, setComputing] = useState(false); const [routeError, setRouteError] = useState(null); @@ -93,6 +93,7 @@ export function useRouting(yjs: YjsState | null) { waypoints, profile: (yjs.routeData.get("profile") as string) ?? "fastbike", noGoAreas: noGoAreas.length > 0 ? noGoAreas : undefined, + sessionId, }), }); diff --git a/apps/planner/app/routes/api.overpass.ts b/apps/planner/app/routes/api.overpass.ts index b1cb400..fda4e83 100644 --- a/apps/planner/app/routes/api.overpass.ts +++ b/apps/planner/app/routes/api.overpass.ts @@ -1,5 +1,6 @@ import type { Route } from "./+types/api.overpass"; import { checkRateLimit } from "~/lib/rate-limit"; +import { requireSession } from "~/lib/require-session"; import { overpassCacheEvents, overpassCacheSize, @@ -169,6 +170,11 @@ export async function action({ request }: Route.ActionArgs) { return new Response("Forbidden", { status: 403 }); } + // Session-bind: every proxy call must present a live planner session, + // so anonymous abuse traffic can't ride on our trails.cool Origin. + const session = await requireSession(request.headers.get("x-trails-session")); + if (session instanceof Response) return session; + const body = await request.text(); const cacheKey = body; diff --git a/apps/planner/app/routes/api.route.ts b/apps/planner/app/routes/api.route.ts index 35ceefe..a57c272 100644 --- a/apps/planner/app/routes/api.route.ts +++ b/apps/planner/app/routes/api.route.ts @@ -2,6 +2,7 @@ import { data } from "react-router"; import type { Route } from "./+types/api.route"; import { computeRoute, computeSegmentGpx, BRouterError } from "~/lib/brouter"; import { checkRateLimit } from "~/lib/rate-limit"; +import { requireSession } from "~/lib/require-session"; export async function action({ request }: Route.ActionArgs) { if (request.method !== "POST") { @@ -21,9 +22,13 @@ export async function action({ request }: Route.ActionArgs) { return data({ error: "At least 2 waypoints are required" }, { status: 400 }); } - // Rate limit by session ID or IP - const rateLimitKey = sessionId ?? request.headers.get("x-forwarded-for") ?? "unknown"; - const limit = checkRateLimit(`route:${rateLimitKey}`); + // Session-bind: only live planner sessions can compute routes through + // us, so we don't act as an anonymous BRouter proxy for scrapers. + const session = await requireSession(sessionId); + if (session instanceof Response) return session; + + // Rate limit by session ID (always present after requireSession) + const limit = checkRateLimit(`route:${session.id}`); if (!limit.allowed) { return data( diff --git a/e2e/integration.test.ts b/e2e/integration.test.ts index 95beb26..4834e9d 100644 --- a/e2e/integration.test.ts +++ b/e2e/integration.test.ts @@ -59,7 +59,18 @@ test.describe("Integration: Journal ↔ Planner handoff", () => { }); test.describe("Integration: BRouter routing", () => { + // Helper: mint a planner session so our /api/route calls satisfy + // the session-bound gate introduced alongside this test file. + async function createSessionId( + request: import("@playwright/test").APIRequestContext, + ): Promise { + const resp = await request.post(`${PLANNER}/api/sessions`, { data: {} }); + const payload = (await resp.json()) as { sessionId: string }; + return payload.sessionId; + } + test("computes route between Berlin waypoints", async ({ request }) => { + const sessionId = await createSessionId(request); const response = await request.post(`${PLANNER}/api/route`, { data: { waypoints: [ @@ -67,6 +78,7 @@ test.describe("Integration: BRouter routing", () => { { lat: 52.515, lon: 13.351 }, ], profile: "trekking", + sessionId, }, }); expect(response.ok()).toBeTruthy(); @@ -77,6 +89,7 @@ test.describe("Integration: BRouter routing", () => { }); test("routes through all waypoints (segment by segment)", async ({ request }) => { + const sessionId = await createSessionId(request); const response = await request.post(`${PLANNER}/api/route`, { data: { waypoints: [ @@ -85,6 +98,7 @@ test.describe("Integration: BRouter routing", () => { { lat: 52.510, lon: 13.390 }, ], profile: "trekking", + sessionId, }, }); expect(response.ok()).toBeTruthy(); @@ -99,27 +113,32 @@ test.describe("Integration: BRouter routing", () => { }); test("returns rate limit headers", async ({ request }) => { + const sessionId = await createSessionId(request); const response = await request.post(`${PLANNER}/api/route`, { data: { waypoints: [ { lat: 52.516, lon: 13.377 }, { lat: 52.515, lon: 13.351 }, ], + sessionId, }, }); expect(response.headers()["x-ratelimit-remaining"]).toBeDefined(); }); test("rejects with fewer than 2 waypoints", async ({ request }) => { + const sessionId = await createSessionId(request); const response = await request.post(`${PLANNER}/api/route`, { data: { waypoints: [{ lat: 52.516, lon: 13.377 }], + sessionId, }, }); expect(response.status()).toBe(400); }); test("returns enriched route with segment boundaries", async ({ request }) => { + const sessionId = await createSessionId(request); const response = await request.post(`${PLANNER}/api/route`, { data: { waypoints: [ @@ -128,6 +147,7 @@ test.describe("Integration: BRouter routing", () => { { lat: 52.510, lon: 13.390 }, ], profile: "trekking", + sessionId, }, }); expect(response.ok()).toBeTruthy(); @@ -146,6 +166,7 @@ test.describe("Integration: BRouter routing", () => { }); test("accepts no-go areas parameter", async ({ request }) => { + const sessionId = await createSessionId(request); const response = await request.post(`${PLANNER}/api/route`, { data: { waypoints: [ @@ -153,6 +174,7 @@ test.describe("Integration: BRouter routing", () => { { lat: 52.515, lon: 13.351 }, ], profile: "trekking", + sessionId, noGoAreas: [ { points: [ @@ -170,4 +192,28 @@ test.describe("Integration: BRouter routing", () => { expect(enriched.geojson.features).toHaveLength(1); expect(enriched.geojson.features[0].geometry.type).toBe("LineString"); }); + + test("rejects /api/route without a sessionId (session-bound)", async ({ request }) => { + const response = await request.post(`${PLANNER}/api/route`, { + data: { + waypoints: [ + { lat: 52.516, lon: 13.377 }, + { lat: 52.515, lon: 13.351 }, + ], + profile: "trekking", + }, + }); + expect(response.status()).toBe(401); + }); + + test("rejects /api/overpass without an X-Trails-Session header", async ({ request }) => { + const response = await request.post(`${PLANNER}/api/overpass`, { + data: "data=[out:json];node[amenity=drinking_water](52.52,13.4,52.53,13.41);out;", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Origin: PLANNER, + }, + }); + expect(response.status()).toBe(401); + }); });