From cf4e559ca863c9095333f331af52f51d42b48e30 Mon Sep 17 00:00:00 2001 From: one-ea Date: Sun, 19 Apr 2026 08:25:57 +0000 Subject: [PATCH 1/2] fix(security): harden Monolith against XSS, SSRF, info leaks, and abuse Critical/High: - Sanitize custom_header/footer through DOMPurify before injection, forbid inline scripts, only allow external script src (fixes #1) - Add security headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, HSTS (fixes #5) - Remove authorEmail from public comment API, use DiceBear avatars based on nickname instead of Gravatar hash (fixes #2, #14) - Add login rate limiting: 5 attempts per 15 min per IP (fixes #3) - Reflect request Origin in CORS instead of wildcard (fixes #4) - Add SSRF protection for WebDAV backup and image localization: only allow https://, block private/internal IPs (fixes #6, #13) - Filter javascript: URIs in markdown link renderer (fixes #7) Medium: - Replace hardcoded reaction salt with REACTION_SALT env var (fixes #9) - Add .env.* to .gitignore, remove .env.production from tracking (fixes #10) - Disable source maps in production build (fixes #11) - Remove infrastructure details from health endpoint (fixes #15) --- .gitignore | 2 + client/.env.production | 1 - client/functions/api/[[path]].ts | 12 +++- client/src/app.tsx | 18 +++-- client/src/components/comments.tsx | 22 ++----- client/src/lib/api.ts | 2 +- client/src/lib/markdown.ts | 3 +- client/vite.config.ts | 1 + server/src/index.ts | 102 ++++++++++++++++++++++------- 9 files changed, 111 insertions(+), 52 deletions(-) delete mode 100644 client/.env.production diff --git a/.gitignore b/.gitignore index 6f017db..519bbb0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ dist/ # 环境变量 .env .env.local +.env.* +!.env.example .dev.vars # 系统文件 diff --git a/client/.env.production b/client/.env.production deleted file mode 100644 index 7c35aa6..0000000 --- a/client/.env.production +++ /dev/null @@ -1 +0,0 @@ -VITE_API_URL=https://monolith-server.h005-9d9.workers.dev diff --git a/client/functions/api/[[path]].ts b/client/functions/api/[[path]].ts index ef70c14..46c199a 100644 --- a/client/functions/api/[[path]].ts +++ b/client/functions/api/[[path]].ts @@ -8,21 +8,25 @@ import { export const onRequest: PagesFunction<{ API_BASE: string }> = async (context) => { // 直接处理 CORS 预检请求,不转发 if (context.request.method === "OPTIONS") { + const origin = context.request.headers.get("Origin") || "*"; return new Response(null, { status: 204, headers: { - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Max-Age": "86400", + "Vary": "Origin", }, }); } const backend = getBackendUrl(context.env); if (!backend) { + const origin = context.request.headers.get("Origin") || "*"; return createApiBaseErrorResponse({ - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": origin, + "Vary": "Origin", }); } @@ -37,8 +41,10 @@ export const onRequest: PagesFunction<{ API_BASE: string }> = async (context) => redirect: "manual", // 不跟随重定向,原样返回 30x }); + const origin = context.request.headers.get("Origin") || "*"; const responseHeaders = new Headers(res.headers); - responseHeaders.set("Access-Control-Allow-Origin", "*"); + responseHeaders.set("Access-Control-Allow-Origin", origin); + responseHeaders.set("Vary", "Origin"); return new Response(res.body, { status: res.status, diff --git a/client/src/app.tsx b/client/src/app.tsx index db73a0f..c2d3f6b 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -1,5 +1,6 @@ import { Route, Switch, useLocation } from "wouter"; import { useEffect, Suspense, lazy } from "react"; +import DOMPurify from "dompurify"; import { Navbar } from "@/components/navbar"; import { Footer } from "@/components/footer"; import { SearchOverlay } from "@/components/search"; @@ -24,17 +25,22 @@ const DynamicPage = lazy(() => import("@/pages/dynamic-page").then((m) => ({ def const NotFoundPage = lazy(() => import("@/pages/not-found").then((m) => ({ default: m.NotFoundPage }))); -/** 将 HTML 字符串安全注入到容器中(支持 script 标签执行) */ +/** 将设置中的 HTML/JS 代码安全注入到页面(仅允许外部脚本 src) */ function injectHtml(container: HTMLElement, html: string) { const temp = document.createElement("div"); - temp.innerHTML = html; + temp.innerHTML = DOMPurify.sanitize(html, { + ADD_TAGS: ["script"], + ADD_ATTR: ["src", "async", "defer"], + FORBID_TAGS: ["style", "iframe", "object", "embed", "form"], + FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover", "onfocus", "onblur"], + }); Array.from(temp.childNodes).forEach((node) => { if (node instanceof HTMLScriptElement) { - // script 需要重新创建才能执行 + if (!node.src) return; // 禁止内联脚本,只允许带 src 的外部脚本 const script = document.createElement("script"); - if (node.src) script.src = node.src; - else script.textContent = node.textContent; - Array.from(node.attributes).forEach((a) => script.setAttribute(a.name, a.value)); + script.src = node.src; + if (node.hasAttribute("async")) script.async = true; + if (node.hasAttribute("defer")) script.defer = true; container.appendChild(script); } else { container.appendChild(node.cloneNode(true)); diff --git a/client/src/components/comments.tsx b/client/src/components/comments.tsx index 5a72d17..af2520e 100644 --- a/client/src/components/comments.tsx +++ b/client/src/components/comments.tsx @@ -10,22 +10,10 @@ function formatDate(dateStr: string): string { }); } -/** 通过邮箱生成 Gravatar 头像 URL */ -function gravatarUrl(email: string, size = 40): string { - // 简单 hash 用于无邮箱时的默认颜色 - if (!email) return `https://api.dicebear.com/7.x/initials/svg?seed=U&size=${size}`; - const trimmed = email.trim().toLowerCase(); - return `https://gravatar.com/avatar/${simpleHash(trimmed)}?s=${size}&d=identicon`; -} - -function simpleHash(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash |= 0; - } - return Math.abs(hash).toString(16).padStart(32, "0"); +/** 通过昵称生成 DiceBear 头像 URL(不再传输邮箱) */ +function avatarUrl(name: string, size = 40): string { + const seed = encodeURIComponent(name.trim() || "U"); + return `https://api.dicebear.com/7.x/initials/svg?seed=${seed}&size=${size}`; } /* ── 单条评论 ──────────────────────────── */ @@ -34,7 +22,7 @@ function CommentItem({ comment }: { comment: CommentData }) {
{comment.authorName}(); /* ── 全局中间件 ────────────────────────────── */ app.use("*", cors({ - origin: "*", + origin: (origin) => origin || "*", allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowHeaders: ["Content-Type", "Authorization"], })); +app.use("*", async (c, next) => { + await next(); + const headers = c.res.headers; + headers.set("X-Content-Type-Options", "nosniff"); + headers.set("X-Frame-Options", "DENY"); + headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + if (c.req.url.startsWith("https://")) { + headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload"); + } +}); + // 注入存储实例到上下文(每次请求创建 — 在边缘环境中是无状态的) app.use("*", async (c, next) => { c.set("db", await createDatabase(c.env as unknown as Record)); @@ -81,13 +94,7 @@ async function triggerWebhook(c: any, eventName: string, payload: any) { /* ── 健康检查端点 ──────────────────────────── */ app.get("/api/health", async (c) => { - return c.json({ - status: "ok", - timestamp: new Date().toISOString(), - dbProvider: c.env.DB_PROVIDER || "d1", - storageProvider: c.env.STORAGE_PROVIDER || "r2", - environment: "production" - }); + return c.json({ status: "ok", timestamp: new Date().toISOString() }); }); /* ── 公开 API ──────────────────────────────── */ @@ -174,12 +181,13 @@ app.get("/api/categories", async (c) => { return c.json(categories); }); -// 获取文章评论(仅已审核) +// 获取文章评论(仅已审核,不暴露邮箱) app.get("/api/posts/:slug/comments", async (c) => { const slug = c.req.param("slug"); const db = c.get("db"); const comments = await db.getApprovedComments(slug); - return c.json(comments); + const safe = comments.map(({ author_email, authorEmail, ...rest }: any) => rest); + return c.json(safe); }); // 提交评论(公开接口,需审核后才显示) @@ -262,10 +270,11 @@ app.post("/api/posts/:slug/reactions", async (c) => { return c.json({ error: "无效的反应类型" }, 400); } - // IP hash 去重 + // IP hash 去重(使用环境变量盐值,避免源码泄露后可反推) const ip = c.req.header("CF-Connecting-IP") || c.req.header("X-Forwarded-For") || "unknown"; + const reactionSalt = c.env.REACTION_SALT || "monolith-reaction-default"; const encoder = new TextEncoder(); - const data = encoder.encode(ip + ":monolith-reaction-salt"); + const data = encoder.encode(ip + ":" + reactionSalt); const hashBuffer = await crypto.subtle.digest("SHA-256", data); const hashArray = Array.from(new Uint8Array(hashBuffer)); const ipHash = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); @@ -429,19 +438,41 @@ Sitemap: ${siteUrl}/sitemap.xml }); }); +/* ── 登录速率限制 ─────────────────────────── */ +const loginAttempts = new Map(); +const LOGIN_RATE_LIMIT = 5; // 最多 5 次 +const LOGIN_RATE_WINDOW = 15 * 60 * 1000; // 15 分钟窗口 + /* ── 认证 API ──────────────────────────────── */ // 登录 app.post("/api/auth/login", async (c) => { + const ip = c.req.header("CF-Connecting-IP") || c.req.header("X-Forwarded-For") || "unknown"; + + // 速率限制 + const now = Date.now(); + const record = loginAttempts.get(ip); + if (record && record.count >= LOGIN_RATE_LIMIT && (now - record.firstAttempt) < LOGIN_RATE_WINDOW) { + return c.json({ error: "尝试次数过多,请稍后再试" }, 429); + } + if (!record || (now - record.firstAttempt) >= LOGIN_RATE_WINDOW) { + loginAttempts.set(ip, { count: 1, firstAttempt: now }); + } else { + record.count++; + } + const body = await c.req.json<{ password: string }>(); if (!body.password || body.password !== c.env.ADMIN_PASSWORD) { return c.json({ error: "密码错误" }, 401); } - const now = Math.floor(Date.now() / 1000); + // 登录成功后清除速率限制 + loginAttempts.delete(ip); + + const now2 = Math.floor(Date.now() / 1000); const token = await sign( - { sub: "admin", iat: now, exp: now + 60 * 60 * 24 * 7 }, + { sub: "admin", iat: now2, exp: now2 + 60 * 60 * 24 * 7 }, c.env.JWT_SECRET, "HS256" ); @@ -609,26 +640,36 @@ app.delete("/api/admin/posts/:slug", async (c) => { /** 从 Markdown 内容中提取所有外链图片 URL */ function extractExternalImageUrls(content: string): string[] { const urls = new Set(); - // Markdown 格式:![alt](url) 或 ![alt](url "title") const mdRegex = /!\[[^\]]*\]\(([^\s"')]+)/g; let match; while ((match = mdRegex.exec(content)) !== null) { const url = match[1].trim(); if (url && !url.startsWith("/") && !url.startsWith("data:")) { - try { new URL(url); urls.add(url); } catch { /* 非合法 URL 跳过 */ } + try { new URL(url); urls.add(url); } catch { /* non-URL skip */ } } } - // HTML img 标签: const imgRegex = /]+src=["']([^"']+)["']/gi; while ((match = imgRegex.exec(content)) !== null) { const url = match[1].trim(); if (url && !url.startsWith("/") && !url.startsWith("data:")) { - try { new URL(url); urls.add(url); } catch { /* 跳过 */ } + try { new URL(url); urls.add(url); } catch { /* skip */ } } } return Array.from(urls); } +/** SSRF 防护:仅允许 https:// 开头的外部图片地址 */ +function isSafeImageUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:") return false; + if (/^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|0\.|169\.254\.)/.test(parsed.hostname)) return false; + return true; + } catch { + return false; + } +} + // 单篇文章:外链图片转本地 app.post("/api/admin/posts/:slug/localize-images", async (c) => { const slug = c.req.param("slug"); @@ -649,6 +690,11 @@ app.post("/api/admin/posts/:slug/localize-images", async (c) => { let content = post.content; for (const url of externalUrls) { + if (!isSafeImageUrl(url)) { + failed++; + errors.push(`${url}: 仅允许 HTTPS 外部图片地址`); + continue; + } try { const abortCtrl = new AbortController(); const timeoutId = setTimeout(() => abortCtrl.abort(), 10000); // 10秒超时 @@ -714,6 +760,7 @@ app.post("/api/admin/localize-all-images", async (c) => { let content = post.content; for (const url of externalUrls) { + if (!isSafeImageUrl(url)) { failed++; continue; } try { const resp = await fetch(url, { headers: { "User-Agent": "Monolith-Bot/1.0" } }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); @@ -993,6 +1040,19 @@ app.post("/api/admin/backup/webdav", async (c) => { url: string; username: string; password: string; path?: string; }>(); + // SSRF 防护:仅允许 https:// 的外部 URL + try { + const parsed = new URL(body.url); + if (parsed.protocol !== "https:") { + return c.json({ error: "仅允许 HTTPS 协议的 WebDAV 地址" }, 400); + } + if (/^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|0\.|169\.254\.|::1|fc)/.test(parsed.hostname)) { + return c.json({ error: "不允许内网地址" }, 400); + } + } catch { + return c.json({ error: "无效的 WebDAV 地址" }, 400); + } + const db = c.get("db"); const data = await db.exportAll(); const json = JSON.stringify(data, null, 2); @@ -1207,11 +1267,7 @@ app.post("/api/admin/pages/delete", async (c) => { return c.json({ success: true }); }); -/* ── 健康检查 ──────────────────────────────── */ -app.get("/api/health", (c) => { - return c.json({ status: "ok", timestamp: new Date().toISOString() }); -}); - +/* ── Durable Object / 导出 ──────────────────── */ export default { fetch: app.fetch, async scheduled(event: any, env: Bindings, ctx: any) { From cc9bb93b01c028827ef8cc34192fd9ec9b550ea2 Mon Sep 17 00:00:00 2001 From: one-ea Date: Sun, 19 Apr 2026 08:39:23 +0000 Subject: [PATCH 2/2] feat(privacy): add cookie consent banner, privacy policy page, and GDPR notice - Add CookieConsent component: bottom banner with accept/reject, stores consent in localStorage, fires 'cookie-consent-accepted' event for third-party script injection gating - Add /privacy route with full privacy policy page (bilingual) - Gate custom_header/footer script injection behind cookie consent: scripts only load after user accepts; non-script HTML injects immediately as before - Add GDPR warning in admin settings 'Extensions & Injection' tab - Add PRIVACY.md to repo root - Add privacy page link in cookie consent banner --- PRIVACY.md | 99 +++++++++++++++++++ client/src/app.tsx | 43 +++++--- client/src/components/cookie-consent.tsx | 65 ++++++++++++ client/src/pages/admin/settings.tsx | 3 + client/src/pages/privacy.tsx | 121 +++++++++++++++++++++++ 5 files changed, 318 insertions(+), 13 deletions(-) create mode 100644 PRIVACY.md create mode 100644 client/src/components/cookie-consent.tsx create mode 100644 client/src/pages/privacy.tsx diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..499e28c --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,99 @@ +# 隐私政策 / Privacy Policy + +最后更新 / Last updated: 2026-04-19 + +--- + +## 数据收集声明 + +Monolith 博客在您访问时可能自动收集以下非个人身份信息: + +| 数据类型 | 来源 | 用途 | 存储方式 | +|---------|------|------|---------| +| 访问页面路径 | 请求 URL | 内容统计 | 边缘数据库 | +| 访客来源国家 | Cloudflare `CF-IPCountry` 头 | 流量分析 | 边缘数据库 | +| 来源域名 | `Referer` 头 | 流量分析 | 边缘数据库 | +| 设备类型 | `User-Agent` 解析(仅区分 desktop/mobile/bot) | 响应式优化 | 边缘数据库 | +| 评论者昵称 | 用户主动填写 | 公开展示 | 边缘数据库 | + +**我们不收集:** IP 地址(仅处理为不可逆哈希用于投票去重)、邮箱地址不在公开 API 中返回。 + +## Cookie 使用 + +| Cookie | 用途 | 持续时间 | +|--------|------|---------| +| 认证 Token | 管理员登录 | 7 天 | +| `_gdpr_consent` | 记录隐私同意 | 1 年 | + +站点管理员可通过"扩展与注入"设置添加第三方分析脚本(如 Google Analytics)。此类脚本可能设置额外的 Cookie,需遵守本政策的同意机制。 + +## 第三方脚本同意机制 + +当站点配置了自定义头部/尾部脚本(如分析服务),访客将看到 Cookie 同意横幅。**在您明确同意之前,第三方脚本不会被加载。** 您可以随时撤回同意。 + +## 数据存储与处理 + +- 所有数据存储在 Cloudflare 边缘网络(Workers + D1/R2),位于就近的数据中心 +- 评论者邮箱仅用于管理员审核通知,不会在他的评论旁公开显示 +- 我们不售卖、共享或传输用户数据给第三方 + +## 数据删除请求 + +如您希望删除您的评论或相关数据,请通过以下方式联系: + +- GitHub Issue(公开请求) +- [GitHub Security Advisories](https://github.com/one-ea/Monolith/security/advisories/new)(私密请求) + +我们将在 14 个工作日内处理您的请求。 + +## 政策更新 + +本隐私政策可能不定期更新,重大变更将在博客页面上公告。继续访问本站即表示您同意本政策。 + +--- + +## Data Collection Notice + +Monolith blog may automatically collect the following non-personally identifiable information when you visit: + +| Data Type | Source | Purpose | Storage | +|-----------|--------|---------|---------| +| Page path visited | Request URL | Content analytics | Edge database | +| Visitor country | Cloudflare `CF-IPCountry` header | Traffic analysis | Edge database | +| Referrer domain | `Referer` header | Traffic analysis | Edge database | +| Device type | `User-Agent` parsing (desktop/mobile/bot only) | Responsive optimization | Edge database | +| Commenter nickname | User-provided | Public display | Edge database | + +**We do not collect:** IP addresses (only hashed for vote deduplication), email addresses are never returned in public APIs. + +## Cookie Usage + +| Cookie | Purpose | Duration | +|--------|---------|----------| +| Auth Token | Admin login | 7 days | +| `_gdpr_consent` | Records privacy consent | 1 year | + +Site administrators may add third-party analytics scripts via "Extensions & Injection" settings. Such scripts may set additional cookies and are subject to the consent mechanism described in this policy. + +## Third-Party Script Consent + +When custom header/footer scripts (e.g., analytics) are configured, visitors will see a cookie consent banner. **Third-party scripts are not loaded until you explicitly consent.** You may withdraw consent at any time. + +## Data Storage & Processing + +- All data is stored on the Cloudflare edge network (Workers + D1/R2) in nearest data centers +- Commenter emails are used only for admin review notifications and are never displayed publicly +- We do not sell, share, or transfer user data to third parties + +## Data Deletion Requests + +To request deletion of your comments or related data, please contact us via: + +- GitHub Issue (public request) +- [GitHub Security Advisories](https://github.com/one-ea/Monolith/security/advisories/new) (private request) + +We will process your request within 14 business days. + +## Policy Updates + +This privacy policy may be updated periodically. Major changes will be announced on the blog. Continued use of this site constitutes acceptance of this policy. \ No newline at end of file diff --git a/client/src/app.tsx b/client/src/app.tsx index c2d3f6b..c4397d3 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -6,6 +6,7 @@ import { Footer } from "@/components/footer"; import { SearchOverlay } from "@/components/search"; import { ProtectedRoute } from "@/components/protected-route"; import { AdminLayout } from "@/components/admin-layout"; +import { CookieConsent, getCookieConsent } from "@/components/cookie-consent"; // 代码分割 (Code Splitting) const HomePage = lazy(() => import("@/pages/home").then((m) => ({ default: m.HomePage }))); @@ -21,6 +22,7 @@ const AdminPages = lazy(() => import("@/pages/admin/pages").then((m) => ({ defau const AdminComments = lazy(() => import("@/pages/admin/comments").then((m) => ({ default: m.AdminComments }))); const AdminMedia = lazy(() => import("@/pages/admin/media").then((m) => ({ default: m.AdminMedia }))); const AdminAnalytics = lazy(() => import("@/pages/admin/analytics").then((m) => ({ default: m.AdminAnalytics }))); +const PrivacyPage = lazy(() => import("@/pages/privacy").then((m) => ({ default: m.PrivacyPage }))); const DynamicPage = lazy(() => import("@/pages/dynamic-page").then((m) => ({ default: m.DynamicPage }))); const NotFoundPage = lazy(() => import("@/pages/not-found").then((m) => ({ default: m.NotFoundPage }))); @@ -62,23 +64,36 @@ export function App() { const isAdminArea = isAdminRoot && !isEditorPage && !isLoginPage; const isPublicPage = !isAdminRoot; - // 注入自定义 header/footer 代码(仅执行一次) + // 注入自定义 header/footer 代码(需 Cookie 同意后加载第三方脚本) useEffect(() => { fetch("/api/settings/public") .then((r) => r.json()) .then((s) => { - if (s.custom_header) { - const container = document.createElement("div"); - container.id = "monolith-custom-header"; - injectHtml(container, s.custom_header); - // 将子节点移入 head - Array.from(container.childNodes).forEach((n) => document.head.appendChild(n)); - } - if (s.custom_footer) { - const container = document.createElement("div"); - container.id = "monolith-custom-footer"; - injectHtml(container, s.custom_footer); - document.body.appendChild(container); + const hasThirdParty = (s.custom_header && /