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>
91 lines
3 KiB
TypeScript
91 lines
3 KiB
TypeScript
// 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);
|
|
}
|