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>
82 lines
2.7 KiB
TypeScript
82 lines
2.7 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("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"]);
|
|
});
|
|
});
|