Conversation
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)
📝 WalkthroughSummary by CodeRabbit发布说明
功能概览本次提交实现了隐私合规性强化,包括新增隐私政策页面、Cookie 同意机制、内容安全防护(DOMPurify 消毒、XSS 防御)、服务端 CORS 动态来源、SSRF 防护、速率限制、邮箱字段隐藏等功能。环境配置调整包括禁用 source maps 和移除生产环境 API URL。 变更总览
流程图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
代码审查工作量估计🎯 4 (Complex) | ⏱️ ~65 分钟 相关 PR
建议标签
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
✨ Simplify code
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. Comment |
…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
There was a problem hiding this comment.
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
📒 Files selected for processing (13)
.gitignorePRIVACY.mdclient/.env.productionclient/functions/api/[[path]].tsclient/src/app.tsxclient/src/components/comments.tsxclient/src/components/cookie-consent.tsxclient/src/lib/api.tsclient/src/lib/markdown.tsclient/src/pages/admin/settings.tsxclient/src/pages/privacy.tsxclient/vite.config.tsserver/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.tsxclient/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.tsxclient/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-Origin与Vary: Origin,避免了缓存中毒;且未设置Access-Control-Allow-Credentials,因此反射 Origin 不会被滥用升级为可凭据跨域。与server/src/index.ts的 CORS 行为保持对齐。client/src/lib/api.ts (1)
298-302: LGTM:类型分层与后端剥离行为一致
CommentData去掉authorEmail,AdminComment保留,刚好对应后端/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文字对比度均可接受。
| /** 通过昵称生成 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}`; | ||
| } |
There was a problem hiding this comment.
头像请求会把访客信息泄漏给第三方 DiceBear
本 PR 主线强调 GDPR 合规并为第三方脚本加了 Cookie 同意门禁;但此处每条评论的 <img> 依然直接打到 api.dicebear.com,会泄露访客 IP / UA / Referer,等同未经同意的第三方追踪载体。
建议二选一:
- 服务端代理或本地生成:后端写一个
/api/avatar/initials?seed=...返回 SVG(只要两行 SVG 即可生成 initials),彻底去掉外部依赖; - 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).
| 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> | ||
| ); |
There was a problem hiding this comment.
建议增强无障碍标记、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.
| <p className="text-[13px] text-muted-foreground/40 mt-[4px] mb-[24px]"> | ||
| 最后更新:2025-04-19 · Last updated: 2026-04-19 | ||
| </p> |
There was a problem hiding this comment.
最后更新日期中英文不一致
第 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.
| <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]"> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, check if the file exists and read its content
cat -n client/src/pages/privacy.tsx | head -120Repository: 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 -20Repository: 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/nullRepository: 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 -20Repository: one-ea/Monolith
Length of output: 81
🏁 Script executed:
# Read the globals.css to understand color definitions
cat -n client/src/globals.cssRepository: 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.tsxRepository: 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")
EOFRepository: 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'}")
EOFRepository: 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")
EOFRepository: 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 -20Repository: one-ea/Monolith
Length of output: 41
🏁 Script executed:
# Extract light theme color definitions
sed -n '91,125p' client/src/globals.cssRepository: 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}")
EOFRepository: 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.tsxRepository: one-ea/Monolith
Length of output: 629
浅色主题下文本对比度不足,建议改用更深色的文本颜色
第 12、55、111 行的 text-muted-foreground 与 text-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.
| | 数据类型 | 来源 | 用途 | 存储方式 | | ||
| |---------|------|------|---------| | ||
| | 访问页面路径 | 请求 URL | 内容统计 | 边缘数据库 | | ||
| | 访客来源国家 | Cloudflare `CF-IPCountry` 头 | 流量分析 | 边缘数据库 | | ||
| | 来源域名 | `Referer` 头 | 流量分析 | 边缘数据库 | | ||
| | 设备类型 | `User-Agent` 解析(仅区分 desktop/mobile/bot) | 响应式优化 | 边缘数据库 | | ||
| | 评论者昵称 | 用户主动填写 | 公开展示 | 边缘数据库 | | ||
|
|
||
| **我们不收集:** IP 地址(仅处理为不可逆哈希用于投票去重)、邮箱地址不在公开 API 中返回。 |
There was a problem hiding this comment.
数据收集表未列出"评论者邮箱"条目
第 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.
| 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); |
There was a problem hiding this comment.
速率限制依赖进程内 Map,在 Workers 边缘场景等同被绕过
Cloudflare Workers 每个隔离实例都有独立内存,且随机调度到各边缘节点。loginAttempts 这张 Map:
- 不跨实例共享:攻击者发到不同节点即可重置计数;单节点内也可能因 isolate 回收清零。
- 无淘汰机制:随 IP 数量无限增长,长期会压占 isolate 内存。
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.
| /** 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, check if the file exists and get its size
wc -l server/src/index.tsRepository: one-ea/Monolith
Length of output: 83
🏁 Script executed:
# Examine the code around lines 661-671
sed -n '650,700p' server/src/index.tsRepository: 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 2Repository: 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 3Repository: 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 -80Repository: 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 -60Repository: 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 -20Repository: 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 5Repository: 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.tsRepository: 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)}`);
});
EOFRepository: one-ea/Monolith
Length of output: 1062
🏁 Script executed:
# Check the full WebDAV validation logic more carefully
sed -n '1037,1087p' server/src/index.tsRepository: 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 2Repository: 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.tsRepository: one-ea/Monolith
Length of output: 964
SSRF 检查存在绕过风险,需补充防护
实际测试表明 isSafeImageUrl 的不足:
localhost字面量未拦截:https://localhost/...直接通过,可访问本地服务。- IPv6 带括号形式未拦截:
https://[::1]/...的主机名为[::1](包含括号),不匹配现有正则。 - 十进制/十六进制 IPv4 已正确处理:
new URL()自动将2130706433和0x7f000001标准化为127.0.0.1,现有正则可捕获。
WebDAV 端点(第 1049 行)虽包含 ::1 和 fc 前缀检查,但 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).
Summary
Critical/High severity fixes:
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)authorEmailfrom public/api/posts/:slug/commentsresponse, use DiceBear avatar by nickname instead of Gravatar hash (fixes fix(ui): mobile responsive layout #2, chore(config): 优化 Dependabot 合并策略 (启用群组构建) #14)Originheader instead of*in both Workers and Pages Function (fixes feat(v1.2): 流量统计、后台重构、Pages Functions 代理、Bug 修复、文档更新 #4)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)javascript:URI — Filterjavascript:scheme in link renderer (fixes feat(v2.0): 完整版本更新、批量处理、版本历史与各类安全修复 #7)Medium severity fixes:
REACTION_SALTenv var (fixes chore(ci): bump actions/setup-node from 4 to 6 #9).env.productionin git — Add.env.*to.gitignore, remove from tracking (fixes chore(ci): bump actions/checkout from 4 to 6 #10)sourcemap: false(fixes chore(deps): bump typescript from 5.9.3 to 6.0.2 in /client #11)Test
npm run buildpassestsc --noEmitpasses (both client & server)