From cb367a216f0a0ae0567e3f107c6a87944ec38ae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=A5=E6=B2=B3=E6=99=B4?= Date: Sat, 28 Feb 2026 22:55:29 +0900 Subject: [PATCH 1/6] :bug: fix announcement xss sanitization --- package-lock.json | 32 +++++++++++++++++++--------- package.json | 2 ++ src/components/AnnouncementModal.tsx | 31 +++++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 601570d..07c0aeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "name": "app", "version": "1.3.0", "dependencies": { + "@types/dompurify": "^3.2.0", "@types/uuid": "^10.0.0", "clsx": "^2.1.1", + "dompurify": "^3.3.1", "jsqr": "1.4.0", "lucide-react": "^0.344.0", "prop-types": "15.8.1", @@ -80,7 +82,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2878,6 +2879,16 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz", + "integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==", + "deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.", + "license": "MIT", + "dependencies": { + "dompurify": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2891,7 +2902,6 @@ "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2907,7 +2917,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/uuid": { @@ -2956,7 +2966,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3165,7 +3174,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3624,6 +3632,15 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5244,7 +5261,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5257,7 +5273,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6178,7 +6193,6 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -6506,7 +6520,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6903,7 +6916,6 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/package.json b/package.json index 5cb82ea..c7bd638 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,10 @@ "tauri": "tauri" }, "dependencies": { + "@types/dompurify": "^3.2.0", "@types/uuid": "^10.0.0", "clsx": "^2.1.1", + "dompurify": "^3.3.1", "jsqr": "1.4.0", "lucide-react": "^0.344.0", "prop-types": "15.8.1", diff --git a/src/components/AnnouncementModal.tsx b/src/components/AnnouncementModal.tsx index 0322347..4767e2f 100644 --- a/src/components/AnnouncementModal.tsx +++ b/src/components/AnnouncementModal.tsx @@ -1,8 +1,32 @@ import React, { useEffect, useState } from 'react'; import { X, Megaphone } from 'lucide-react'; +import DOMPurify from 'dompurify'; const ANNOUNCEMENT_URL = 'https://www.transmtf.com/api/announcement/tmtf_b243d43f97b51b4fef747016'; const STORAGE_KEY = 'tmtf_announcement_hash'; +const ANNOUNCEMENT_ALLOWED_TAGS = [ + 'p', 'br', 'strong', 'em', 'b', 'i', 'u', + 'ul', 'ol', 'li', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'table', 'thead', 'tbody', 'tr', 'th', 'td', + 'span', 'div', + 'a', 'blockquote', 'code', 'pre', 'hr', 'img' +]; +const ANNOUNCEMENT_ALLOWED_ATTR = [ + 'href', 'title', 'target', 'rel', 'src', 'alt', + 'colspan', 'rowspan', 'scope' +]; + +function sanitizeAnnouncementHtml(html: string): string { + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: ANNOUNCEMENT_ALLOWED_TAGS, + ALLOWED_ATTR: ANNOUNCEMENT_ALLOWED_ATTR, + ALLOW_DATA_ATTR: false, + FORBID_TAGS: ['style', 'script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select', 'svg', 'math'], + FORBID_ATTR: ['style'], + ALLOWED_URI_REGEXP: /^(?:(?:https?):|mailto:|tel:|\/)/i, + }); +} async function hashContent(content: string): Promise { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(content)); @@ -23,10 +47,13 @@ const AnnouncementModal: React.FC = () => { const text = (await res.text()).trim(); if (!text) return; - const hash = await hashContent(text); + const sanitized = sanitizeAnnouncementHtml(text).trim(); + if (!sanitized) return; + + const hash = await hashContent(sanitized); if (hash !== localStorage.getItem(STORAGE_KEY)) { localStorage.setItem(STORAGE_KEY, hash); - setContent(text); + setContent(sanitized); setVisible(true); } } catch { From 12f6629f4df7759fa234bb0081fada7c020e1db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=A5=E6=B2=B3=E6=99=B4?= Date: Sat, 28 Feb 2026 23:11:24 +0900 Subject: [PATCH 2/6] =?UTF-8?q?:bug:=20=E7=A7=BB=E9=99=A4=E8=BF=87?= =?UTF-8?q?=E6=97=B6=E7=9A=84=20@types/dompurify=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 11 ----------- package.json | 1 - 2 files changed, 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 07c0aeb..11d135d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "app", "version": "1.3.0", "dependencies": { - "@types/dompurify": "^3.2.0", "@types/uuid": "^10.0.0", "clsx": "^2.1.1", "dompurify": "^3.3.1", @@ -2879,16 +2878,6 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, - "node_modules/@types/dompurify": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz", - "integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==", - "deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.", - "license": "MIT", - "dependencies": { - "dompurify": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", diff --git a/package.json b/package.json index c7bd638..11ee598 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "tauri": "tauri" }, "dependencies": { - "@types/dompurify": "^3.2.0", "@types/uuid": "^10.0.0", "clsx": "^2.1.1", "dompurify": "^3.3.1", From e96a54764ed345de72a5a64a1c17331a4f16073d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=A5=E6=B2=B3=E6=99=B4?= Date: Sat, 28 Feb 2026 23:14:56 +0900 Subject: [PATCH 3/6] fix(security): prevent reverse tabnabbing in announcement links --- src/components/AnnouncementModal.tsx | 46 +++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/src/components/AnnouncementModal.tsx b/src/components/AnnouncementModal.tsx index 4767e2f..a9de414 100644 --- a/src/components/AnnouncementModal.tsx +++ b/src/components/AnnouncementModal.tsx @@ -16,16 +16,46 @@ const ANNOUNCEMENT_ALLOWED_ATTR = [ 'href', 'title', 'target', 'rel', 'src', 'alt', 'colspan', 'rowspan', 'scope' ]; +const ANNOUNCEMENT_SAFE_REL_TOKENS = ['noopener', 'noreferrer'] as const; +const ANNOUNCEMENT_SANITIZE_CONFIG = { + ALLOWED_TAGS: ANNOUNCEMENT_ALLOWED_TAGS, + ALLOWED_ATTR: ANNOUNCEMENT_ALLOWED_ATTR, + ALLOW_DATA_ATTR: false, + FORBID_TAGS: ['style', 'script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select', 'svg', 'math'], + FORBID_ATTR: ['style'], + ALLOWED_URI_REGEXP: /^(?:(?:https?):|mailto:|tel:|\/)/i, +}; -function sanitizeAnnouncementHtml(html: string): string { - return DOMPurify.sanitize(html, { - ALLOWED_TAGS: ANNOUNCEMENT_ALLOWED_TAGS, - ALLOWED_ATTR: ANNOUNCEMENT_ALLOWED_ATTR, - ALLOW_DATA_ATTR: false, - FORBID_TAGS: ['style', 'script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select', 'svg', 'math'], - FORBID_ATTR: ['style'], - ALLOWED_URI_REGEXP: /^(?:(?:https?):|mailto:|tel:|\/)/i, +function enforceSafeLinkTargets(html: string): string { + const template = document.createElement('template'); + template.innerHTML = html; + + template.content.querySelectorAll('a[target]').forEach((anchor) => { + const target = anchor.getAttribute('target'); + if (!target) return; + + if (target.toLowerCase() !== '_blank') { + anchor.removeAttribute('target'); + return; + } + + anchor.setAttribute('target', '_blank'); + const relTokens = new Set( + (anchor.getAttribute('rel') ?? '') + .split(/\s+/) + .map(token => token.trim().toLowerCase()) + .filter(Boolean) + ); + ANNOUNCEMENT_SAFE_REL_TOKENS.forEach(token => relTokens.add(token)); + anchor.setAttribute('rel', Array.from(relTokens).join(' ')); }); + + return template.innerHTML; +} + +function sanitizeAnnouncementHtml(html: string): string { + const sanitized = DOMPurify.sanitize(html, ANNOUNCEMENT_SANITIZE_CONFIG); + return enforceSafeLinkTargets(sanitized); } async function hashContent(content: string): Promise { From 573f9efa2d5057085713c2ffa13a9d97f5325664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=A5=E6=B2=B3=E6=99=B4?= Date: Sat, 28 Feb 2026 23:25:01 +0900 Subject: [PATCH 4/6] fix(security): normalize target before tabnabbing guard --- src/components/AnnouncementModal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/AnnouncementModal.tsx b/src/components/AnnouncementModal.tsx index a9de414..7710dc2 100644 --- a/src/components/AnnouncementModal.tsx +++ b/src/components/AnnouncementModal.tsx @@ -32,9 +32,10 @@ function enforceSafeLinkTargets(html: string): string { template.content.querySelectorAll('a[target]').forEach((anchor) => { const target = anchor.getAttribute('target'); - if (!target) return; + if (target == null) return; - if (target.toLowerCase() !== '_blank') { + const normalizedTarget = target.trim().toLowerCase(); + if (normalizedTarget !== '_blank') { anchor.removeAttribute('target'); return; } From 4ec5b52de0aa8bb05959fe3dc98fb7d0306e1af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=A5=E6=B2=B3=E6=99=B4?= Date: Sat, 28 Feb 2026 23:30:28 +0900 Subject: [PATCH 5/6] fix(announcement): migrate legacy raw-content hash --- src/components/AnnouncementModal.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/components/AnnouncementModal.tsx b/src/components/AnnouncementModal.tsx index 7710dc2..e8067eb 100644 --- a/src/components/AnnouncementModal.tsx +++ b/src/components/AnnouncementModal.tsx @@ -81,12 +81,22 @@ const AnnouncementModal: React.FC = () => { const sanitized = sanitizeAnnouncementHtml(text).trim(); if (!sanitized) return; - const hash = await hashContent(sanitized); - if (hash !== localStorage.getItem(STORAGE_KEY)) { - localStorage.setItem(STORAGE_KEY, hash); - setContent(sanitized); - setVisible(true); + const currentHash = await hashContent(sanitized); + const storedHash = localStorage.getItem(STORAGE_KEY); + + // Backward compatibility: older versions stored hash of raw content. + if (storedHash === currentHash) return; + if (storedHash) { + const legacyHash = await hashContent(text); + if (storedHash === legacyHash) { + localStorage.setItem(STORAGE_KEY, currentHash); + return; + } } + + localStorage.setItem(STORAGE_KEY, currentHash); + setContent(sanitized); + setVisible(true); } catch { // 公告是非关键功能,静默失败 } From 07ff6bf4b563f36002f15267b5f1044af439de6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=A5=E6=B2=B3=E6=99=B4?= Date: Sat, 28 Feb 2026 23:37:00 +0900 Subject: [PATCH 6/6] fix(security): enforce https-only announcement URLs --- src/components/AnnouncementModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AnnouncementModal.tsx b/src/components/AnnouncementModal.tsx index e8067eb..1ec2a27 100644 --- a/src/components/AnnouncementModal.tsx +++ b/src/components/AnnouncementModal.tsx @@ -23,7 +23,7 @@ const ANNOUNCEMENT_SANITIZE_CONFIG = { ALLOW_DATA_ATTR: false, FORBID_TAGS: ['style', 'script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'textarea', 'select', 'svg', 'math'], FORBID_ATTR: ['style'], - ALLOWED_URI_REGEXP: /^(?:(?:https?):|mailto:|tel:|\/)/i, + ALLOWED_URI_REGEXP: /^(?:https:|mailto:|tel:|\/(?!\/))/i, }; function enforceSafeLinkTargets(html: string): string {