security: validate Planner callback URL to close an SSRF sink

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>
This commit is contained in:
Ullrich Schäfer 2026-06-10 22:18:18 +02:00
parent e64155b490
commit c3454641df
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
4 changed files with 139 additions and 0 deletions

View file

@ -4,6 +4,7 @@ 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") {
@ -17,6 +18,20 @@ export async function action({ request }: Route.ActionArgs) {
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 });