// #462 — validator for the user-set custom avatar URL (nodes.avatar_url). // // The value ends up in an on every dashboard client, so this is // an XSS trust boundary: protocol MUST be http/https (rejects javascript:, // data:, vbscript:, file:, blob:, …), no whitespace/control characters, // bounded length. Pure function, unit-tested in avatar-validate.test.ts. export const MAX_AVATAR_URL_LENGTH = 2048; export type AvatarValidation = | { ok: true; value: string | null } | { ok: false; reason: string }; /** * Normalize + validate an untrusted avatar_url patch value. * * null / undefined / "" / whitespace-only → { ok, value: null } (clear) * same-origin pool path /avatars/. → { ok, value: trimmed } * valid absolute http(s) URL → { ok, value: normalized } * anything else → { ok: false, reason } * * Relative branch rationale (通信龙 裁定, avatar 接线单): the hub is * reached through several origins (localhost dev, public domains) — an * absolute URL would weld ONE hostname into the DB and break the image * for every other entry point; a same-origin relative path is portable. * 🔴 Trap this branch must block: "starts with /" ≠ "same-origin" — * "//evil.com/x.png" ALSO starts with "/" but is a protocol-relative URL * the browser resolves to evil.com. Hence: single leading slash with the * SECOND char not "/", then an exact value-set match (the /avatars/ pool * prefix + extension whitelist), not a shape match. */ // Exact allowed set for same-origin values: the dashboard's designed pool // under /avatars/. Filename charset excludes "/" (no traversal, no nested // paths) and "%" (no encoded surprises); extensions mirror the actual // pool contents. Widen ONLY by extending this list deliberately. const SAME_ORIGIN_AVATAR_RE = /^\/avatars\/[A-Za-z0-9._-]+\.(webp|png|svg)$/; export function validateAvatarUrl(raw: unknown): AvatarValidation { if (raw === null || raw === undefined) return { ok: true, value: null }; if (typeof raw !== "string") { return { ok: false, reason: "avatar_url must be a string or null" }; } const trimmed = raw.trim(); if (trimmed.length === 0) return { ok: true, value: null }; if (trimmed.length > MAX_AVATAR_URL_LENGTH) { return { ok: false, reason: `avatar_url must be ≤ ${MAX_AVATAR_URL_LENGTH} chars` }; } // Reject embedded whitespace + C0/C1 control chars outright. URL() // silently strips some of these (e.g. tabs/newlines), which would let // "java\tscript:" style payloads normalize into a hostile protocol — // so this check must run BEFORE parsing, on the raw trimmed string. if (/[\u0000-\u001f\u007f-\u009f\s]/.test(trimmed)) { return { ok: false, reason: "avatar_url must not contain whitespace or control characters" }; } // Same-origin relative branch (runs AFTER the whitespace/control gate // above — that check protects this branch too). Order of the two tests // matters for the reason string, not for safety: the exact-set regex // alone already rejects "//…" (second char is "/", first segment must // literally be "avatars"), the explicit double-slash check just names // the classic bypass in its own words. if (trimmed.startsWith("/")) { if (trimmed.startsWith("//")) { return { ok: false, reason: "avatar_url must not be protocol-relative (//host/…)" }; } if (!SAME_ORIGIN_AVATAR_RE.test(trimmed)) { return { ok: false, reason: "relative avatar_url must match /avatars/.(webp|png|svg)" }; } return { ok: true, value: trimmed }; } // Absolute http(s) branch — unchanged from #462 (do not touch while // relaxing: 通信龙 裁定 condition 3). let parsed: URL; try { parsed = new URL(trimmed); } catch { return { ok: false, reason: "avatar_url must be an absolute URL" }; } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return { ok: false, reason: "avatar_url protocol must be http or https" }; } // Embedded credentials (https://user:pass@host/…) would be persisted, // leaked to the avatar host on every render, and can spoof the visible // origin in UIs. Reject outright rather than silently stripping — the // caller should know their input carried secrets (通信龙 review round-2 // follow-up). if (parsed.username || parsed.password) { return { ok: false, reason: "avatar_url must not contain credentials (userinfo)" }; } // Persist the NORMALIZED href, not the raw input: href percent-encodes // markup-hostile characters (" < > ` → %22 %3C %3E %60), so what lands // in the DB is already safe to interpolate into an attribute. // The dashboard renderer lives in a different package this repo cannot // audit — the server must not bet on it escaping (审查修复 per 通信龙 // #461/#462 review, finding 3). const normalized = parsed.href; // Belt-and-braces: reject anything href does NOT encode that could // still break out of an attribute context (WHATWG URL leaves ' and \ // untouched in some positions). if (/["'<>\\`]/.test(normalized)) { return { ok: false, reason: "avatar_url contains characters not allowed in a URL" }; } return { ok: true, value: normalized }; }