mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-08-28 19:48:01 +08:00
fix: harden frontend-next mail rendering
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.3.0",
|
||||
"lucide-react": "^0.561.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.0",
|
||||
|
||||
Generated
+16
@@ -20,6 +20,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
dompurify:
|
||||
specifier: ^3.3.0
|
||||
version: 3.4.13
|
||||
lucide-react:
|
||||
specifier: ^0.561.0
|
||||
version: 0.561.0(react@19.2.7)
|
||||
@@ -1267,6 +1270,9 @@ packages:
|
||||
'@types/react@19.2.17':
|
||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@vitejs/plugin-react@5.2.0':
|
||||
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1319,6 +1325,9 @@ packages:
|
||||
detect-node-es@1.1.0:
|
||||
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
||||
|
||||
dompurify@3.4.13:
|
||||
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
|
||||
|
||||
electron-to-chromium@1.5.379:
|
||||
resolution: {integrity: sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==}
|
||||
|
||||
@@ -2811,6 +2820,9 @@ snapshots:
|
||||
dependencies:
|
||||
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))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
@@ -2857,6 +2869,10 @@ snapshots:
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
|
||||
dompurify@3.4.13:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
electron-to-chromium@1.5.379: {}
|
||||
|
||||
enhanced-resolve@5.21.6:
|
||||
|
||||
+48
-16
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import DOMPurify from "dompurify"
|
||||
import {
|
||||
Archive,
|
||||
Check,
|
||||
@@ -171,6 +172,7 @@ function App() {
|
||||
setMails([])
|
||||
setSelectedId("")
|
||||
setSettings(defaultAddressSettings)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
@@ -178,6 +180,7 @@ function App() {
|
||||
setSettings({ ...defaultAddressSettings, fetched: true })
|
||||
setMails([])
|
||||
setSelectedId("")
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -899,10 +902,14 @@ function NavItem({
|
||||
function AddressPill({ address, showNotice }: { address: string; showNotice: (notice: Notice) => void }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copyAddress = async () => {
|
||||
await navigator.clipboard.writeText(address)
|
||||
setCopied(true)
|
||||
showNotice({ type: "success", text: "Address copied" })
|
||||
window.setTimeout(() => setCopied(false), 1400)
|
||||
try {
|
||||
await navigator.clipboard.writeText(address)
|
||||
setCopied(true)
|
||||
showNotice({ type: "success", text: "Address copied" })
|
||||
window.setTimeout(() => setCopied(false), 1400)
|
||||
} catch (error) {
|
||||
showNotice({ type: "error", text: `Copy failed: ${getErrorMessage(error)}` })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1010,8 +1017,10 @@ function MessageReader({
|
||||
)
|
||||
}
|
||||
|
||||
const body = mail.message || mail.text || mail.raw || ""
|
||||
const isHtml = /<\/?[a-z][\s\S]*>/i.test(body)
|
||||
const htmlBody = mail.html || (mail.message && /<\/?[a-z][\s\S]*>/i.test(mail.message) ? mail.message : "")
|
||||
const body = htmlBody || mail.text || mail.message || ""
|
||||
const sender = mail.sender || mail.source
|
||||
const safeHtml = htmlBody ? createSafeMailHtml(htmlBody) : ""
|
||||
|
||||
return (
|
||||
<section className="reader">
|
||||
@@ -1034,10 +1043,10 @@ function MessageReader({
|
||||
</div>
|
||||
<div className="reader-body">
|
||||
<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">
|
||||
<h1>{mail.subject || "No Subject"}</h1>
|
||||
<p>{mail.source || "Unknown sender"}</p>
|
||||
<p>{sender || "Unknown sender"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
to <span className="font-mono">{address}</span> · {formatDate(mail.created_at)}
|
||||
</p>
|
||||
@@ -1045,8 +1054,14 @@ function MessageReader({
|
||||
</div>
|
||||
<Card className="message-card">
|
||||
<CardContent className="p-6">
|
||||
{isHtml ? (
|
||||
<iframe className="message-frame" title={`mail-${mail.id}`} sandbox="" srcDoc={body} />
|
||||
{safeHtml ? (
|
||||
<iframe
|
||||
className="message-frame"
|
||||
title={`mail-${mail.id}`}
|
||||
sandbox=""
|
||||
referrerPolicy="no-referrer"
|
||||
srcDoc={safeHtml}
|
||||
/>
|
||||
) : (
|
||||
<pre className="message-text">{body}</pre>
|
||||
)}
|
||||
@@ -1407,8 +1422,12 @@ function SettingsPanel({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(jwt)
|
||||
showNotice({ type: "success", text: "Credential copied" })
|
||||
try {
|
||||
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" />
|
||||
@@ -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) {
|
||||
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(
|
||||
(nextValue: string) => {
|
||||
setValue(nextValue)
|
||||
localStorage.setItem(key, nextValue)
|
||||
if (typeof window !== "undefined") window.localStorage.setItem(key, nextValue)
|
||||
},
|
||||
[key],
|
||||
)
|
||||
@@ -1500,8 +1531,9 @@ function useLocalStorageState<T extends string = string>(key: string, defaultVal
|
||||
|
||||
function useLocalStorageArray(key: string) {
|
||||
const [value, setValue] = useState<string[]>(() => {
|
||||
if (typeof window === "undefined") return []
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
const raw = window.localStorage.getItem(key)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : []
|
||||
@@ -1513,7 +1545,7 @@ function useLocalStorageArray(key: string) {
|
||||
(nextValue: string[]) => {
|
||||
const deduped = Array.from(new Set(nextValue.filter(Boolean)))
|
||||
setValue(deduped)
|
||||
localStorage.setItem(key, JSON.stringify(deduped))
|
||||
if (typeof window !== "undefined") window.localStorage.setItem(key, JSON.stringify(deduped))
|
||||
},
|
||||
[key],
|
||||
)
|
||||
|
||||
@@ -63,6 +63,8 @@ export type MailItem = {
|
||||
source?: string
|
||||
address?: string
|
||||
subject?: string
|
||||
sender?: string
|
||||
html?: string
|
||||
message?: string
|
||||
text?: string
|
||||
raw?: string
|
||||
@@ -158,8 +160,8 @@ const normalizeOpenSettings = (payload: Record<string, unknown>): OpenSettings =
|
||||
title: String(payload.title || ""),
|
||||
prefix: String(payload.prefix || ""),
|
||||
addressRegex: String(payload.addressRegex || ""),
|
||||
minAddressLen: Number(payload.minAddressLen || 1),
|
||||
maxAddressLen: Number(payload.maxAddressLen || 30),
|
||||
minAddressLen: Number(payload.minAddressLen ?? 1),
|
||||
maxAddressLen: Number(payload.maxAddressLen ?? 30),
|
||||
needAuth: Boolean(payload.needAuth),
|
||||
enableUserCreateEmail: Boolean(payload.enableUserCreateEmail),
|
||||
disableAnonymousUserCreateEmail: Boolean(payload.disableAnonymousUserCreateEmail),
|
||||
@@ -354,7 +356,7 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user