diff options
| author | Fuwn <[email protected]> | 2026-06-01 12:57:57 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-06-01 12:57:57 +0000 |
| commit | 55780fa9a8d3b95e103c5f5222f6a42e9cf278df (patch) | |
| tree | de785a30afd34d761e19bdf7aeba9b09614ecacc /src/lib/User/BadgeWall/badgeLinks.test.ts | |
| parent | style: apply biome autofixes and resolve remaining lint findings (diff) | |
| download | due.moe-55780fa9a8d3b95e103c5f5222f6a42e9cf278df.tar.xz due.moe-55780fa9a8d3b95e103c5f5222f6a42e9cf278df.zip | |
fix(security): escape badge source/designer to close stored XSS
classifySource/classifyDesigner built <a> markup by raw-interpolating
user-controlled badge fields and rendered it via {@html}, enabling
stored XSS on public badge walls (any visitor who opened a crafted
badge). Extract them into badgeLinks.ts returning {href,label} with
http(s)-only href validation, render via escaped Svelte bindings, and
add regression tests.
Diffstat (limited to 'src/lib/User/BadgeWall/badgeLinks.test.ts')
| -rw-r--r-- | src/lib/User/BadgeWall/badgeLinks.test.ts | 70 |
1 files changed, 70 insertions, 0 deletions
diff --git a/src/lib/User/BadgeWall/badgeLinks.test.ts b/src/lib/User/BadgeWall/badgeLinks.test.ts new file mode 100644 index 00000000..301288ec --- /dev/null +++ b/src/lib/User/BadgeWall/badgeLinks.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { classifyDesigner, classifySource } from "./badgeLinks"; + +describe("classifySource", () => { + it("labels known hosts and keeps the original href", () => { + expect(classifySource("https://www.pixiv.net/en/artworks/1")).toEqual({ + href: "https://www.pixiv.net/en/artworks/1", + label: "Pixiv", + }); + expect(classifySource("https://x.com/someone")).toEqual({ + href: "https://x.com/someone", + label: "X (Twitter)", + }); + }); + + it("falls back to the raw url as label for unknown hosts", () => { + expect(classifySource("https://example.com/art")).toEqual({ + href: "https://example.com/art", + label: "https://example.com/art", + }); + }); + + it("rejects non-http(s) and malformed urls (href becomes null)", () => { + expect(classifySource("javascript:alert(1)").href).toBeNull(); + expect(classifySource("data:text/html,<script>1</script>").href).toBeNull(); + expect(classifySource("not a url").href).toBeNull(); + }); + + it("never produces a usable href for an html-injection payload", () => { + const payload = 'http://x"><img src=x onerror=alert(document.cookie)>'; + const result = classifySource(payload); + + expect(result.href).toBeNull(); + expect(result.label).toBe(payload); + }); +}); + +describe("classifyDesigner", () => { + it("extracts the username from an anilist profile url", () => { + expect(classifyDesigner("https://anilist.co/user/fuwn/")).toEqual({ + href: "https://anilist.co/user/fuwn/", + label: "fuwn", + }); + }); + + it("builds an anilist link from an @handle", () => { + expect(classifyDesigner("@fuwn")).toEqual({ + href: "https://anilist.co/user/fuwn/", + label: "fuwn", + }); + }); + + it("builds an anilist link from a bare name", () => { + expect(classifyDesigner("fuwn")).toEqual({ + href: "https://anilist.co/user/fuwn/", + label: "fuwn", + }); + }); + + it("forces an html-injection payload into the anilist path, never its own scheme/host", () => { + const payload = '"><img src=x onerror=alert(document.cookie)>'; + const result = classifyDesigner(payload); + + expect( + result.href === null || + result.href.startsWith("https://anilist.co/user/"), + ).toBe(true); + expect(result.label).toBe(payload); + }); +}); |