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>
This commit is contained in:
Ullrich Schäfer 2026-05-26 00:05:47 +02:00
parent e35a8e27c8
commit 51e6b8a0d7
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
4 changed files with 244 additions and 10 deletions

View file

@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
validateFetchUrl,
validateRedirectUrl,
getCallbackAllowedHosts,
} from "./url-validation.server.ts";
describe("validateFetchUrl", () => {
it("accepts a plain https URL", () => {
expect(validateFetchUrl("https://journal.trails.cool/api/cb").ok).toBe(true);
});
it("rejects javascript: scheme", () => {
const r = validateFetchUrl("javascript:alert(1)");
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/scheme/);
});
it("rejects file: scheme", () => {
expect(validateFetchUrl("file:///etc/passwd").ok).toBe(false);
});
it("rejects relative paths (must be absolute)", () => {
expect(validateFetchUrl("/foo/bar").ok).toBe(false);
});
it("rejects malformed input", () => {
expect(validateFetchUrl("not a url").ok).toBe(false);
});
it("rejects oversized input", () => {
expect(validateFetchUrl("https://" + "x".repeat(3000) + ".test").ok).toBe(false);
});
it("enforces the host allowlist when provided", () => {
const allowed = ["journal.trails.cool"];
expect(validateFetchUrl("https://journal.trails.cool/x", { allowedHosts: allowed }).ok).toBe(true);
expect(validateFetchUrl("https://evil.example/x", { allowedHosts: allowed }).ok).toBe(false);
});
it("ignores the allowlist when it's empty/undefined", () => {
expect(validateFetchUrl("https://random.example/x").ok).toBe(true);
expect(validateFetchUrl("https://random.example/x", { allowedHosts: [] }).ok).toBe(true);
});
});
describe("validateRedirectUrl", () => {
it("accepts an absolute https URL", () => {
expect(validateRedirectUrl("https://trails.cool/r/123").ok).toBe(true);
});
it("accepts a same-origin relative path", () => {
expect(validateRedirectUrl("/routes/abc").ok).toBe(true);
});
it("rejects javascript: scheme", () => {
expect(validateRedirectUrl("javascript:alert(1)").ok).toBe(false);
});
it("rejects protocol-relative //host URLs", () => {
// `<a href="//evil.example">` would resolve to https://evil.example
// when the page is on HTTPS. Explicitly reject to keep the
// "same-origin path" branch tight.
expect(validateRedirectUrl("//evil.example/x").ok).toBe(false);
});
});
describe("getCallbackAllowedHosts", () => {
beforeEach(() => {
vi.unstubAllEnvs();
});
it("returns undefined when the env is unset", () => {
delete process.env.PLANNER_CALLBACK_ALLOWED_HOSTS;
expect(getCallbackAllowedHosts()).toBeUndefined();
});
it("splits, trims, and filters empty entries", () => {
vi.stubEnv("PLANNER_CALLBACK_ALLOWED_HOSTS", "a.test , b.test,, c.test ");
expect(getCallbackAllowedHosts()).toEqual(["a.test", "b.test", "c.test"]);
});
});

View file

@ -0,0 +1,91 @@
// Shared validation for query-param URLs that flow into either an
// outbound fetch (callbackUrl) or a rendered link (returnUrl).
// Keeps the new-session and session-detail loaders honest without
// scattering ad-hoc string checks.
const SAFE_SCHEMES = new Set(["http:", "https:"]);
/**
* Result type so callers can decide whether to reject the request or
* just drop the value (e.g. returnUrl is optional UX; callbackUrl is
* load-bearing).
*/
export interface UrlValidationResult {
ok: boolean;
reason?: string;
url?: URL;
}
/**
* Validate a URL string for use as a fetch target. Requires absolute
* http(s) URL. If `allowedHosts` is provided, the host must be on the
* list matches behavior of standard "open redirect" allowlists.
*/
export function validateFetchUrl(
raw: string,
opts: { allowedHosts?: string[]; maxLength?: number } = {},
): UrlValidationResult {
const maxLength = opts.maxLength ?? 2048;
if (raw.length > maxLength) {
return { ok: false, reason: `url exceeds ${maxLength} chars` };
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return { ok: false, reason: "not a valid absolute URL" };
}
if (!SAFE_SCHEMES.has(parsed.protocol)) {
return { ok: false, reason: `disallowed scheme ${parsed.protocol}` };
}
if (opts.allowedHosts && opts.allowedHosts.length > 0) {
if (!opts.allowedHosts.includes(parsed.host)) {
return { ok: false, reason: `host ${parsed.host} not on allowlist` };
}
}
return { ok: true, url: parsed };
}
/**
* Validate a URL string for use as a rendered `<a href>`. Accepts
* either a same-origin relative path (starts with `/` and doesn't
* start with `//`) or an absolute http(s) URL. The browser still
* follows the link, so the goal is to refuse `javascript:` and
* `data:` schemes which would execute on click.
*/
export function validateRedirectUrl(
raw: string,
opts: { maxLength?: number } = {},
): UrlValidationResult {
const maxLength = opts.maxLength ?? 2048;
if (raw.length > maxLength) {
return { ok: false, reason: `url exceeds ${maxLength} chars` };
}
// Same-origin relative path. `//host` would be protocol-relative and
// bypass the scheme check, so reject those explicitly.
if (raw.startsWith("/") && !raw.startsWith("//")) {
return { ok: true };
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return { ok: false, reason: "not a valid absolute or relative URL" };
}
if (!SAFE_SCHEMES.has(parsed.protocol)) {
return { ok: false, reason: `disallowed scheme ${parsed.protocol}` };
}
return { ok: true, url: parsed };
}
/**
* Parse the `PLANNER_CALLBACK_ALLOWED_HOSTS` env (comma-separated
* hostnames). When set, callbackUrl hosts must match. When unset,
* no allowlist is applied useful in dev/self-hosted where the
* journal lives somewhere unpredictable.
*/
export function getCallbackAllowedHosts(): string[] | undefined {
const raw = process.env.PLANNER_CALLBACK_ALLOWED_HOSTS;
if (!raw) return undefined;
return raw.split(",").map((s) => s.trim()).filter(Boolean);
}