trails/apps/planner/app/routes/session.$id.tsx
Ullrich Schäfer 51e6b8a0d7
fix(planner): validate callback/returnUrl + cap session URL-param payloads
Addresses planner audit #3 (SSRF via callbackUrl) and #7 (URL-param
size). Two attack surfaces hardened:

1. /new loader — \`callback\`, \`token\`, \`returnUrl\`, \`gpx\` query
   params now validated:
   - callbackUrl: must be a valid absolute http(s) URL ≤ 2048 chars.
     If \`PLANNER_CALLBACK_ALLOWED_HOSTS\` is set (comma-separated),
     the host must match — defense-in-depth SSRF guard for self-
     hosted instances. Unset = no allowlist (dev / open self-host).
   - token: max 2048 chars.
   - returnUrl: must be a same-origin path or absolute http(s) URL
     ≤ 2048 chars. Rejects \`javascript:\`, \`data:\`, and
     protocol-relative \`//host\` (which would resolve to a remote
     origin on HTTPS pages).
   - gpx: ≤ 2 MB encoded.
   Invalid input throws 400 from the loader.

2. /session/:id default-export component — \`waypoints\`, \`noGoAreas\`,
   \`notes\`, \`returnUrl\` URL params now bounded before
   \`JSON.parse\` / use:
   - waypoints / noGoAreas: ≤ 50KB each; over-cap returns undefined
     (component starts with empty initial state, same as malformed).
   - notes: ≤ 10KB.
   - returnUrl: ≤ 2KB + same scheme rules as #1.

Pulled the URL validation into \`lib/url-validation.server.ts\` so
both routes (and any future caller) share the same rules.

Tests: \`url-validation.server.test.ts\` (14 cases — schemes,
allowlist, length caps, protocol-relative guards, env parsing).

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:05:47 +02:00

104 lines
3.7 KiB
TypeScript

import { useParams, useSearchParams } from "react-router";
import type { Route } from "./+types/session.$id";
import { getSession } from "~/lib/sessions";
import { data } from "react-router";
import { withDb } from "@trails-cool/db";
import { ClientOnly } from "~/components/ClientOnly";
import { lazy, Suspense } from "react";
const SessionView = lazy(() =>
import("~/components/SessionView").then((m) => ({ default: m.SessionView })),
);
export function meta(_args: Route.MetaArgs) {
return [{ title: "trails.cool Planner — Session" }];
}
export async function loader({ params }: Route.LoaderArgs) {
return withDb(async () => {
const session = await getSession(params.id);
if (!session) {
throw data({ error: "Session not found" }, { status: 404 });
}
return data({
sessionId: session.id,
callbackUrl: session.callbackUrl ?? null,
callbackToken: session.callbackToken ?? null,
});
});
}
// URL-param caps prevent a hostile link from feeding multi-MB JSON
// into JSON.parse on the client (and from there into the Yjs doc).
// Picked generously enough to accept realistic multi-day routes
// (~hundreds of waypoints) but small enough to refuse abuse.
const MAX_WAYPOINTS_LEN = 50_000;
const MAX_NOGO_LEN = 50_000;
const MAX_NOTES_LEN = 10_000;
const MAX_RETURN_URL_LEN = 2048;
function safeParseJson<T>(raw: string, maxLen: number): T | undefined {
if (raw.length > maxLen) return undefined;
try { return JSON.parse(raw) as T; } catch { return undefined; }
}
function safeReturnUrl(raw: string | null): string | undefined {
if (!raw || raw.length > MAX_RETURN_URL_LEN) return undefined;
// Same-origin relative path or http(s) absolute URL only. Rejects
// `javascript:` and protocol-relative `//host` so the value is safe
// to drop into an `<a href>`.
if (raw.startsWith("/") && !raw.startsWith("//")) return raw;
try {
const parsed = new URL(raw);
if (parsed.protocol === "http:" || parsed.protocol === "https:") return raw;
} catch { /* fall through */ }
return undefined;
}
export default function SessionPage({ loaderData }: Route.ComponentProps) {
const { id } = useParams();
const [searchParams] = useSearchParams();
const returnUrl = safeReturnUrl(searchParams.get("returnUrl"));
const waypointsParam = searchParams.get("waypoints");
const initialWaypoints = waypointsParam
? safeParseJson<Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }>>(waypointsParam, MAX_WAYPOINTS_LEN)
: undefined;
const noGoParam = searchParams.get("noGoAreas");
const initialNoGoAreas = noGoParam
? safeParseJson<Array<{ points: Array<{ lat: number; lon: number }> }>>(noGoParam, MAX_NOGO_LEN)
: undefined;
const notesRaw = searchParams.get("notes");
const initialNotes = notesRaw && notesRaw.length <= MAX_NOTES_LEN ? notesRaw : undefined;
return (
<div className="flex h-full flex-col">
<ClientOnly
fallback={
<div className="flex h-full items-center justify-center">
<p className="text-gray-500">Loading planner...</p>
</div>
}
>
{() => (
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<p className="text-gray-500">Loading planner...</p>
</div>
}
>
<SessionView
sessionId={id!}
callbackUrl={loaderData.callbackUrl ?? undefined}
callbackToken={loaderData.callbackToken ?? undefined}
returnUrl={returnUrl}
initialWaypoints={initialWaypoints}
initialNoGoAreas={initialNoGoAreas}
initialNotes={initialNotes}
/>
</Suspense>
)}
</ClientOnly>
</div>
);
}