The session callbackUrl becomes a server-side fetch target in api.save-to-journal (POSTed with the callback bearer token, and the journal's response is reflected to the caller). The /new query-param loader already validated it, but the programmatic POST /api/sessions entry point — anonymous, since the Planner is stateless — stored it unvalidated. An attacker could make the Planner backend POST to arbitrary hosts, including 169.254.169.254 and other internal targets. - validateFetchUrl now blocks private / loopback / link-local / CGNAT / cloud-metadata hosts (IPv4, IPv6, IPv4-mapped) when an explicit allowlist isn't set. Gated on NODE_ENV=production && !E2E (the requireSecret idiom) so the dev/e2e journal-on-localhost save flow is unaffected. An explicit PLANNER_CALLBACK_ALLOWED_HOSTS still takes precedence and remains the full-closure control (it also stops DNS-name-to-private rebinding, which literal blocking does not). - POST /api/sessions now validates callbackUrl exactly as /new does. - api.save-to-journal re-validates immediately before the fetch (defense in depth: covers sessions persisted before this change and narrows the create→save rebinding window). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
70 lines
2.6 KiB
TypeScript
70 lines
2.6 KiB
TypeScript
import { data } from "react-router";
|
|
import type { Route } from "./+types/api.sessions";
|
|
import { createSession, listSessions } from "~/lib/sessions";
|
|
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
|
|
import { withDb } from "@trails-cool/db";
|
|
import type { Waypoint } from "@trails-cool/types";
|
|
import { validateFetchUrl, getCallbackAllowedHosts } from "~/lib/url-validation.server";
|
|
|
|
export async function action({ request }: Route.ActionArgs) {
|
|
if (request.method !== "POST") {
|
|
return data({ error: "Method not allowed" }, { status: 405 });
|
|
}
|
|
|
|
const body = await request.json();
|
|
const { callbackUrl, callbackToken, gpx } = body as {
|
|
callbackUrl?: string;
|
|
callbackToken?: string;
|
|
gpx?: string;
|
|
};
|
|
|
|
// callbackUrl becomes a server-side fetch target on save-to-journal,
|
|
// so an unvalidated value here is an SSRF sink. The /new loader
|
|
// already validates the query-param form; this is the programmatic
|
|
// JSON entry point and must do the same.
|
|
if (callbackUrl !== undefined) {
|
|
if (typeof callbackUrl !== "string") {
|
|
return data({ error: "callbackUrl must be a string" }, { status: 400 });
|
|
}
|
|
const v = validateFetchUrl(callbackUrl, { allowedHosts: getCallbackAllowedHosts() });
|
|
if (!v.ok) {
|
|
return data({ error: `Invalid callback URL: ${v.reason}` }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
return withDb(async () => {
|
|
const session = await createSession({ callbackUrl, callbackToken });
|
|
|
|
let initialWaypoints: Waypoint[] | undefined;
|
|
let initialNoGoAreas: Array<{ points: Array<{ lat: number; lon: number }> }> | undefined;
|
|
let initialNotes: string | undefined;
|
|
if (gpx) {
|
|
try {
|
|
const gpxData = await parseGpxAsync(gpx);
|
|
const wps = extractWaypoints(gpxData);
|
|
if (wps.length > 0) initialWaypoints = wps;
|
|
if (gpxData.noGoAreas.length > 0) initialNoGoAreas = gpxData.noGoAreas;
|
|
if (gpxData.description) initialNotes = gpxData.description;
|
|
} catch {
|
|
// Continue with empty session if GPX is invalid
|
|
}
|
|
}
|
|
|
|
return data(
|
|
{ sessionId: session.id, url: `/session/${session.id}`, initialWaypoints, initialNoGoAreas, initialNotes },
|
|
{ status: 201 },
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function loader({ request }: Route.LoaderArgs) {
|
|
return withDb(async () => {
|
|
const url = new URL(request.url);
|
|
// Accept an explicit `?limit=` but rely on listSessions to clamp
|
|
// it to a sane upper bound.
|
|
const limitParam = Number(url.searchParams.get("limit"));
|
|
const limit = Number.isFinite(limitParam) && limitParam > 0 ? limitParam : undefined;
|
|
const sessions = await listSessions(limit);
|
|
return data({ sessions });
|
|
});
|
|
}
|