aboutsummaryrefslogtreecommitdiff
path: root/src/lib/User/BadgeWall/badgeLinks.ts
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-06-01 12:57:57 +0000
committerFuwn <[email protected]>2026-06-01 12:57:57 +0000
commit55780fa9a8d3b95e103c5f5222f6a42e9cf278df (patch)
treede785a30afd34d761e19bdf7aeba9b09614ecacc /src/lib/User/BadgeWall/badgeLinks.ts
parentstyle: apply biome autofixes and resolve remaining lint findings (diff)
downloaddue.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.ts')
-rw-r--r--src/lib/User/BadgeWall/badgeLinks.ts56
1 files changed, 56 insertions, 0 deletions
diff --git a/src/lib/User/BadgeWall/badgeLinks.ts b/src/lib/User/BadgeWall/badgeLinks.ts
new file mode 100644
index 00000000..92cbe6ce
--- /dev/null
+++ b/src/lib/User/BadgeWall/badgeLinks.ts
@@ -0,0 +1,56 @@
+export interface BadgeLink {
+ href: string | null;
+ label: string;
+}
+
+const safeHttpUrl = (value: string): string | null => {
+ try {
+ const url = new URL(value);
+
+ return url.protocol === "http:" || url.protocol === "https:" ? value : null;
+ } catch {
+ return null;
+ }
+};
+
+export const classifySource = (source: string): BadgeLink => {
+ let label = source;
+ const sourceLower = source.toLowerCase();
+
+ if (sourceLower.includes("pixiv.net")) {
+ label = "Pixiv";
+ } else if (
+ sourceLower.includes("twitter.com") ||
+ sourceLower.includes("x.com")
+ ) {
+ label = "X (Twitter)";
+ } else if (sourceLower.includes("zerochan.net")) {
+ label = "Zerochan";
+ } else if (sourceLower.includes("imgur.com")) {
+ label = "Imgur";
+ } else if (sourceLower.includes("lofter.com")) {
+ label = "Lofter";
+ }
+
+ return { href: safeHttpUrl(source), label };
+};
+
+export const classifyDesigner = (designer: string): BadgeLink => {
+ let name = designer;
+ let userLink = designer;
+ const designerLower = designer.toLowerCase();
+ const anilistUser = designer.match(
+ /https?:\/\/anilist\.co\/user\/([^/]+)\/?/,
+ );
+
+ if (anilistUser) {
+ name = anilistUser[1];
+ } else if (designerLower.startsWith("@")) {
+ name = designer.replace("@", "");
+ userLink = `https://anilist.co/user/${name}/`;
+ } else if (!designerLower.startsWith("http")) {
+ userLink = `https://anilist.co/user/${name}/`;
+ }
+
+ return { href: safeHttpUrl(userLink), label: name };
+};