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:
parent
e64155b490
commit
c3454641df
4 changed files with 139 additions and 0 deletions
|
|
@ -16,6 +16,7 @@ import { data } from "react-router";
|
|||
import type { Route } from "./+types/api.save-to-journal";
|
||||
import { getSession } from "~/lib/sessions";
|
||||
import { fetchWithTimeout } from "~/lib/http.server";
|
||||
import { validateFetchUrl, getCallbackAllowedHosts } from "~/lib/url-validation.server";
|
||||
|
||||
interface SaveRequestBody {
|
||||
sessionId?: unknown;
|
||||
|
|
@ -51,6 +52,15 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
return data({ error: "session has no journal callback" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Defense in depth: re-validate immediately before the outbound fetch.
|
||||
// Guards sessions persisted before callbackUrl validation existed, and
|
||||
// narrows the window for a host that was public at create time but
|
||||
// resolves private now.
|
||||
const v = validateFetchUrl(session.callbackUrl, { allowedHosts: getCallbackAllowedHosts() });
|
||||
if (!v.ok) {
|
||||
return data({ error: "session callback URL is not allowed" }, { status: 400 });
|
||||
}
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetchWithTimeout(session.callbackUrl, {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue