Skip to content

fix(security): harden Monolith against XSS, SSRF, info leaks, and abuse#38

Merged
one-ea merged 2 commits into
mainfrom
dev
Apr 19, 2026
Merged

fix(security): harden Monolith against XSS, SSRF, info leaks, and abuse#38
one-ea merged 2 commits into
mainfrom
dev

Conversation

@one-ea

@one-ea one-ea commented Apr 19, 2026

Copy link
Copy Markdown
Owner

Summary

Critical/High severity fixes:

  1. Stored XSS via injectHtml() — Sanitize custom_header/footer through DOMPurify, forbid inline scripts, only allow external <script src> (fixes fix: security & logic improvements (high/medium priority issues) #1)
  2. Missing security headers — Add X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, HSTS (fixes docs+chore: README 更新 + CodeRabbit AI 审查配置 #5)
  3. Comment email exposure — Remove authorEmail from public /api/posts/:slug/comments response, use DiceBear avatar by nickname instead of Gravatar hash (fixes fix(ui): mobile responsive layout #2, chore(config): 优化 Dependabot 合并策略 (启用群组构建) #14)
  4. Login brute force — Add rate limiting: 5 attempts per 15 min per IP (fixes fix(ui): dashboard mobile layout v2 #3)
  5. CORS wildcard — Reflect request Origin header instead of * in both Workers and Pages Function (fixes feat(v1.2): 流量统计、后台重构、Pages Functions 代理、Bug 修复、文档更新 #4)
  6. SSRF via WebDAV & image localization — Only allow https:// URLs, block private/internal IP ranges (fixes feat(seo): SEO 全面增强 — 爬虫预渲染 + JSON-LD + 面包屑 + 404 页面 #6, chore(deps): bump vite from 6.4.2 to 8.0.8 in /client #13)
  7. Markdown XSS via javascript: URI — Filter javascript: scheme in link renderer (fixes feat(v2.0): 完整版本更新、批量处理、版本历史与各类安全修复 #7)

Medium severity fixes:

  1. Hardcoded hash salt — Replace with REACTION_SALT env var (fixes chore(ci): bump actions/setup-node from 4 to 6 #9)
  2. .env.production in git — Add .env.* to .gitignore, remove from tracking (fixes chore(ci): bump actions/checkout from 4 to 6 #10)
  3. Production source maps — Disable with sourcemap: false (fixes chore(deps): bump typescript from 5.9.3 to 6.0.2 in /client #11)
  4. Health endpoint info leak — Remove dbProvider/storageProvider/environment (fixes chore(ci): bump the actions-updates group with 2 updates #15)

Test

  • npm run build passes
  • tsc --noEmit passes (both client & server)

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)
@coderabbitai

coderabbitai Bot commented Apr 19, 2026

Copy link
Copy Markdown
📝 Walkthrough

Summary by CodeRabbit

发布说明

  • 新功能

    • 新增隐私政策页面,详细说明数据收集、Cookie使用及用户权利
    • 新增Cookie同意横幅,在加载第三方脚本前征求用户同意
  • 安全加固

    • 增强HTML注入清理,仅允许外部脚本加载
    • 添加登录速率限制和SSRF保护机制
    • 改进CORS动态源处理
  • 变更

    • 隐藏评论中的用户电子邮件字段
    • 阻止markdown中的javascript链接
    • 禁用源代码映射生成

功能概览

本次提交实现了隐私合规性强化,包括新增隐私政策页面、Cookie 同意机制、内容安全防护(DOMPurify 消毒、XSS 防御)、服务端 CORS 动态来源、SSRF 防护、速率限制、邮箱字段隐藏等功能。环境配置调整包括禁用 source maps 和移除生产环境 API URL。

变更总览

Cohort / File(s) Summary
隐私政策与文档
PRIVACY.md, client/src/pages/privacy.tsx, client/src/pages/admin/settings.tsx
新增隐私政策文档(中英双语)和对应页面路由;在管理后台添加分析脚本注入风险提示。
Cookie 同意与 HTML 消毒
client/src/components/cookie-consent.tsx, client/src/app.tsx
实现 Cookie 同意管理(localStorage 持久化、过期校验),集成 DOMPurify 限制脚本注入仅允许外部脚本,条件化延迟第三方脚本加载。
内容安全加固
client/src/lib/markdown.ts, client/src/lib/api.ts
Markdown 渲染器拦截 javascript: URI 攻击;隐藏公开接口中的邮箱字段,仅在管理员接口返回。
评论与头像
client/src/components/comments.tsx
移除 Gravatar 依赖,改用 DiceBear 生成头像 URL。
跨域与网络安全
client/functions/api/[[path]].ts, server/src/index.ts
CORS 支持动态来源反射;添加反应速率限制(5 次/15 分钟)、图片/WebDAV 备份 SSRF 防护、安全响应头(CSP、X-Frame-Options、HSTS);精化健康检查端点。
环境与构建配置
.gitignore, client/.env.production, client/vite.config.ts
调整环境文件忽略规则、移除生产 API_URL 配置、禁用 source maps 输出。

流程图

sequenceDiagram
    participant User as 用户浏览器
    participant Client as 前端应用
    participant Storage as localStorage
    participant Event as 事件系统
    participant Script as 第三方脚本

    User->>Client: 初次访问页面
    Client->>Storage: 检查 _gdpr_consent
    alt Cookie 已过期或不存在
        Client->>User: 显示 Cookie 同意横幅
        User->>Client: 点击 "接受" 或 "拒绝"
        Client->>Storage: 保存同意记录(过期时间 1 年)
    else Cookie 已过期
        Client->>Storage: 清除过期数据
        Client->>User: 再次显示横幅
    end
    
    alt 用户接受同意
        Client->>Event: 派发 cookie-consent-accepted 事件
        Event->>Client: app.tsx 监听事件
        Client->>Client: 执行延迟的脚本注入(custom_header/footer)
        Client->>Script: 加载已消毒的第三方脚本(仅 src 属性)
        Script->>User: 在用户浏览器执行分析
    else 用户拒绝
        Client->>Client: 跳过脚本注入
    end
Loading

代码审查工作量估计

🎯 4 (Complex) | ⏱️ ~65 分钟


相关 PR

  • Monolith#31:同步锁定 DOMPurify 版本号,与本 PR 的 HTML 消毒功能直接关联
  • Monolith#7:重叠涵盖 Cookie/隐私路由、反应特性、服务端安全头等多个公共代码区域
  • Monolith#19:同步更新 Markdown 渲染器安全防护(javascript: URI 拦截和图片正则优化)

建议标签

frontend, backend, bug

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 标题遵循 Conventional Commits 格式 (type: fix, scope: security),清晰总结了主要安全加固内容。
Description check ✅ Passed PR 描述详细阐述了 11 项修复项,涵盖 XSS、SSRF、信息泄露和滥用防护,与 changeset 紧密相关。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

…PR 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
@coderabbitai coderabbitai Bot added bug Something isn't working frontend 前端 (React/Vite) 相关变更 backend 后端 (Hono Workers) 相关变更 labels Apr 19, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (4)
client/src/app.tsx (1)

31-51: 建议进一步限制 <script src> 的协议

当前 DOMPurify 允许 src,并通过 !node.src → return 阻止了内联脚本,这是很合理的纵深防御。可以再加一层 ALLOWED_URI_REGEXP: /^https:\/\//i(或在附加脚本前显式校验 new URL(node.src).protocol === "https:"),避免管理员误粘贴 http:// / //protocol-relative / blob: 脚本被页面混合加载。

♻️ 建议修改
   Array.from(temp.childNodes).forEach((node) => {
     if (node instanceof HTMLScriptElement) {
-      if (!node.src) return; // 禁止内联脚本,只允许带 src 的外部脚本
+      if (!node.src) return; // 禁止内联脚本
+      try {
+        if (new URL(node.src, location.href).protocol !== "https:") return; // 仅允许 https 外部脚本
+      } catch { return; }
       const script = document.createElement("script");
       script.src = node.src;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app.tsx` around lines 31 - 51, The injectHtml function currently
allows external scripts by src but does not restrict protocols; update
injectHtml (or the DOMPurify call) to only permit HTTPS script URLs by either
adding ALLOWED_URI_REGEXP: /^https:\/\/.*/i to the DOMPurify.sanitize config or,
before appending the created script, validate node.src with a safe URL parse
(e.g. new URL(node.src, location.href) and check url.protocol === "https:") and
skip any script whose protocol is not "https:" (also wrap the URL parse in
try/catch to skip invalid URLs).
server/src/index.ts (1)

1043-1054: 建议复用 isSafeImageUrl 以消除 SSRF 校验重复

此处内联正则与 isSafeImageUrl 逻辑高度重合,却各自覆盖不全(如这里对 localhost、IPv6 ::1/ULA/链路本地、数字编码主机同样不拦截)。抽一个通用的 isSafeExternalUrl(url, { allowedProtocols }) 统一两处逻辑,后续修一处即可。

♻️ 建议修改
-  // 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);
-  }
+  if (!isSafeImageUrl(body.url)) {
+    return c.json({ error: "WebDAV 地址必须为 HTTPS 且不能指向内网" }, 400);
+  }

(同时按上一条建议强化 isSafeImageUrl 即可同时覆盖 WebDAV。若两处错误文案需不同,可把函数返回枚举/原因。)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/src/index.ts` around lines 1043 - 1054, Replace the inline SSRF checks
with a single reusable validator: stop duplicating the hostname/protocol regex
and call a new or extended helper (either enhance isSafeImageUrl to accept
options or add isSafeExternalUrl(url, { allowedProtocols })) from this WebDAV
handler; ensure the helper validates protocol (https only when required),
rejects localhost, loopback, private IPv4 ranges, IPv6 loopback and ULA,
link‑local, numeric IPv4 host encodings, and returns either boolean or a reason
enum so this handler can return the existing localized error messages when
validation fails; update the WebDAV code to call that helper (e.g.,
isSafeExternalUrl(body.url, { allowedProtocols: ['https:'] }) or isSafeImageUrl
with options) instead of the inline URL parsing/regex.
client/src/lib/markdown.ts (1)

282-283: 建议扩展禁用的危险协议列表

当前只过滤 javascript:,但 data:vbscript:、以及 Unicode 编码变体(例如 \u0000 前缀绕过)同样可作 XSS 载荷。虽然 renderMarkdown 末尾的 DOMPurify 会兜底清理,此处作为第一道防线扩大覆盖面更稳妥。

♻️ 建议修改
-  // 防止 javascript: URI XSS
-  if (/^\s*javascript:/i.test(href)) return escapeHtml(token.text || href);
+  // 防止危险协议 URI XSS(javascript:/vbscript:/data:)
+  if (/^[\s\u0000-\u001f]*(?:javascript|vbscript|data):/i.test(href)) {
+    return escapeHtml(token.text || href);
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/lib/markdown.ts` around lines 282 - 283, The current early check
in markdown rendering only blocks "javascript:" URIs; update the
href-sanitization in the function handling token.href/token.text (the same spot
that currently uses /^\s*javascript:/i.test(href)) to also detect and reject
other dangerous schemes such as "data:" and "vbscript:" and to guard against
encoded/unicode-escaped variants (e.g., "\u0000" or percent-encoding) by
normalizing/decoding the href first (strip common escape sequences and decode
percent-encodings) then testing against a regex that matches
/^\s*(javascript|data|vbscript):/i; keep returning escapeHtml(token.text ||
href) on match and leave the existing renderMarkdown / DOMPurify pipeline intact
as a final sanitizer.
PRIVACY.md (1)

37-37: 中文表达建议微调

"不会在他的评论旁公开显示"中的"他的"指代不清且略生硬,建议改为"不会在评论旁公开显示",与英文版"are never displayed publicly"对齐。

🔧 建议修复
-- 评论者邮箱仅用于管理员审核通知,不会在他的评论旁公开显示
+- 评论者邮箱仅用于管理员审核通知,不会在评论旁公开显示
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@PRIVACY.md` at line 37, Update the Chinese sentence in PRIVACY.md that
currently reads "评论者邮箱仅用于管理员审核通知,不会在他的评论旁公开显示" to use a gender-neutral phrasing
matching the English "are never displayed publicly"—replace "不会在他的评论旁公开显示" with
"不会在评论旁公开显示" so the reference is clear and aligned.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/src/components/comments.tsx`:
- Around line 13-17: The current avatarUrl function directly requests DiceBear
and leaks visitor data; change behavior so when getCookieConsent() is false the
UI renders the local CSS fallback (use existing fallback styles instead of an
<img>), and when consent is true either (A) point avatarUrl to a new internal
endpoint like /api/avatar/initials?seed=... (implement a server handler that
returns a minimal SVG for initials) or (B) lazily load the external DiceBear URL
only after consent; update references to avatarUrl and the component that
renders the <img> to check getCookieConsent() and switch to the local
placeholder or the proxied/internal SVG endpoint accordingly (keep function name
avatarUrl and component logic consistent to locate changes).

In `@client/src/components/cookie-consent.tsx`:
- Around line 41-64: Replace the native anchor with wouter's Link for SPA
navigation (swap the <a href="/privacy"> with Link to "/privacy"), wrap any
localStorage writes performed by setConsent (the click handlers that call
setConsent and localStorage.setItem) in try/catch so failures do not prevent
setVisible(false) from running, and add accessibility attributes to the banner
container and controls: give the outer div role="region" and a descriptive
aria-label/aria-live as appropriate, and add explicit aria-label attributes to
the "拒绝" and "接受" buttons; keep the existing custom event name
("cookie-consent-accepted") unchanged.

In `@client/src/pages/privacy.tsx`:
- Around line 12-14: The two dates in the privacy page paragraph are
inconsistent — update the text node in the <p className="text-[13px]
text-muted-foreground/40 mt-[4px] mb-[24px]"> element in
client/src/pages/privacy.tsx so both languages show the same last-updated date
(use 2026-04-19 to match PRIVACY.md); ensure the line reads something like
"最后更新:2026-04-19 · Last updated: 2026-04-19" so both Chinese and English are
aligned.
- Line 12: Replace the low-contrast utility classes causing poor readability in
light mode: find uses of "text-muted-foreground" and "text-muted-foreground/60"
(e.g., the <p> at line with className "text-[13px] text-muted-foreground/40
mt-[4px] mb-[24px]" and the other occurrences) and swap them to a
higher-contrast class such as "text-secondary-foreground" or "text-foreground"
so the text meets WCAG AA in light theme; update each element using those
classes (including the ones at the other noted occurrences) to the chosen
higher-contrast utility.

In `@PRIVACY.md`:
- Around line 11-19: Add a new data row for "评论者邮箱" to the data collection table
so the policy accurately reflects that emails are collected: set 数据类型 to 评论者邮箱,
来源 to 用户主动填写, 用途 to 管理员审核通知, 存储方式 to 边缘数据库(不公开); mirror the same explicit row in
the English section (e.g., "Commenter email" — Source: user-provided; Purpose:
admin notification/review; Storage: edge DB, not public) so both language
versions are consistent with the statement that emails are not returned in the
public API.

In `@server/src/index.ts`:
- Around line 442-471: The current in-memory rate limiter (loginAttempts Map
used in app.post("/api/auth/login") with LOGIN_RATE_LIMIT and LOGIN_RATE_WINDOW)
is unsafe on edge (not shared across isolates) and can grow unbounded and trust
spoofable headers; replace it with a shared store: implement a Durable Object
(preferred) or use c.env.KV with per-IP keys + expirationTtl to store {count,
firstAttempt} (shard by IP), increment atomically on each request and reset
after LOGIN_RATE_WINDOW so entries expire and memory/KV usage is bounded, and
stop trusting X-Forwarded-For (use CF-Connecting-IP or the platform-provided
client IP). Also, when rejecting with 429 include a Retry-After header computed
from LOGIN_RATE_WINDOW - (now - firstAttempt). Ensure the new logic references
the same symbols (loginAttempts semantics, LOGIN_RATE_LIMIT, LOGIN_RATE_WINDOW,
and the app.post("/api/auth/login") handler) so tests/code paths stay
consistent.
- Around line 661-671: The SSRF check in isSafeImageUrl is bypassable; update
isSafeImageUrl to normalize the hostname by stripping surrounding brackets and
lowercasing, explicitly reject "localhost" and any host ending with
".localhost", detect IPv4 private/loopback ranges (reuse the existing regex) and
also reject IPv6 loopback (::1) and the fc00::/7 ULA range using a precise IPv6
check (do not rely on a too-broad "fc" match), and use net.isIP or equivalent to
canonicalize/validate addresses; then replace the ad-hoc WebDAV logic with a
call to this same isSafeImageUrl helper so both image and WebDAV endpoints share
the exact checks, and add the requested headers (set Retry-After on 429
responses and include Vary: Origin in CORS responses).

---

Nitpick comments:
In `@client/src/app.tsx`:
- Around line 31-51: The injectHtml function currently allows external scripts
by src but does not restrict protocols; update injectHtml (or the DOMPurify
call) to only permit HTTPS script URLs by either adding ALLOWED_URI_REGEXP:
/^https:\/\/.*/i to the DOMPurify.sanitize config or, before appending the
created script, validate node.src with a safe URL parse (e.g. new URL(node.src,
location.href) and check url.protocol === "https:") and skip any script whose
protocol is not "https:" (also wrap the URL parse in try/catch to skip invalid
URLs).

In `@client/src/lib/markdown.ts`:
- Around line 282-283: The current early check in markdown rendering only blocks
"javascript:" URIs; update the href-sanitization in the function handling
token.href/token.text (the same spot that currently uses
/^\s*javascript:/i.test(href)) to also detect and reject other dangerous schemes
such as "data:" and "vbscript:" and to guard against encoded/unicode-escaped
variants (e.g., "\u0000" or percent-encoding) by normalizing/decoding the href
first (strip common escape sequences and decode percent-encodings) then testing
against a regex that matches /^\s*(javascript|data|vbscript):/i; keep returning
escapeHtml(token.text || href) on match and leave the existing renderMarkdown /
DOMPurify pipeline intact as a final sanitizer.

In `@PRIVACY.md`:
- Line 37: Update the Chinese sentence in PRIVACY.md that currently reads
"评论者邮箱仅用于管理员审核通知,不会在他的评论旁公开显示" to use a gender-neutral phrasing matching the
English "are never displayed publicly"—replace "不会在他的评论旁公开显示" with "不会在评论旁公开显示"
so the reference is clear and aligned.

In `@server/src/index.ts`:
- Around line 1043-1054: Replace the inline SSRF checks with a single reusable
validator: stop duplicating the hostname/protocol regex and call a new or
extended helper (either enhance isSafeImageUrl to accept options or add
isSafeExternalUrl(url, { allowedProtocols })) from this WebDAV handler; ensure
the helper validates protocol (https only when required), rejects localhost,
loopback, private IPv4 ranges, IPv6 loopback and ULA, link‑local, numeric IPv4
host encodings, and returns either boolean or a reason enum so this handler can
return the existing localized error messages when validation fails; update the
WebDAV code to call that helper (e.g., isSafeExternalUrl(body.url, {
allowedProtocols: ['https:'] }) or isSafeImageUrl with options) instead of the
inline URL parsing/regex.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3aaf9aec-45d4-454e-bfc0-fca7a057e677

📥 Commits

Reviewing files that changed from the base of the PR and between 696ed6c and cc9bb93.

📒 Files selected for processing (13)
  • .gitignore
  • PRIVACY.md
  • client/.env.production
  • client/functions/api/[[path]].ts
  • client/src/app.tsx
  • client/src/components/comments.tsx
  • client/src/components/cookie-consent.tsx
  • client/src/lib/api.ts
  • client/src/lib/markdown.ts
  • client/src/pages/admin/settings.tsx
  • client/src/pages/privacy.tsx
  • client/vite.config.ts
  • server/src/index.ts
💤 Files with no reviewable changes (1)
  • client/.env.production
📜 Review details
⏰ Context from checks skipped due to timeout of 120000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: Analyze (javascript-typescript)
🧰 Additional context used
📓 Path-based instructions (4)
client/src/pages/**

⚙️ CodeRabbit configuration file

client/src/pages/**: 页面级组件。审查时请关注: 1. 数据加载和错误处理是否完善 2. SEO 相关(页面标题、meta 标签) 3. 导航和路由是否正确

Files:

  • client/src/pages/admin/settings.tsx
  • client/src/pages/privacy.tsx
client/src/components/**

⚙️ CodeRabbit configuration file

client/src/components/**: 这是 React 前端组件目录。审查时请关注: 1. 是否同时兼容暗色和亮色主题(检查 CSS 变量和 data-theme) 2. 响应式布局是否完整(移动端/平板/桌面端) 3. 无障碍访问(aria 标签、键盘导航) 4. 组件是否保持单一职责

Files:

  • client/src/components/comments.tsx
  • client/src/components/cookie-consent.tsx
client/functions/**

⚙️ CodeRabbit configuration file

client/functions/**: Cloudflare Pages Functions(API 反向代理层)。审查时请关注: 1. 代理目标 URL 是否正确构建 2. 请求头的传递和清理 3. 错误响应处理

Files:

  • client/functions/api/[[path]].ts
server/src/index.ts

⚙️ CodeRabbit configuration file

server/src/index.ts: Hono Workers API 路由总入口。审查时请关注: 1. JWT 认证中间件是否正确保护管理接口 2. CORS 配置是否安全 3. 请求参数验证

Files:

  • server/src/index.ts
🔇 Additional comments (5)
.gitignore (1)

13-14: 这组忽略规则设计正确,符合安全目标。

.env.* 配合 !.env.example 能有效防止环境配置泄露,同时保留示例模板用于开发指引;与当前通过 CI/Cloudflare Secrets 注入敏感变量的方式一致。

client/vite.config.ts (1)

71-74: 符合安全加固目标。

显式关闭构建 sourcemap 可以避免生产产物泄露源码结构和实现细节;该改动简洁且与 PR 目标一致,未发现问题。

client/functions/api/[[path]].ts (1)

10-53: LGTM:动态 Origin + Vary: Origin 处理一致

预检、错误分支和代理响应三处都显式设置了 Access-Control-Allow-OriginVary: Origin,避免了缓存中毒;且未设置 Access-Control-Allow-Credentials,因此反射 Origin 不会被滥用升级为可凭据跨域。与 server/src/index.ts 的 CORS 行为保持对齐。

client/src/lib/api.ts (1)

298-302: LGTM:类型分层与后端剥离行为一致

CommentData 去掉 authorEmailAdminComment 保留,刚好对应后端 /api/posts/:slug/comments 剥离字段、/api/admin/comments 保留字段的新行为。

client/src/pages/admin/settings.tsx (1)

302-304: LGTM:GDPR 提示位置与 Cookie 同意流程互相印证

提示文案明确声明第三方脚本需在获得同意后加载,与 client/src/app.tsx 中对 <script> 检测 + cookie-consent-accepted 事件延迟注入的实现形成一致闭环。暗/亮主题下 amber-500/20 边框与 amber-400/80 文字对比度均可接受。

Comment on lines +13 to 17
/** 通过昵称生成 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}`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

头像请求会把访客信息泄漏给第三方 DiceBear

本 PR 主线强调 GDPR 合规并为第三方脚本加了 Cookie 同意门禁;但此处每条评论的 <img> 依然直接打到 api.dicebear.com,会泄露访客 IP / UA / Referer,等同未经同意的第三方追踪载体。

建议二选一:

  1. 服务端代理或本地生成:后端写一个 /api/avatar/initials?seed=... 返回 SVG(只要两行 SVG 即可生成 initials),彻底去掉外部依赖;
  2. Cookie 同意门禁:在 getCookieConsent()false 时渲染纯本地的 CSS 占位圆形(当前文件本就有类似 fallback 样式),同意后再懒加载 DiceBear。

方案 1 顺带省掉一次 HTTPS 握手,移动端性能也更好。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/components/comments.tsx` around lines 13 - 17, The current
avatarUrl function directly requests DiceBear and leaks visitor data; change
behavior so when getCookieConsent() is false the UI renders the local CSS
fallback (use existing fallback styles instead of an <img>), and when consent is
true either (A) point avatarUrl to a new internal endpoint like
/api/avatar/initials?seed=... (implement a server handler that returns a minimal
SVG for initials) or (B) lazily load the external DiceBear URL only after
consent; update references to avatarUrl and the component that renders the <img>
to check getCookieConsent() and switch to the local placeholder or the
proxied/internal SVG endpoint accordingly (keep function name avatarUrl and
component logic consistent to locate changes).

Comment on lines +41 to +64
return (
<div className="fixed bottom-0 left-0 right-0 z-[9999] p-[12px] animate-fade-in">
<div className="mx-auto max-w-[720px] rounded-xl border border-border/30 bg-card/95 backdrop-blur-md shadow-lg shadow-black/20 px-[20px] py-[16px] flex flex-col sm:flex-row items-start sm:items-center gap-[12px]">
<div className="flex-1 text-[13px] text-muted-foreground/80 leading-[1.6]">
本站使用 Cookie 进行访问统计与第三方脚本加载。继续访问即表示您同意我们的{" "}
<a href="/privacy" className="text-foreground/70 underline underline-offset-2 hover:text-foreground transition-colors">隐私政策</a>。
</div>
<div className="flex gap-[8px] shrink-0">
<button
onClick={() => { setConsent("rejected"); setVisible(false); }}
className="px-[14px] py-[6px] text-[12px] rounded-md border border-border/30 text-muted-foreground/60 hover:text-foreground hover:border-border/50 transition-all"
>
拒绝
</button>
<button
onClick={() => { setConsent("accepted"); window.dispatchEvent(new Event("cookie-consent-accepted")); setVisible(false); }}
className="px-[14px] py-[6px] text-[12px] rounded-md bg-foreground text-background font-medium hover:bg-foreground/80 transition-all"
>
接受
</button>
</div>
</div>
</div>
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

建议增强无障碍标记、SPA 导航与错误容错

  • Line 46: 在基于 wouter 的 SPA 中使用原生 <a href="/privacy"> 会触发整页刷新,丢失应用内状态。应改用 wouter 的 Link
  • Line 50/56: setConsent 直接访问 localStorage.setItem,在隐私/禁用存储模式下会抛出,导致按钮事件中断、横幅永远无法关闭。应包 try/catch,即使写入失败也应关闭横幅。
  • 整个横幅缺少 role="region" / aria-label,按钮语义层仅有中文文本节点,建议补齐 aria-label 与 live region 语义;参考 WCAG,同意横幅宜使用可聚焦 focus trap 或至少明确标记。
♻️ 建议修改
-import { useEffect, useState } from "react";
+import { useEffect, useState } from "react";
+import { Link } from "wouter";
@@
 function setConsent(value: "accepted" | "rejected") {
-  localStorage.setItem(CONSENT_KEY, JSON.stringify({
-    value,
-    expires: Date.now() + CONSENT_EXPIRY_DAYS * 86400000,
-  }));
+  try {
+    localStorage.setItem(CONSENT_KEY, JSON.stringify({
+      value,
+      expires: Date.now() + CONSENT_EXPIRY_DAYS * 86400000,
+    }));
+  } catch {
+    /* 存储不可用时静默失败,横幅仍需关闭 */
+  }
 }
@@
-    <div className="fixed bottom-0 left-0 right-0 z-[9999] p-[12px] animate-fade-in">
+    <div
+      className="fixed bottom-0 left-0 right-0 z-[9999] p-[12px] animate-fade-in"
+      role="region"
+      aria-label="Cookie 同意"
+    >
@@
-          <a href="/privacy" className="text-foreground/70 underline underline-offset-2 hover:text-foreground transition-colors">隐私政策</a>。
+          <Link href="/privacy" className="text-foreground/70 underline underline-offset-2 hover:text-foreground transition-colors">隐私政策</Link>。

根据代码规范:“无障碍访问(aria 标签、键盘导航)”、“响应式布局”。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/components/cookie-consent.tsx` around lines 41 - 64, Replace the
native anchor with wouter's Link for SPA navigation (swap the <a
href="/privacy"> with Link to "/privacy"), wrap any localStorage writes
performed by setConsent (the click handlers that call setConsent and
localStorage.setItem) in try/catch so failures do not prevent setVisible(false)
from running, and add accessibility attributes to the banner container and
controls: give the outer div role="region" and a descriptive
aria-label/aria-live as appropriate, and add explicit aria-label attributes to
the "拒绝" and "接受" buttons; keep the existing custom event name
("cookie-consent-accepted") unchanged.

Comment on lines +12 to +14
<p className="text-[13px] text-muted-foreground/40 mt-[4px] mb-[24px]">
最后更新:2025-04-19 · Last updated: 2026-04-19
</p>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

最后更新日期中英文不一致

第 13 行显示"最后更新:2025-04-19 · Last updated: 2026-04-19",中英文年份不一致,疑为笔误。PRIVACY.md 中仅为 2026-04-19,建议对齐。

🔧 建议修复
-        最后更新:2025-04-19 · Last updated: 2026-04-19
+        最后更新:2026-04-19 · Last updated: 2026-04-19
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<p className="text-[13px] text-muted-foreground/40 mt-[4px] mb-[24px]">
最后更新:2025-04-19 · Last updated: 2026-04-19
</p>
<p className="text-[13px] text-muted-foreground/40 mt-[4px] mb-[24px]">
最后更新:2026-04-19 · Last updated: 2026-04-19
</p>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/pages/privacy.tsx` around lines 12 - 14, The two dates in the
privacy page paragraph are inconsistent — update the text node in the <p
className="text-[13px] text-muted-foreground/40 mt-[4px] mb-[24px]"> element in
client/src/pages/privacy.tsx so both languages show the same last-updated date
(use 2026-04-19 to match PRIVACY.md); ensure the line reads something like
"最后更新:2026-04-19 · Last updated: 2026-04-19" so both Chinese and English are
aligned.

url="/privacy"
/>
<h1 className="text-[28px] font-semibold tracking-[-0.02em]">隐私政策</h1>
<p className="text-[13px] text-muted-foreground/40 mt-[4px] mb-[24px]">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and read its content
cat -n client/src/pages/privacy.tsx | head -120

Repository: one-ea/Monolith

Length of output: 5634


🏁 Script executed:

# Find Tailwind configuration file
fd -e "config\.[jt]s" -e "tailwind\.[jt]s" --type f | head -20

Repository: one-ea/Monolith

Length of output: 41


🏁 Script executed:

# Search for tailwind config in different locations
find . -name "tailwind.config.*" -o -name "tailwind.ts" -o -name "tailwind.js" 2>/dev/null

Repository: one-ea/Monolith

Length of output: 41


🏁 Script executed:

# Look for CSS files and color definitions
find client -type f \( -name "*.css" -o -name "*.scss" \) | head -20

Repository: one-ea/Monolith

Length of output: 81


🏁 Script executed:

# Read the globals.css to understand color definitions
cat -n client/src/globals.css

Repository: one-ea/Monolith

Length of output: 50371


🏁 Script executed:

# Verify the exact color values and line 111
sed -n '54,57p; 110,112p' client/src/pages/privacy.tsx

Repository: one-ea/Monolith

Length of output: 347


🏁 Script executed:

# Calculate WCAG AA contrast ratios for the color combinations
python3 << 'EOF'
import math

# Convert OKLch to Linear RGB
def oklch_to_linear_rgb(L, C, h):
    h_rad = math.radians(h)
    a = C * math.cos(h_rad)
    b = C * math.sin(h_rad)
    
    # OKLab to LMS
    l_ = L + 0.3963377774 * a + 0.2158037573 * b
    m_ = L - 0.1055613458 * a - 0.0638541728 * b
    s_ = L - 0.0894841775 * a - 1.2914855480 * b
    
    l = l_ * l_ * l_
    m = m_ * m_ * m_
    s = s_ * s_ * s_
    
    # LMS to Linear RGB
    r = +4.0767416621 * l - 3.3077363322 * m + 0.2309101289 * s
    g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193761 * s
    b_out = -0.0041960771 * l - 0.7034186147 * m + 1.7076147010 * s
    
    return (r, g, b_out)

# sRGB gamma correction
def linear_to_srgb(c):
    if c <= 0.0031308:
        return 12.92 * c
    else:
        return (1.0 + 0.055) * (c ** (1/2.4)) - 0.055

# Calculate relative luminance
def get_luminance(r, g, b):
    r = linear_to_srgb(r)
    g = linear_to_srgb(g)
    b = linear_to_srgb(b)
    
    r = max(0, min(1, r))
    g = max(0, min(1, g))
    b = max(0, min(1, b))
    
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

# Blend colors with alpha
def blend_color(fg_rgb, bg_rgb, alpha):
    return tuple(fg_rgb[i] * alpha + bg_rgb[i] * (1 - alpha) for i in range(3))

# Calculate contrast ratio
def contrast_ratio(L1, L2):
    light = max(L1, L2)
    dark = min(L1, L2)
    return (light + 0.05) / (dark + 0.05)

# Light theme colors
muted_fg_oklch = (0.46, 0.01, 250)
background_oklch = (0.975, 0.002, 250)

muted_fg_rgb = oklch_to_linear_rgb(*muted_fg_oklch)
background_rgb = oklch_to_linear_rgb(*background_oklch)

# Calculate luminance for background
bg_luminance = get_luminance(*background_rgb)
print(f"Background luminance: {bg_luminance:.4f}")

# Calculate for /40 opacity
fg_40_rgb = blend_color(muted_fg_rgb, background_rgb, 0.4)
fg_40_luminance = get_luminance(*fg_40_rgb)
ratio_40 = contrast_ratio(fg_40_luminance, bg_luminance)
print(f"\ntext-muted-foreground/40 (40% opacity):")
print(f"  Luminance: {fg_40_luminance:.4f}")
print(f"  Contrast ratio: {ratio_40:.2f}:1")
print(f"  WCAG AA (normal text, 4.5:1): {'PASS' if ratio_40 >= 4.5 else 'FAIL'}")
print(f"  WCAG AA (large text, 3:1): {'PASS' if ratio_40 >= 3 else 'FAIL'}")

# Calculate for /60 opacity
fg_60_rgb = blend_color(muted_fg_rgb, background_rgb, 0.6)
fg_60_luminance = get_luminance(*fg_60_rgb)
ratio_60 = contrast_ratio(fg_60_luminance, bg_luminance)
print(f"\ntext-muted-foreground/60 (60% opacity):")
print(f"  Luminance: {fg_60_luminance:.4f}")
print(f"  Contrast ratio: {ratio_60:.2f}:1")
print(f"  WCAG AA (normal text, 4.5:1): {'PASS' if ratio_60 >= 4.5 else 'FAIL'}")
print(f"  WCAG AA (large text, 3:1): {'PASS' if ratio_60 >= 3 else 'FAIL'}")

# Calculate for /70 opacity (suggested fix)
fg_70_rgb = blend_color(muted_fg_rgb, background_rgb, 0.7)
fg_70_luminance = get_luminance(*fg_70_rgb)
ratio_70 = contrast_ratio(fg_70_luminance, bg_luminance)
print(f"\ntext-muted-foreground/70 (70% opacity - suggested):")
print(f"  Luminance: {fg_70_luminance:.4f}")
print(f"  Contrast ratio: {ratio_70:.2f}:1")
print(f"  WCAG AA (normal text, 4.5:1): {'PASS' if ratio_70 >= 4.5 else 'FAIL'}")

# Note about text size
print(f"\nNote: Text size in file is 13px (text-[13px]), which is normal text")
print(f"WCAG AA standard for normal text requires 4.5:1 contrast ratio")
EOF

Repository: one-ea/Monolith

Length of output: 668


🏁 Script executed:

# Check if using text-muted-foreground without opacity would pass
python3 << 'EOF'
import math

def oklch_to_linear_rgb(L, C, h):
    h_rad = math.radians(h)
    a = C * math.cos(h_rad)
    b = C * math.sin(h_rad)
    
    l_ = L + 0.3963377774 * a + 0.2158037573 * b
    m_ = L - 0.1055613458 * a - 0.0638541728 * b
    s_ = L - 0.0894841775 * a - 1.2914855480 * b
    
    l = l_ * l_ * l_
    m = m_ * m_ * m_
    s = s_ * s_ * s_
    
    r = +4.0767416621 * l - 3.3077363322 * m + 0.2309101289 * s
    g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193761 * s
    b_out = -0.0041960771 * l - 0.7034186147 * m + 1.7076147010 * s
    
    return (r, g, b_out)

def linear_to_srgb(c):
    if c <= 0.0031308:
        return 12.92 * c
    else:
        return (1.0 + 0.055) * (c ** (1/2.4)) - 0.055

def get_luminance(r, g, b):
    r = linear_to_srgb(r)
    g = linear_to_srgb(g)
    b = linear_to_srgb(b)
    
    r = max(0, min(1, r))
    g = max(0, min(1, g))
    b = max(0, min(1, b))
    
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def contrast_ratio(L1, L2):
    light = max(L1, L2)
    dark = min(L1, L2)
    return (light + 0.05) / (dark + 0.05)

# Light theme: muted-foreground (full opacity, 100%)
muted_fg_oklch = (0.46, 0.01, 250)
background_oklch = (0.975, 0.002, 250)

muted_fg_rgb = oklch_to_linear_rgb(*muted_fg_oklch)
background_rgb = oklch_to_linear_rgb(*background_oklch)

muted_fg_luminance = get_luminance(*muted_fg_rgb)
bg_luminance = get_luminance(*background_rgb)

ratio_100 = contrast_ratio(muted_fg_luminance, bg_luminance)
print(f"text-muted-foreground (100% opacity - no /opacity):")
print(f"  Luminance: {muted_fg_luminance:.4f}")
print(f"  Contrast ratio: {ratio_100:.2f}:1")
print(f"  WCAG AA (normal text, 4.5:1): {'PASS' if ratio_100 >= 4.5 else 'FAIL'}")

# Dark theme test - for comparison
dark_muted_fg_oklch = (0.78, 0, 220)
dark_background_oklch = (0.18, 0.006, 220)

dark_muted_fg_rgb = oklch_to_linear_rgb(*dark_muted_fg_oklch)
dark_background_rgb = oklch_to_linear_rgb(*dark_background_oklch)

dark_muted_fg_luminance = get_luminance(*dark_muted_fg_rgb)
dark_bg_luminance = get_luminance(*dark_background_rgb)

dark_ratio_60 = contrast_ratio(dark_muted_fg_luminance, dark_bg_luminance)
print(f"\nDark theme - text-muted-foreground/60:")
print(f"  Contrast ratio: {dark_ratio_60:.2f}:1")
print(f"  WCAG AA (normal text, 4.5:1): {'PASS' if dark_ratio_60 >= 4.5 else 'FAIL'}")
EOF

Repository: one-ea/Monolith

Length of output: 294


🏁 Script executed:

# Find what opacity level would be needed to pass WCAG AA
python3 << 'EOF'
import math

def oklch_to_linear_rgb(L, C, h):
    h_rad = math.radians(h)
    a = C * math.cos(h_rad)
    b = C * math.sin(h_rad)
    
    l_ = L + 0.3963377774 * a + 0.2158037573 * b
    m_ = L - 0.1055613458 * a - 0.0638541728 * b
    s_ = L - 0.0894841775 * a - 1.2914855480 * b
    
    l = l_ * l_ * l_
    m = m_ * m_ * m_
    s = s_ * s_ * s_
    
    r = +4.0767416621 * l - 3.3077363322 * m + 0.2309101289 * s
    g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193761 * s
    b_out = -0.0041960771 * l - 0.7034186147 * m + 1.7076147010 * s
    
    return (r, g, b_out)

def linear_to_srgb(c):
    if c <= 0.0031308:
        return 12.92 * c
    else:
        return (1.0 + 0.055) * (c ** (1/2.4)) - 0.055

def get_luminance(r, g, b):
    r = linear_to_srgb(r)
    g = linear_to_srgb(g)
    b = linear_to_srgb(b)
    
    r = max(0, min(1, r))
    g = max(0, min(1, g))
    b = max(0, min(1, b))
    
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def blend_color(fg_rgb, bg_rgb, alpha):
    return tuple(fg_rgb[i] * alpha + bg_rgb[i] * (1 - alpha) for i in range(3))

def contrast_ratio(L1, L2):
    light = max(L1, L2)
    dark = min(L1, L2)
    return (light + 0.05) / (dark + 0.05)

muted_fg_oklch = (0.46, 0.01, 250)
background_oklch = (0.975, 0.002, 250)

muted_fg_rgb = oklch_to_linear_rgb(*muted_fg_oklch)
background_rgb = oklch_to_linear_rgb(*background_oklch)
bg_luminance = get_luminance(*background_rgb)

# Find required opacity for 4.5:1 contrast
print("Finding opacity needed for 4.5:1 WCAG AA contrast in light theme:\n")
for opacity in range(100, 0, -5):
    alpha = opacity / 100
    fg_rgb = blend_color(muted_fg_rgb, background_rgb, alpha)
    fg_luminance = get_luminance(*fg_rgb)
    ratio = contrast_ratio(fg_luminance, bg_luminance)
    status = "✓ PASS" if ratio >= 4.5 else "✗ FAIL"
    print(f"  /{opacity}: {ratio:.2f}:1 {status}")
    if ratio >= 4.5:
        break
else:
    print("\n  → Even at 100% opacity, muted-foreground cannot achieve 4.5:1")
    print("  → A fundamentally darker color is needed for light theme\n")
EOF

Repository: one-ea/Monolith

Length of output: 670


🏁 Script executed:

# Check if there are any other color variables that might work better
grep -A 50 "Light theme" client/src/globals.css | grep "muted\|foreground" | head -20

Repository: one-ea/Monolith

Length of output: 41


🏁 Script executed:

# Extract light theme color definitions
sed -n '91,125p' client/src/globals.css

Repository: one-ea/Monolith

Length of output: 1320


🏁 Script executed:

# Test if using foreground color would pass
python3 << 'EOF'
import math

def oklch_to_linear_rgb(L, C, h):
    h_rad = math.radians(h)
    a = C * math.cos(h_rad)
    b = C * math.sin(h_rad)
    
    l_ = L + 0.3963377774 * a + 0.2158037573 * b
    m_ = L - 0.1055613458 * a - 0.0638541728 * b
    s_ = L - 0.0894841775 * a - 1.2914855480 * b
    
    l = l_ * l_ * l_
    m = m_ * m_ * m_
    s = s_ * s_ * s_
    
    r = +4.0767416621 * l - 3.3077363322 * m + 0.2309101289 * s
    g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193761 * s
    b_out = -0.0041960771 * l - 0.7034186147 * m + 1.7076147010 * s
    
    return (r, g, b_out)

def linear_to_srgb(c):
    if c <= 0.0031308:
        return 12.92 * c
    else:
        return (1.0 + 0.055) * (c ** (1/2.4)) - 0.055

def get_luminance(r, g, b):
    r = linear_to_srgb(r)
    g = linear_to_srgb(g)
    b = linear_to_srgb(b)
    
    r = max(0, min(1, r))
    g = max(0, min(1, g))
    b = max(0, min(1, b))
    
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def contrast_ratio(L1, L2):
    light = max(L1, L2)
    dark = min(L1, L2)
    return (light + 0.05) / (dark + 0.05)

background_oklch = (0.975, 0.002, 250)
background_rgb = oklch_to_linear_rgb(*background_oklch)
bg_luminance = get_luminance(*background_rgb)

# Test alternatives
colors = {
    "foreground": (0.16, 0.006, 250),
    "secondary-foreground": (0.20, 0, 250),
    "muted-foreground": (0.46, 0.01, 250),
}

print("Light theme alternatives for small text (13px, needs 4.5:1):\n")
for name, oklch in colors.items():
    rgb = oklch_to_linear_rgb(*oklch)
    lum = get_luminance(*rgb)
    ratio = contrast_ratio(lum, bg_luminance)
    status = "✓ PASS" if ratio >= 4.5 else "✗ FAIL"
    print(f"{name:25} {ratio:.2f}:1 {status}")
EOF

Repository: one-ea/Monolith

Length of output: 241


🏁 Script executed:

# Check if there are SEO-related concerns mentioned in the file (as per coding guidelines)
head -15 client/src/pages/privacy.tsx

Repository: one-ea/Monolith

Length of output: 629


浅色主题下文本对比度不足,建议改用更深色的文本颜色

第 12、55、111 行的 text-muted-foregroundtext-muted-foreground/60 在亮色主题下存在可读性问题。经验证,即使使用 100% 不透明度,该颜色仍无法满足 WCAG AA 对比度要求(正常文本需要 4.5:1,目前仅 2.58:1)。调整不透明度(如 /70)无法解决此问题,因为颜色本身过浅。

建议改用:

  • text-foreground(对比度 10:1)
  • text-secondary-foreground(对比度 7.47:1)

深色主题不受影响(/60 对比度 6.46:1,符合标准)。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/pages/privacy.tsx` at line 12, Replace the low-contrast utility
classes causing poor readability in light mode: find uses of
"text-muted-foreground" and "text-muted-foreground/60" (e.g., the <p> at line
with className "text-[13px] text-muted-foreground/40 mt-[4px] mb-[24px]" and the
other occurrences) and swap them to a higher-contrast class such as
"text-secondary-foreground" or "text-foreground" so the text meets WCAG AA in
light theme; update each element using those classes (including the ones at the
other noted occurrences) to the chosen higher-contrast utility.

Comment thread PRIVACY.md
Comment on lines +11 to +19
| 数据类型 | 来源 | 用途 | 存储方式 |
|---------|------|------|---------|
| 访问页面路径 | 请求 URL | 内容统计 | 边缘数据库 |
| 访客来源国家 | Cloudflare `CF-IPCountry` 头 | 流量分析 | 边缘数据库 |
| 来源域名 | `Referer` 头 | 流量分析 | 边缘数据库 |
| 设备类型 | `User-Agent` 解析(仅区分 desktop/mobile/bot) | 响应式优化 | 边缘数据库 |
| 评论者昵称 | 用户主动填写 | 公开展示 | 边缘数据库 |

**我们不收集:** IP 地址(仅处理为不可逆哈希用于投票去重)、邮箱地址不在公开 API 中返回。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

数据收集表未列出"评论者邮箱"条目

第 19 行声明邮箱"不在公开 API 中返回",且第 37 行说明邮箱用于管理员通知,说明系统确实收集并存储邮箱。为符合 GDPR 的透明性要求,建议在数据收集表中显式列出"评论者邮箱"一项(来源:用户主动填写;用途:管理员审核通知;存储:边缘数据库,不公开),避免用户误以为未收集。英文版同理(第 59-67 行)。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@PRIVACY.md` around lines 11 - 19, Add a new data row for "评论者邮箱" to the data
collection table so the policy accurately reflects that emails are collected:
set 数据类型 to 评论者邮箱, 来源 to 用户主动填写, 用途 to 管理员审核通知, 存储方式 to 边缘数据库(不公开); mirror the
same explicit row in the English section (e.g., "Commenter email" — Source:
user-provided; Purpose: admin notification/review; Storage: edge DB, not public)
so both language versions are consistent with the statement that emails are not
returned in the public API.

Comment thread server/src/index.ts
Comment on lines +442 to +471
const loginAttempts = new Map<string, { count: number; firstAttempt: number }>();
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

速率限制依赖进程内 Map,在 Workers 边缘场景等同被绕过

Cloudflare Workers 每个隔离实例都有独立内存,且随机调度到各边缘节点。loginAttempts 这张 Map:

  1. 不跨实例共享:攻击者发到不同节点即可重置计数;单节点内也可能因 isolate 回收清零。
  2. 无淘汰机制:随 IP 数量无限增长,长期会压占 isolate 内存。
  3. X-Forwarded-For 回退可被伪造:在非 CF 入站场景下,攻击者可任意伪造 key。

建议改用 Durable Object(推荐,强一致计数)或 KV + TTL,作为鉴权前的一道真实限流。同时建议在 429 响应中附加 Retry-After 头,便于合规客户端退避。

🛡️ 建议方向
// 方向 A:Durable Object 保存 { count, firstAttempt },按 IP 分片
// 方向 B:使用 c.env.KV.put(key, "1", { expirationTtl: 900 }) + 计数累加

// 临时加固(仍为 in-memory,但至少避免无限增长 + 加 Retry-After)
if (record && record.count >= LOGIN_RATE_LIMIT && (now - record.firstAttempt) < LOGIN_RATE_WINDOW) {
  const retryAfter = Math.ceil((LOGIN_RATE_WINDOW - (now - record.firstAttempt)) / 1000);
  c.header("Retry-After", String(retryAfter));
  return c.json({ error: "尝试次数过多,请稍后再试" }, 429);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/src/index.ts` around lines 442 - 471, The current in-memory rate
limiter (loginAttempts Map used in app.post("/api/auth/login") with
LOGIN_RATE_LIMIT and LOGIN_RATE_WINDOW) is unsafe on edge (not shared across
isolates) and can grow unbounded and trust spoofable headers; replace it with a
shared store: implement a Durable Object (preferred) or use c.env.KV with per-IP
keys + expirationTtl to store {count, firstAttempt} (shard by IP), increment
atomically on each request and reset after LOGIN_RATE_WINDOW so entries expire
and memory/KV usage is bounded, and stop trusting X-Forwarded-For (use
CF-Connecting-IP or the platform-provided client IP). Also, when rejecting with
429 include a Retry-After header computed from LOGIN_RATE_WINDOW - (now -
firstAttempt). Ensure the new logic references the same symbols (loginAttempts
semantics, LOGIN_RATE_LIMIT, LOGIN_RATE_WINDOW, and the
app.post("/api/auth/login") handler) so tests/code paths stay consistent.

Comment thread server/src/index.ts
Comment on lines +661 to +671
/** 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;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and get its size
wc -l server/src/index.ts

Repository: one-ea/Monolith

Length of output: 83


🏁 Script executed:

# Examine the code around lines 661-671
sed -n '650,700p' server/src/index.ts

Repository: one-ea/Monolith

Length of output: 1560


🏁 Script executed:

# Search for the isSafeImageUrl function definition and any WebDAV-related checks
rg -n "isSafeImageUrl|WebDAV" server/src/index.ts -A 5 -B 2

Repository: one-ea/Monolith

Length of output: 2252


🏁 Script executed:

# Look for rate limiting implementation
rg -n "rate|limit|429|Retry-After" server/src/index.ts -i -A 3

Repository: one-ea/Monolith

Length of output: 1271


🏁 Script executed:

# Check CORS configuration in the file
rg -n "cors|CORS|origin|Origin" server/src/index.ts -i -A 5 | head -80

Repository: one-ea/Monolith

Length of output: 1842


🏁 Script executed:

# Check JWT usage on admin endpoints
rg -n "verify.*jwt|JWT|c.get.*auth|middleware" server/src/index.ts -A 2 | head -60

Repository: one-ea/Monolith

Length of output: 804


🏁 Script executed:

# Look for the actual middleware that protects admin endpoints
rg -n "/api/admin" server/src/index.ts | head -20

Repository: one-ea/Monolith

Length of output: 1180


🏁 Script executed:

# Check if there's any JWT verification middleware
rg -n "app.use.*admin|authMiddleware" server/src/index.ts -B 2 -A 5

Repository: one-ea/Monolith

Length of output: 299


🏁 Script executed:

# Examine the exact rate limiting implementation and Map usage
sed -n '443,500p' server/src/index.ts

Repository: one-ea/Monolith

Length of output: 1673


🏁 Script executed:

# Check if Retry-After is set anywhere in the codebase
rg -n "Retry-After" server/src/

Repository: one-ea/Monolith

Length of output: 41


🏁 Script executed:

# Test URL parsing behavior with edge cases
node <<'EOF'
// Test various URL formats to understand hostname parsing
const testUrls = [
  'https://localhost/',
  'https://127.0.0.1/',
  'https://2130706433/',  // decimal IPv4
  'https://0x7f000001/',  // hex IPv4
  'https://::1/',         // IPv6 loopback
  'https://[::1]/',       // IPv6 loopback with brackets
  'https://fc00::1/',     // IPv6 ULA
  'https://0.0.0.0/',
];

console.log('URL hostname parsing:');
testUrls.forEach(url => {
  try {
    const parsed = new URL(url);
    console.log(`${url.padEnd(30)} -> hostname: "${parsed.hostname}"`);
  } catch(e) {
    console.log(`${url.padEnd(30)} -> ERROR: ${e.message}`);
  }
});

// Test regex matching from isSafeImageUrl
const ipv4Regex = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|0\.|169\.254\.)/;
console.log('\nIPv4 regex matches:');
['127.0.0.1', '2130706433', '0x7f000001', 'localhost', '::1', 'fc00::1', '[::1]'].forEach(host => {
  console.log(`${host.padEnd(20)} -> matches: ${ipv4Regex.test(host)}`);
});

// Test WebDAV regex
const webdavRegex = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|127\.|0\.|169\.254\.|::1|fc)/;
console.log('\nWebDAV regex matches:');
['127.0.0.1', 'localhost', '::1', 'fc00::1', '[::1]', 'fc12::1'].forEach(host => {
  console.log(`${host.padEnd(20)} -> matches: ${webdavRegex.test(host)}`);
});
EOF

Repository: one-ea/Monolith

Length of output: 1062


🏁 Script executed:

# Check the full WebDAV validation logic more carefully
sed -n '1037,1087p' server/src/index.ts

Repository: one-ea/Monolith

Length of output: 1851


🏁 Script executed:

# Verify if there's a global loginAttempts Map declaration
rg -n "loginAttempts" server/src/index.ts -B 2 -A 2

Repository: one-ea/Monolith

Length of output: 855


🏁 Script executed:

# Check if Vary header is set in CORS or security middleware
sed -n '36,60p' server/src/index.ts

Repository: one-ea/Monolith

Length of output: 964


SSRF 检查存在绕过风险,需补充防护

实际测试表明 isSafeImageUrl 的不足:

  1. localhost 字面量未拦截https://localhost/... 直接通过,可访问本地服务。
  2. IPv6 带括号形式未拦截https://[::1]/... 的主机名为 [::1](包含括号),不匹配现有正则。
  3. 十进制/十六进制 IPv4 已正确处理new URL() 自动将 21307064330x7f000001 标准化为 127.0.0.1,现有正则可捕获。

WebDAV 端点(第 1049 行)虽包含 ::1fc 前缀检查,但 fc 匹配过于宽泛(不精确覆盖 fc00::/7),且同样缺少 localhost[::1] 检查。建议统一到一个可复用的校验函数并补充边界情况。

相关生产安全建议

  • 添加 localhost.localhost 后缀拒绝
  • 明确拒绝 IPv6 带括号形式(用 .replace(/^\[|\]$/g, "") 标准化)
  • 补充 Retry-After 响应头(429 状态码)
  • CORS 配置添加 Vary: Origin 头,优化缓存策略
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/src/index.ts` around lines 661 - 671, The SSRF check in isSafeImageUrl
is bypassable; update isSafeImageUrl to normalize the hostname by stripping
surrounding brackets and lowercasing, explicitly reject "localhost" and any host
ending with ".localhost", detect IPv4 private/loopback ranges (reuse the
existing regex) and also reject IPv6 loopback (::1) and the fc00::/7 ULA range
using a precise IPv6 check (do not rely on a too-broad "fc" match), and use
net.isIP or equivalent to canonicalize/validate addresses; then replace the
ad-hoc WebDAV logic with a call to this same isSafeImageUrl helper so both image
and WebDAV endpoints share the exact checks, and add the requested headers (set
Retry-After on 429 responses and include Vary: Origin in CORS responses).

@one-ea one-ea merged commit 3aaa008 into main Apr 19, 2026
10 checks passed
@github-actions github-actions Bot mentioned this pull request May 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend 后端 (Hono Workers) 相关变更 bug Something isn't working frontend 前端 (React/Vite) 相关变更

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant