tamakyi
5def41d0f8
feat(ux): 真 404 / HTML 500 页,以及「提问者找回自己的提问」
...
一、错误页
原来未知路径一律 302 到首页:用户点了个失效链接,地址栏莫名跳到首页却什么
提示都没有,分不清是链接坏了还是自己点错了;对搜索引擎是软 404,失效链接会
被当成有效页面一直占抓取预算。500 则是 flamego.Recovery() 的纯文本
"Internal Server Error",白底黑字两行英文,没有返回入口,也没法报编号。
现在是 internal/route/errorpage.go 里的 writeErrorPage:
- 404 保留状态码 + 一页能看懂的说明 + 返回首页按钮 + noindex
- panic 由自写的 recoveryMiddleware 兜住,渲染同样的 HTML 500 页,
并带上 trace id(用户反馈时报这个号就能定位日志),日志里也带完整栈
- 响应已写出时不再写头(否则 net/http 会忽略状态码并打 superfluous 警告)
为什么不套模板:f.NotFound 不经过组级中间件 —— template.Templater 与
Contexter(连同 session、CSRF)都挂在 f.Group 上,在 NotFound 里注入
template.Data 会直接 panic(实测:"value not found for type template.Data")。
而 recovery 是最外层,那时模板还没装配。错误页恰恰最不该依赖可能出错的链路,
所以用内联 HTML + i18n.T,语言从 Accept-Language 猜(拿不到 session)。
蜜罐分流仍在 NotFound 最前面:临时打开 enable_honeypot 实测 /wp-admin
仍返回假 WordPress 登录页(200),普通未知路径返回 404。
二、提问者找回自己的提问
不填邮箱时,唯一能追踪这条提问的东西是提问成功页上那条链接 —— 关掉页面、
链接冲掉了就永久失联。箱主认真答了,提问者永远不知道。这是这类产品最核心的
体验断点,而它不需要账号体系就能解决:
- 成功页把 {domain, id, token} 写进本机 localStorage(最多 30 条,按 domain+id 去重)
- 再访该箱时显示一条不打扰的横条:「这台设备上有 N 条提问记录,其中 M 条已有回答」,
点「查看」展开列表,每条可直接打开
- 新增 POST /api/v1/ask-status 批量回答「哪些已被回答」:一次 IN 查询,不逐条 N+1
它不能用来枚举:必须同时给出正确 token 才返回 ok=true,而「token 错」「id 不存在」
「不属于该箱主」三种情况的响应完全相同(ok=false / answered=false / excerpt 为空),
无法区分。token 比较走常数时间,单次最多 20 条,限流 30/分钟。用 POST 不用 GET,
因为 token 不能进 URL(会留在浏览器历史、代理日志、Referer 里)。
列表摘要先过 MarkdownPlainText 再按 rune 截断:直接截原文会露出 
这种噪音,一条带图的提问几乎只剩链接。
验证:
- verify_ask_history.py 12/12(含「错误 token / 不存在的 id / 别人的提问」三者
响应完全一致、缺 CSRF 被拒、超 20 条截断)
- verify_ask_history_ui.mjs 用 CDP 在真实 Chrome 里跑:无记录时提示条隐藏;
注入记录后显示「1 条,其中 1 条已有回答」;列表渲染、链接带 token、点击展开正常
- 错误页单测 6 项(HTML/不泄露 panic 内容/已写出时不重复写头/语言选择/状态码/转义)
2026-10-29 17:18:39 +08:00
tamakyi
f2e0c43083
feat(ops): 健康检查端点,并把上传超时与服务端读超时对齐
...
1) /healthz 与 /readyz
改动前站内唯一的「探活」手段是 curl /,而首页只渲染模板、不查数据库:
DB 挂了首页照样 200(漏报),Redis 挂了首页又可能 500(误报)。
监控、CDN 源站健康检查、滚动重启的摘流判定都没有可靠依据。
→ /healthz 永远 200,刻意不碰任何依赖:进程活着 ≠ 能对外服务,
把 DB 检查塞进存活探针会导致数据库抖动时进程被反复重启,对恢复毫无帮助;
→ /readyz 查 DB(2s 超时),失败 503;响应体只回组件状态,
绝不回错误详情 —— 这是公开端点,DB 报错常带库名/主机名/账号。
实测:停掉 PostgreSQL 后 /healthz 仍 200、/readyz 503,恢复后 200。
2) ReadTimeout 30s → 120s
站内允许单图 8MB、请求体上限 12MB。30s 传完 8MB 需要 ≥273 KB/s 上行,
而手机 4G 上行常在 1–2 Mbps(125–250 KB/s)—— 一张 8MB 图必然超时,
且用户已写好的正文会一起丢(POST 失败不回填)。120s 对应约 68 KB/s,
偏远地区 3G 也能过。
放宽它不会让 slowloris 重新可行:那类攻击卡在「发不完 header」,
由 10s 的 ReadHeaderTimeout 拦住,ReadTimeout 只影响读完 header 之后传 body 的时长。
2026-10-29 16:28:44 +08:00
tamakyi
11d6e701ee
fix(ops): 访问日志脱敏、优雅关闭、Redis 可降级并启动即探活
...
三处都属于「故障时才发现」的运维缺口,一起改。
1) 访问日志泄露凭证
flamego.Classic() 内部 Use(Logger()),而 Logger 记的是 RequestURI —— 含 query。
实测日志出现 path=/_/tblocal/4?t=SECRETTOKEN123:?t= 是私密提问钥匙,
?code= 是一次性改密凭证,?q= 是搜索词,全都会被明文落盘,凡是能看日志的人
都能直接读私密提问或重置密码。internal/tracing 早就为同一原因只取 URL.Path
并留了注释,漏的是框架自带那层。
→ 改 flamego.New() + accessLogMiddleware,只记 Method/Path/Status/Duration/IP;
静态资源成功请求不记(噪音),4xx/5xx 照记(排查裂图靠它)。
2) 没有优雅关闭
直接 ListenAndServe,Go 对 SIGTERM 是立即退出,每次部署都会把正在写库的 POST
就地处死,留下「提问已 INSERT、图片没传完」这类半成品。
→ signal.NotifyContext(SIGTERM/SIGINT) + srv.Shutdown(15s),超时才 Close;
退出前关连接池。实测 8 个并发分享图请求中发 SIGTERM,8/8 全部成功。
3) Redis 单点
session 建在 Redis 上,load 失败 flamego/session 会 panic → 每请求 500(含匿名访客);
而 cache.Cacher 没判空 Addr,go-redis 会回退 localhost:6379,于是「不配 Redis」
这种被允许的部署里找回密码整体不可用、私信邮件节流失效,且启动零提示。
→ cache 跟随开关(未配则 MemoryIniter);启动 PingRedis fail-fast,显式 2s 超时。
实测停掉 Redis 后启动立即失败并明确指出是 Redis。
验证:单测 2 项(query 永不入日志 / 静态资源过滤口径);重启日志出现
「收到退出信号」→「HTTP 服务已关闭」;启动打「Redis 连接正常」。
2026-10-29 15:53:32 +08:00
tamakyi
7802bb2078
fix(security): 修复安全审计的 P1/P2/P3 项,含全局复查新发现的两处越权与外跳
...
P1:
- 外链图片:markdown 渲染层只放行站内与自有 CDN 图片。配置里的
image_bucket_cdn_host 常写成 host/path,统一归一化后再比对,避免把自家图一起剥掉
- 密码:页面/提问密码最小 6 位;密码比对挪到验证码之后;页面密码失败锁定 5 次/10 分钟
- 客户端 IP:CF-Connecting-IP / EO-Client-IP 只在 TCP 对端可信(回环或 trusted_proxies)
时采信,公网对端带来的权威头一律忽略
P2/P3:
- 跳转参数收敛到 shared.SafeRedirectTarget:拒反斜杠与控制字符(url.Parse 会把 %0d%0a
解成 CRLF,伪造 Location),最后再用 path.Clean 验一次前缀
- 注册不再暴露「邮箱已注册 / 已停用」:与全新注册共用同一个成功出口,只在日志留痕
- CSP:默认 enforce,白名单由配置派生,覆盖 umami(运行时注入)、Turnstile 与
按 sentry_dsn 条件放行的 Sentry CDN
- /user 与 /_ 下发 no-store;补 X-XSS-Protection、Permissions-Policy,并把安全头前移到
静态资源中间件之前(原先静态文件拿不到 nosniff/HSTS)
- 封禁事实落库 ban_records;429 统一带 Retry-After 与本地化文案
- token 比较改常量时间;探索页页码上限;JSON 响应显式 Content-Type
全局复查补的两处:
- 私聊 SendMessage 的 POST 缺 conv.OwnerID 校验,被拉黑者可换 domain 继续发消息
- 页面密码 next 与登录 to 同源的反斜杠外跳
验收:verify_p1_fixes 4/4、verify_round2 19/19、verify_page_next 6/6、
verify_chat_owner 3/3(含变异对照);单测与 go build ./... 全通过。
2026-10-27 12:37:40 +08:00
tamakyi
a5441c99fd
feat(stats): 新增公开数据页 /stats,入口收敛到页脚
...
首页底部那张「站点统计」卡片下线,完整数据移到公开只读页 /stats:
今日实时数、概览六格、近 30 天折线与 12 个月柱状、三个占比环、
7×24 时段热力图、提问字数与回答耗时分布。
- 聚合在 internal/db/analytics.go(SUM(CASE WHEN),方言差异收敛在 analyticsDialectFor)
- 结果按自然日缓存,跨日重算一次后全站共用;重算失败降级返回上一份快照
- 图表零前端依赖:服务端算坐标的 SVG + CSS grid,禁用 JS 页面仍完整
- 环比取「上月 vs 上上月」:本月未过完,否则每月 1 号会得出 -95%
- 入口只在页脚「社区规则」与「找站长」之间;route.Home 不再计算统计
- 补 questions(created_at) 索引;启动后异步预热当天快照
- 测试:analytics 连真库验证软删除不计入、分桶下标、DOW 对齐
2026-10-24 00:57:08 +08:00
tamakyi
86ea96e5ca
feat(autoban): 封禁响应图改为 ban_image_path 配置,默认仍是纯文字
...
上一版把 403 图 //go:embed 进了二进制。实际部署里图会集中放在 R2 统一管理、
由运维分发到各机器,进仓库反而成了负担:换一张图就要重新编译。
改为配置驱动:
- 新增 [security] ban_image_path(相对路径以进程工作目录为基准);
- 留空 / 文件读不到 / 是目录 / 不是图片 / 超过 2 MB → 一律回退为纯文字
"403 Forbidden",即改造前的老行为。封禁响应是最后一道兜底,不能因为
一张图缺失就变成 500,更不能把站点拦在启动阶段(失败只 Warn);
- 启动时读一次装进 atomic.Pointer,热路径零磁盘 IO;换图 = 换文件 + 重启。
安全细节:按内容嗅探(http.DetectContentType)而不是扩展名,必须是 image/*。
把 ban_image_path 配成 app.ini / .env 会被拒绝并告警 —— 否则等于把配置文件
原样吐给每一个被封的攻击者。这条有专门的用例与变异验证。
顺带删掉 internal/route/assets(webp + embed),已无引用。
测试:
- TestWriteBannedTextFallback:未配图 → 403 + text/plain + 13 字节明文;
- TestWriteBannedServesConfiguredImage:配了 1×1 PNG → image/png(按内容嗅探)、
Content-Length 与文件一致、body 与文件逐字节相同;
- TestLoadBanImageRejectsBadInput:不存在 / 是目录 / 非图片 / 空文件四种配置错误
都不得装载,且**不得把文件内容当响应体吐出去**;
- TestLoadBanImageTooLarge:超 2 MB 拒绝;
- TestWriteBannedHead:两条分支的 HEAD 都只回头不给 body。
变异验证:去掉 image/* 校验 → 用例报「把文件内容当响应体吐了出去:
"[server]\nsalt = secret"」;强制走纯文字分支 → 配图用例按预期变红。
实测:真 webp 嗅探为 image/webp;app.sample.ini 嗅探为 text/plain 被拒。
2026-10-23 15:25:07 +08:00
tamakyi
818fa74160
fix(honeypot): 取证记精确地址、封禁用 /64 键,并补建封与拦截日志
...
线上实测:IPv6 访问蜜罐后收到「你的 IP 已被封禁」,邮件里却是
`2408:…::/64` —— 既不像自己的地址,也说不清封了多大范围;同时用户自己
仍能正常访问,看日志也无从判断到底有没有封。
根因是「取证地址」与「封禁键」混用同一个变量(都取 LimitKey 的折叠值):
- 取证丢掉精确 IPv6 后 64 位,事后无法定位到具体主机、没法和其他日志对账;
- 邮件只给聚合键,运维第一反应是「不是我」,排查方向直接被带偏。
改动:
- Serve 拆 clientIP(ResolveClientIP,精确)与 banKey(LimitKey,/64):
DB / 凭据文件 / 告警邮件用前者,封禁 / 探测采样 / 告警去重用后者;
- autoban.Options 新增 OnBan 回调(保持纯标准库、不引入 logrus),
route.newBanner 注入后每次建封打 WARN:ban_key / reason / duration / expires_at;
中间件 403 分支加 Debug 日志(被封者会持续请求,Info 会被刷屏);
- 告警邮件新增「封禁范围」行,仅 ip != ban_key 时渲染(IPv4 不重复显示),
顺带把过时的「已入库」改为「写入取证文件」——凭据早就外置了;
- honeypot stats 的来源排行改按 LimitKey 合并,否则 IPv6 轮换地址会把
排行碎成几十条各 1 次的记录;合并口径与封禁完全一致。
测试(每项都做了变异验证:改坏→确认变红→恢复):
- TestHoneypotRecordsExactIPv6ButBansByPrefix:精确地址进 DB 与凭据文件、
/64 才是封禁键、同 /64 换地址仍 403;
- TestOnBanCallback:建封回调带键/时长/原因,封禁期内重复命中不重复回调;
- TestHoneypotAlertTemplateRenders:IPv6 渲染封禁范围、IPv4 不重复;
- TestMergeSourcesByKey:同 /64 合并、IPv4 原样、limit 与空输入。
2026-10-23 10:53:03 +08:00
tamakyi
bd02eb7ed2
fix templates v1
2026-10-22 17:04:10 +08:00
tamakyi
c29e1d071f
fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库
...
一、封禁链路整条没接上(P0,已实测复现)
route.New 的 autoban.New 漏传 HoneypotEnabled/Threshold/Duration,而
RecordHoneypot 首行就是 `if !b.honeypotEnabled { return false }`
→ 蜜罐命中从不封禁、落库 banned 恒 false、IsBanned 永不为真。
同一批改动又把中间件里蜜罐路径的常规计数也跳过了,净效果是蜜罐路径
从「10 次封禁」退化为零计数零封禁 —— 是 regression 不是修复。
处理:抽 newBanner() 作为唯一装配入口并补齐参数;新增 banner_config_test
四个用例(全部经由 newBanner,不再自建 Banner)。变异验证:分别把
HoneypotEnabled 置 false、HoneypotThreshold 置 0,对应用例按预期失败。
二、诱捕语义(审计时漏掉,比上面更影响功能价值)
原实现对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
永远走不到「POST 提交账号密码」那一步,蜜罐的核心价值(拿到字典)归零。
处理:提交凭据 → RecordHoneypot(不受 enable_auto_ban 约束,立即封);
纯探测 → 常规 Record(阈值 10)。新增 TestHoneypotProbeDoesNotBanImmediately,
变异验证:把 GET 也塞进 RecordHoneypot,测试按预期失败。
三、落库边界(实测取证,非推断)
- Path 原样落库而列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
实测 600 字符失败、500 字符成功;
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL
可稳定触发)。统一改 rune 截断并按列宽逐项对齐。
四、资源与噪音
- alerted 无界 map → 带 TTL 的 deduper(上限 2 万,过期清理 + 超限硬裁),
同时承担告警 24h 冷却与纯探测落库采样(每 IP 每分钟一条)——
GET 洪泛不再变成等量 INSERT;
- credentialKeys 去掉 id/name,普通 JSON 探测不再被误判为凭据提交并触发假告警;
- canary 从 meta generator 移到 HTML 注释,generator 伪装成 WordPress,
不再同时向扫描器自曝「这是蜜罐」和「这站是 TamaBox」;
- serveLoginFailure 复用 WriteLoginPage,不再手抄响应头(已开始漂移);
- Describer 死代码 → Prefixes(),接到新命令 honeypot paths;
- honeypot_paths 解析挪到 honeypot.SplitPaths(CLI 与 route 共用),
补前导斜杠、拒绝 "/"(会让所有 404 变假登录页)。
五、凭据外置:数据库不再存用户名/密码
明文账号密码一旦入库就会跟着进备份、主从复制与任何能连库的报表工具,
事后无法彻底清除。改为:
- 新增 honeypot.CredRecord/CredStore:按天文件 creds-YYYY-MM-DD.jsonl,
目录 0700、文件 0600、坏行跳过、PurgeBefore 只删整天过期的文件;
- db.HoneypotHit 删掉 Username/Password/Fields/RawBody,只留 HadFields 元数据;
- HoneypotStore 删掉 TopCredentials/ListSinceByIP(CLI 改读文件后零调用);
- 新增 LegacyCredentialColumns/PurgeLegacyCredentialColumns:AutoMigrate 不删
既有列,老库升级后历史明文仍在 → 启动 WARN + `honeypot purge-creds` 显式 DROP;
- 清理器 HoneypotRetention 增加 CredPurgeable 参数,与 DB 记录共用保留期;
honeypot prune 也一并删过期凭据文件;
- CLI:hits 不再连库(DB 挂了也能查),stats 的凭据排行读文件;
- 配置 honeypot_cred_dir(缺省 data/honeypot-creds),.gitignore 加 /data/。
验证:go build ./... / go vet ./internal/... / go test ./...(含 PostgreSQL)全绿;
新增 credstore_test(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
TestHoneypotLegacyCredentialColumns(ADD COLUMN 模拟老库 → 检测 → DROP)、
TestHoneypotWritesCredentialsToFile;CLI 端到端手动验证 hits/--ip/--show-password。
2026-10-21 15:43:25 +08:00
tamakyi
79051acae8
miguan v1
2026-10-21 12:09:28 +08:00
tamakyi
e9fba8b9ed
theme v1
2026-10-19 15:23:11 +08:00
tamakyi
7a6417c33d
restrict v8
2026-10-18 23:32:38 +08:00
tamakyi
bd81398a06
restrict v6
2026-10-18 22:18:21 +08:00
tamakyi
6bd90779f8
restrict v1
2026-10-18 21:14:59 +08:00
tamakyi
0e0c16f786
fix deactivate
2026-09-15 12:10:42 +08:00
tamakyi
adcc42cc8c
fix anwser token and database issus
2026-09-15 11:06:09 +08:00
tamakyi
8e2325eda5
aliyun sensor server
2026-09-10 18:04:19 +08:00
tamakyi
e3e59e7c69
chore: baseline snapshot before moderation-strictness feature
...
Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
2026-09-10 10:56:27 +08:00