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>
135 lines
4.5 KiB
TypeScript
135 lines
4.5 KiB
TypeScript
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("validateFetchUrl — private-address blocking (production)", () => {
|
|
beforeEach(() => {
|
|
vi.unstubAllEnvs();
|
|
// Production-without-E2E is the only mode that blocks (mirrors the
|
|
// requireSecret guard). Tests otherwise run as NODE_ENV=test → off.
|
|
vi.stubEnv("NODE_ENV", "production");
|
|
vi.stubEnv("E2E", "");
|
|
});
|
|
|
|
const blocked = [
|
|
"http://127.0.0.1/x",
|
|
"http://localhost:3000/x",
|
|
"http://sub.localhost/x",
|
|
"http://169.254.169.254/latest/meta-data/", // cloud metadata
|
|
"http://10.0.0.5/x",
|
|
"http://172.16.0.1/x",
|
|
"http://172.31.255.254/x",
|
|
"http://192.168.1.1/x",
|
|
"http://100.64.0.1/x", // CGNAT
|
|
"http://0.0.0.0/x",
|
|
"http://[::1]/x",
|
|
"http://[fc00::1]/x",
|
|
"http://[fe80::1]/x",
|
|
"http://[::ffff:127.0.0.1]/x",
|
|
];
|
|
for (const url of blocked) {
|
|
it(`blocks ${url}`, () => {
|
|
expect(validateFetchUrl(url).ok).toBe(false);
|
|
});
|
|
}
|
|
|
|
it("still allows public hosts", () => {
|
|
expect(validateFetchUrl("https://journal.trails.cool/api/cb").ok).toBe(true);
|
|
expect(validateFetchUrl("http://203.0.113.10/x").ok).toBe(true); // public IP literal
|
|
expect(validateFetchUrl("http://172.15.0.1/x").ok).toBe(true); // just outside RFC1918 /12
|
|
expect(validateFetchUrl("http://172.32.0.1/x").ok).toBe(true);
|
|
});
|
|
|
|
it("an explicit allowlist overrides private blocking (operator decision)", () => {
|
|
expect(
|
|
validateFetchUrl("http://10.0.0.2:3000/cb", { allowedHosts: ["10.0.0.2:3000"] }).ok,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does not block private hosts outside production (dev/e2e localhost flow)", () => {
|
|
vi.stubEnv("NODE_ENV", "development");
|
|
expect(validateFetchUrl("http://localhost:3000/cb").ok).toBe(true);
|
|
vi.stubEnv("NODE_ENV", "production");
|
|
vi.stubEnv("E2E", "true");
|
|
expect(validateFetchUrl("http://localhost:3000/cb").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"]);
|
|
});
|
|
});
|