// #462 — unit tests for the avatar_url XSS boundary (avatar-validate.ts).
//
// The validator's whole job is to keep hostile values out of a dashboard
//
; every rejection case here is a concrete injection shape.
// Live-gate check: replace validateAvatarUrl with `return { ok: true,
// value: String(raw) }` and the javascript:/data:/control-char tests go
// red.
import { describe, expect, test } from "bun:test";
import { MAX_AVATAR_URL_LENGTH, validateAvatarUrl } from "./avatar-validate";
describe("#462 validateAvatarUrl", () => {
test("accepts plain https URL", () => {
const r = validateAvatarUrl("https://cdn.example.com/a/b.png");
expect(r).toEqual({ ok: true, value: "https://cdn.example.com/a/b.png" });
});
test("accepts http URL and trims surrounding whitespace", () => {
const r = validateAvatarUrl(" http://example.com/x.jpg ");
expect(r).toEqual({ ok: true, value: "http://example.com/x.jpg" });
});
test("accepts URL with query string", () => {
const r = validateAvatarUrl("https://example.com/img?size=64&v=2");
expect(r.ok).toBe(true);
});
test("null / undefined / empty / whitespace-only → clear (value null)", () => {
expect(validateAvatarUrl(null)).toEqual({ ok: true, value: null });
expect(validateAvatarUrl(undefined)).toEqual({ ok: true, value: null });
expect(validateAvatarUrl("")).toEqual({ ok: true, value: null });
expect(validateAvatarUrl(" ")).toEqual({ ok: true, value: null });
});
test("rejects non-string types", () => {
expect(validateAvatarUrl(42).ok).toBe(false);
expect(validateAvatarUrl({ url: "https://x" }).ok).toBe(false);
expect(validateAvatarUrl(["https://x"]).ok).toBe(false);
expect(validateAvatarUrl(true).ok).toBe(false);
});
test("rejects javascript: pseudo-protocol", () => {
expect(validateAvatarUrl("javascript:alert(1)").ok).toBe(false);
expect(validateAvatarUrl("JavaScript:alert(1)").ok).toBe(false);
expect(validateAvatarUrl("JAVASCRIPT:alert(document.cookie)").ok).toBe(false);
});
test("rejects javascript: smuggled via embedded tab/newline (URL() would strip them)", () => {
expect(validateAvatarUrl("java\tscript:alert(1)").ok).toBe(false);
expect(validateAvatarUrl("java\nscript:alert(1)").ok).toBe(false);
expect(validateAvatarUrl("java\rscript:alert(1)").ok).toBe(false);
});
test("rejects data: / vbscript: / file: / blob: and other non-http protocols", () => {
expect(validateAvatarUrl("data:text/html,").ok).toBe(false);
expect(validateAvatarUrl("data:image/png;base64,iVBORw0KGgo=").ok).toBe(false);
expect(validateAvatarUrl("vbscript:msgbox(1)").ok).toBe(false);
expect(validateAvatarUrl("file:///etc/passwd").ok).toBe(false);
expect(validateAvatarUrl("blob:https://example.com/uuid").ok).toBe(false);
expect(validateAvatarUrl("ftp://example.com/a.png").ok).toBe(false);
});
test("rejects protocol-relative / non-URL / schemeless strings", () => {
// SPEC CHANGE (通信龙 裁定, avatar 接线单): same-origin pool paths
// (/avatars/.) are now ACCEPTED — that case moved to the
// dedicated relative-branch suite below. Everything here stays rejected.
expect(validateAvatarUrl("//cdn.example.com/1.png").ok).toBe(false);
expect(validateAvatarUrl("not a url").ok).toBe(false);
expect(validateAvatarUrl("example.com/x.png").ok).toBe(false);
});
test("rejects embedded whitespace and control characters", () => {
expect(validateAvatarUrl("https://example.com/a b.png").ok).toBe(false);
expect(validateAvatarUrl("https://example.com/a\u0000.png").ok).toBe(false);
expect(validateAvatarUrl("https://example.com/a\u009f.png").ok).toBe(false);
});
test("normalizes to parsed.href: markup-hostile chars come out percent-encoded (finding 3)", () => {
const r1 = validateAvatarUrl('https://example.com/a"b.png');
expect(r1).toEqual({ ok: true, value: "https://example.com/a%22b.png" });
const r2 = validateAvatarUrl("https://example.com/