From 55780fa9a8d3b95e103c5f5222f6a42e9cf278df Mon Sep 17 00:00:00 2001
From: Fuwn <contact@fuwn.net>
Date: Mon, 1 Jun 2026 12:57:57 +0000
Subject: 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.
---
 src/lib/User/BadgeWall/BadgePreview.svelte | 75 ++++++------------------------
 1 file changed, 15 insertions(+), 60 deletions(-)

(limited to 'src/lib/User/BadgeWall/BadgePreview.svelte')

diff --git a/src/lib/User/BadgeWall/BadgePreview.svelte b/src/lib/User/BadgeWall/BadgePreview.svelte
index d630de3e..c381fd53 100644
--- a/src/lib/User/BadgeWall/BadgePreview.svelte
+++ b/src/lib/User/BadgeWall/BadgePreview.svelte
@@ -7,6 +7,7 @@ import { cdn, thumbnail } from "$lib/Utility/image";
 import root from "$lib/Utility/root";
 import { databaseTimeToDate } from "$lib/Utility/time";
 import locale from "$stores/locale";
+import { classifyDesigner, classifySource } from "./badgeLinks";
 
 export let selectedBadge: Badge | undefined;
 export let onNext: () => void = () => undefined;
@@ -52,48 +53,6 @@ onMount(() => {
 	};
 });
 
-const classifySource = (source: string) => {
-	let name = source;
-	const sourceLower = source.toLowerCase();
-
-	if (sourceLower.includes("pixiv.net")) {
-		name = "Pixiv";
-	} else if (
-		sourceLower.includes("twitter.com") ||
-		sourceLower.includes("x.com")
-	) {
-		name = "X (Twitter)";
-	} else if (sourceLower.includes("zerochan.net")) {
-		name = "Zerochan";
-	} else if (sourceLower.includes("imgur.com")) {
-		name = "Imgur";
-	} else if (sourceLower.includes("lofter.com")) {
-		name = "Lofter";
-	}
-
-	return `<a href="${source}" target="_blank">${name}</a>`;
-};
-
-const classifyDesigner = (designer: string) => {
-	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 `<a href="${userLink}" target="_blank">@${name}</a>`;
-};
-
 const onClick = (event: MouseEvent) => {
 	event.preventDefault();
 
@@ -148,18 +107,14 @@ const onClick = (event: MouseEvent) => {
       {#if selectedBadge.designer}
         <b>{$locale().badgePreview?.designer}</b>
 
-        <!-- {#if selectedBadge.designer.startsWith('http')}
-				<a href={selectedBadge.designer} target="_blank">
-					{selectedBadge.designer}
-				</a>
-			{:else if selectedBadge.designer.startsWith('@')}
-				<a href="https://anilist.co/user/{selectedBadge.designer.replace('@', '')}" target="_blank">
-					{selectedBadge.designer}
-				</a>
-			{:else}
-				{selectedBadge.designer}
-			{/if} -->
-        {@html classifyDesigner(selectedBadge.designer)}
+        {@const designerLink = classifyDesigner(selectedBadge.designer)}
+        {#if designerLink.href}
+          <a href={designerLink.href} target="_blank" rel="noopener noreferrer">
+            @{designerLink.label}
+          </a>
+        {:else}
+          @{designerLink.label}
+        {/if}
 
         <br />
       {/if}
@@ -181,13 +136,13 @@ const onClick = (event: MouseEvent) => {
       {#if selectedBadge.source}
         <b>{$locale().badgePreview?.source}</b>
 
-        {#if selectedBadge.source.startsWith('http')}
-          <!-- <a href={selectedBadge.source} target="_blank">
-					{selectedBadge.source}
-				</a> -->
-          {@html classifySource(selectedBadge.source)}
+        {@const sourceLink = classifySource(selectedBadge.source)}
+        {#if sourceLink.href}
+          <a href={sourceLink.href} target="_blank" rel="noopener noreferrer">
+            {sourceLink.label}
+          </a>
         {:else}
-          {selectedBadge.source}
+          {sourceLink.label}
         {/if}
 
         <br />
-- 
cgit v1.2.3

