fix: harden frontend-next mail rendering

This commit is contained in:
dreamhunter2333
2026-08-19 10:34:32 +08:00
parent 2299c56040
commit e5c3c7bf71
4 changed files with 70 additions and 19 deletions
+1
View File
@@ -13,6 +13,7 @@
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.3.0",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.6.0", "radix-ui": "^1.6.0",
+16
View File
@@ -20,6 +20,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
dompurify:
specifier: ^3.3.0
version: 3.4.13
lucide-react: lucide-react:
specifier: ^0.561.0 specifier: ^0.561.0
version: 0.561.0(react@19.2.7) version: 0.561.0(react@19.2.7)
@@ -1267,6 +1270,9 @@ packages:
'@types/react@19.2.17': '@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@vitejs/plugin-react@5.2.0': '@vitejs/plugin-react@5.2.0':
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1319,6 +1325,9 @@ packages:
detect-node-es@1.1.0: detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
dompurify@3.4.13:
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
electron-to-chromium@1.5.379: electron-to-chromium@1.5.379:
resolution: {integrity: sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==} resolution: {integrity: sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==}
@@ -2811,6 +2820,9 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/trusted-types@2.0.7':
optional: true
'@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0))': '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0))':
dependencies: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7
@@ -2857,6 +2869,10 @@ snapshots:
detect-node-es@1.1.0: {} detect-node-es@1.1.0: {}
dompurify@3.4.13:
optionalDependencies:
'@types/trusted-types': 2.0.7
electron-to-chromium@1.5.379: {} electron-to-chromium@1.5.379: {}
enhanced-resolve@5.21.6: enhanced-resolve@5.21.6:
+48 -16
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react" import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
import DOMPurify from "dompurify"
import { import {
Archive, Archive,
Check, Check,
@@ -171,6 +172,7 @@ function App() {
setMails([]) setMails([])
setSelectedId("") setSelectedId("")
setSettings(defaultAddressSettings) setSettings(defaultAddressSettings)
setPage(1)
} }
const logout = () => { const logout = () => {
@@ -178,6 +180,7 @@ function App() {
setSettings({ ...defaultAddressSettings, fetched: true }) setSettings({ ...defaultAddressSettings, fetched: true })
setMails([]) setMails([])
setSelectedId("") setSelectedId("")
setPage(1)
} }
return ( return (
@@ -899,10 +902,14 @@ function NavItem({
function AddressPill({ address, showNotice }: { address: string; showNotice: (notice: Notice) => void }) { function AddressPill({ address, showNotice }: { address: string; showNotice: (notice: Notice) => void }) {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const copyAddress = async () => { const copyAddress = async () => {
await navigator.clipboard.writeText(address) try {
setCopied(true) await navigator.clipboard.writeText(address)
showNotice({ type: "success", text: "Address copied" }) setCopied(true)
window.setTimeout(() => setCopied(false), 1400) showNotice({ type: "success", text: "Address copied" })
window.setTimeout(() => setCopied(false), 1400)
} catch (error) {
showNotice({ type: "error", text: `Copy failed: ${getErrorMessage(error)}` })
}
} }
return ( return (
@@ -1010,8 +1017,10 @@ function MessageReader({
) )
} }
const body = mail.message || mail.text || mail.raw || "" const htmlBody = mail.html || (mail.message && /<\/?[a-z][\s\S]*>/i.test(mail.message) ? mail.message : "")
const isHtml = /<\/?[a-z][\s\S]*>/i.test(body) const body = htmlBody || mail.text || mail.message || ""
const sender = mail.sender || mail.source
const safeHtml = htmlBody ? createSafeMailHtml(htmlBody) : ""
return ( return (
<section className="reader"> <section className="reader">
@@ -1034,10 +1043,10 @@ function MessageReader({
</div> </div>
<div className="reader-body"> <div className="reader-body">
<div className="reader-meta"> <div className="reader-meta">
<span className="sender-avatar large">{senderInitials(mail.source)}</span> <span className="sender-avatar large">{senderInitials(sender)}</span>
<div className="min-w-0"> <div className="min-w-0">
<h1>{mail.subject || "No Subject"}</h1> <h1>{mail.subject || "No Subject"}</h1>
<p>{mail.source || "Unknown sender"}</p> <p>{sender || "Unknown sender"}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
to <span className="font-mono">{address}</span> · {formatDate(mail.created_at)} to <span className="font-mono">{address}</span> · {formatDate(mail.created_at)}
</p> </p>
@@ -1045,8 +1054,14 @@ function MessageReader({
</div> </div>
<Card className="message-card"> <Card className="message-card">
<CardContent className="p-6"> <CardContent className="p-6">
{isHtml ? ( {safeHtml ? (
<iframe className="message-frame" title={`mail-${mail.id}`} sandbox="" srcDoc={body} /> <iframe
className="message-frame"
title={`mail-${mail.id}`}
sandbox=""
referrerPolicy="no-referrer"
srcDoc={safeHtml}
/>
) : ( ) : (
<pre className="message-text">{body}</pre> <pre className="message-text">{body}</pre>
)} )}
@@ -1407,8 +1422,12 @@ function SettingsPanel({
<Button <Button
variant="outline" variant="outline"
onClick={async () => { onClick={async () => {
await navigator.clipboard.writeText(jwt) try {
showNotice({ type: "success", text: "Credential copied" }) await navigator.clipboard.writeText(jwt)
showNotice({ type: "success", text: "Credential copied" })
} catch (error) {
showNotice({ type: "error", text: `Copy failed: ${getErrorMessage(error)}` })
}
}} }}
> >
<Copy className="size-4" /> <Copy className="size-4" />
@@ -1486,12 +1505,24 @@ function NoticeToast({ notice }: { notice: NonNullable<Notice> }) {
) )
} }
function createSafeMailHtml(html: string) {
const sanitized = DOMPurify.sanitize(html, {
FORBID_TAGS: ["base", "embed", "form", "iframe", "link", "meta", "object", "script"],
FORBID_ATTR: ["action", "formaction", "srcset"],
})
const policy = "default-src 'none'; img-src data: blob: cid:; style-src 'unsafe-inline'; form-action 'none'; base-uri 'none'"
return `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="${policy}"></head><body>${sanitized}</body></html>`
}
function useLocalStorageState<T extends string = string>(key: string, defaultValue: T) { function useLocalStorageState<T extends string = string>(key: string, defaultValue: T) {
const [value, setValue] = useState<string>(() => localStorage.getItem(key) ?? defaultValue) const [value, setValue] = useState<string>(() => {
if (typeof window === "undefined") return defaultValue
return window.localStorage.getItem(key) ?? defaultValue
})
const setStoredValue = useCallback( const setStoredValue = useCallback(
(nextValue: string) => { (nextValue: string) => {
setValue(nextValue) setValue(nextValue)
localStorage.setItem(key, nextValue) if (typeof window !== "undefined") window.localStorage.setItem(key, nextValue)
}, },
[key], [key],
) )
@@ -1500,8 +1531,9 @@ function useLocalStorageState<T extends string = string>(key: string, defaultVal
function useLocalStorageArray(key: string) { function useLocalStorageArray(key: string) {
const [value, setValue] = useState<string[]>(() => { const [value, setValue] = useState<string[]>(() => {
if (typeof window === "undefined") return []
try { try {
const raw = localStorage.getItem(key) const raw = window.localStorage.getItem(key)
if (!raw) return [] if (!raw) return []
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [] return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : []
@@ -1513,7 +1545,7 @@ function useLocalStorageArray(key: string) {
(nextValue: string[]) => { (nextValue: string[]) => {
const deduped = Array.from(new Set(nextValue.filter(Boolean))) const deduped = Array.from(new Set(nextValue.filter(Boolean)))
setValue(deduped) setValue(deduped)
localStorage.setItem(key, JSON.stringify(deduped)) if (typeof window !== "undefined") window.localStorage.setItem(key, JSON.stringify(deduped))
}, },
[key], [key],
) )
+5 -3
View File
@@ -63,6 +63,8 @@ export type MailItem = {
source?: string source?: string
address?: string address?: string
subject?: string subject?: string
sender?: string
html?: string
message?: string message?: string
text?: string text?: string
raw?: string raw?: string
@@ -158,8 +160,8 @@ const normalizeOpenSettings = (payload: Record<string, unknown>): OpenSettings =
title: String(payload.title || ""), title: String(payload.title || ""),
prefix: String(payload.prefix || ""), prefix: String(payload.prefix || ""),
addressRegex: String(payload.addressRegex || ""), addressRegex: String(payload.addressRegex || ""),
minAddressLen: Number(payload.minAddressLen || 1), minAddressLen: Number(payload.minAddressLen ?? 1),
maxAddressLen: Number(payload.maxAddressLen || 30), maxAddressLen: Number(payload.maxAddressLen ?? 30),
needAuth: Boolean(payload.needAuth), needAuth: Boolean(payload.needAuth),
enableUserCreateEmail: Boolean(payload.enableUserCreateEmail), enableUserCreateEmail: Boolean(payload.enableUserCreateEmail),
disableAnonymousUserCreateEmail: Boolean(payload.disableAnonymousUserCreateEmail), disableAnonymousUserCreateEmail: Boolean(payload.disableAnonymousUserCreateEmail),
@@ -354,7 +356,7 @@ export class ApiClient {
} }
async listMails(limit: number, offset: number) { async listMails(limit: number, offset: number) {
return this.request<MailListResponse>(`/api/mails?limit=${limit}&offset=${offset}`) return this.request<MailListResponse>(`/api/parsed_mails?limit=${limit}&offset=${offset}`)
} }
async deleteMail(id: string | number) { async deleteMail(id: string | number) {