diff --git a/package-lock.json b/package-lock.json index 601570d..11d135d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@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 +81,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", @@ -2891,7 +2891,6 @@ "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2907,7 +2906,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 +2955,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 +3163,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3624,6 +3621,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 +5250,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 +5262,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 +6182,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 +6509,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 +6905,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..11ee598 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@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..1ec2a27 100644 --- a/src/components/AnnouncementModal.tsx +++ b/src/components/AnnouncementModal.tsx @@ -1,8 +1,63 @@ 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' +]; +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 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 == null) return; + + const normalizedTarget = target.trim().toLowerCase(); + if (normalizedTarget !== '_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 { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(content)); @@ -23,12 +78,25 @@ const AnnouncementModal: React.FC = () => { const text = (await res.text()).trim(); if (!text) return; - const hash = await hashContent(text); - if (hash !== localStorage.getItem(STORAGE_KEY)) { - localStorage.setItem(STORAGE_KEY, hash); - setContent(text); - setVisible(true); + const sanitized = sanitizeAnnouncementHtml(text).trim(); + if (!sanitized) return; + + 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 { // 公告是非关键功能,静默失败 }