feat(安全): 对外暴露面加固 + 界面去 AI 化(v1.5.0)
界面(去 AI 味): - 大屏页清除 114 处生成器残留属性 data-page-node-id - 视觉系统改回工程控制台风格:去 radial/linear-gradient、去辉光、 去标题前彩色装饰条,改为中性灰阶 + 单一蓝色强调色;KPI 色条改状态点 - 精简各页说教式长提示;修掉 profile.html 泄漏到页面上的 Markdown 星号 - 删除登录页过时的「默认账号 admin / admin123」提示(1.4.0 起已无默认口令) 安全与隐私(按「将会被公网访问」收口): - 内部异常只回 8 位事件号,完整堆栈进服务端日志(web/api.py::_internal) - 导出文件名收敛:防响应头注入与路径穿越;manage.py passwd 补用户名校验 - 登录对不存在的账号也走一次哑哈希,抹平用户名枚举的时序差异 - /api/* 读接口限速 240 次 / 60 秒 / 账号(挡住循环调 /api/bundle) - 进程 umask 0077 + 目录 0700 / 文件 0600:对话正文与主密钥的落盘权限 - 表名与库文件路径只对管理员下发;大屏页所有数据插值转义 - --debug 只允许绑定回环地址;新增 Permissions-Policy 与 413 处理器 文档: - DEPLOYMENT 新增第十三节「安全与隐私基线」;迁移表补 1.4.0 → 1.5.0 行 - SECURITY 更新支持范围、新增「信息泄漏收敛」小节与上线检查项 - .codebuddy/ 加入 .gitignore(助手工作记忆不进仓库) 版本:1.4.0 → 1.5.0(无库结构变更,user_version 仍为 4) 验证:python tools/smoke.py → ok=264 fail=0;python tools/check_docs.py → 0 处问题
这个提交包含在:
+46
-4
@@ -3,15 +3,34 @@
|
||||
## 支持范围
|
||||
|
||||
本项目按「自托管」定位开发,**1.4.0 起已按「可以暴露到公网」加固**(IP 来源、限速、
|
||||
资源上限、采集跨度硬顶都补上了),但仍然建议置于反向代理之后。安全修复只针对当前主分支与最新发布版本。
|
||||
资源上限、采集跨度硬顶都补上了),**1.5.0 又按同一前提收口了一轮信息泄漏、注入与读接口滥用**。
|
||||
即便如此,仍然建议置于反向代理之后。安全修复只针对当前主分支与最新发布版本。
|
||||
|
||||
| 版本 | 是否接受安全修复 |
|
||||
|---|---|
|
||||
| `1.4.x`(当前) | ✅ |
|
||||
| `1.5.x`(当前) | ✅ |
|
||||
| `1.4.x` | ⚠️ 可用,但建议升级(1.5.0 收口了内部异常/表名外泄、响应头注入、导出路径穿越、读接口无刹车) |
|
||||
| `1.3.x` | ⚠️ 可用,但**公网暴露前必须升级**(1.4.0 修掉了三道 IP 防线可被 XFF 伪造绕过的问题) |
|
||||
| `< 1.3` | ⚠️ 可用,但建议升级(1.3.0 收紧了普通账号的越权面,见下) |
|
||||
| `< 1.2` | ❌ 请先升级(1.2.0 修掉了单用户时代「Cookie 明文入库」与「人人都是管理员」两个根本问题) |
|
||||
|
||||
> **1.5.0 收口了什么**(都不是「可直接利用的高危漏洞」,而是**对外时迟早出事**的那一类):
|
||||
>
|
||||
> 1. **信息泄漏**:接口的兜底异常原本把 `str(e)` 原样回给客户端(含绝对路径与 SQL 片段),
|
||||
> 现在只回一个 8 位事件号,细节进服务端日志;`/api/manifest` / `/api/bundle` 里的
|
||||
> **表名与库文件路径**也只对管理员下发。另外补掉了登录接口的**用户名枚举时序侧信道**
|
||||
> (账号不存在时也走一次同代价的哑哈希)。
|
||||
> 2. **注入**:导出文件名含用户名,而用户名并不总是注册正则的产物
|
||||
> (`manage.py passwd` 建号、老库升级上来的名字都可能带引号或 CR/LF)——
|
||||
> 直接拼 `Content-Disposition` 就是**响应头注入**;`export_csv` 的账号名直拼路径则是
|
||||
> **路径穿越**。两者都改为先收敛字符集,建号也补上了用户名校验。
|
||||
> 3. **资源滥用**:读接口原本**没有任何刹车**,一个注册账号循环调 `/api/bundle`
|
||||
> 就能持续吃满 CPU 与出口带宽。现在 `/api/*` 按账号(未登录按 IP)限速 240 次 / 60 秒。
|
||||
> 4. **隐私落盘**:进程 `umask` 收到 `0077` —— 数据目录里是**对话正文**、凭证密文、主密钥
|
||||
> 与它们的明文导出副本,默认 `0644` 等于同机任何用户可读。
|
||||
> 5. **调试器**:`--debug` 不再允许绑定对外地址(Werkzeug 调试器 = 任意代码执行,
|
||||
> 而 `--host` 的默认值恰好是 `0.0.0.0`)。
|
||||
|
||||
> **1.4.0 修掉了什么**(三条 P0):
|
||||
> 1. **`X-Forwarded-For` 可伪造 ⇒ 三道 IP 防线(验证码限速 / 注册配额 / 登录锁定)同时失效**。
|
||||
> 实测:每次换一个伪造的 XFF,45 次验证码请求**全部放行**。现在默认不信任 XFF,
|
||||
@@ -76,7 +95,8 @@
|
||||
| 开放重定向 | 登录后的 `next` 只允许站内相对路径,`//evil.com` 一律回落到 `/` | `security.safe_next` |
|
||||
| 失败限速(双维度、**强度刻意不同**) | **IP 维度**:连续失败 5 次**真锁** 10 分钟。**用户名维度**:只做秒级递增退避(封顶 60 秒)—— 「知道用户名就能把对方锁死 10 分钟」本身是攻击,而**管理员用户名在导航里是公开的**。另有**单来源登录尝试总量** 40 次 / 5 分钟(**含成功**),挡「慢慢撞、不触发失败阈值」。计数表有上限与 TTL | `security.auth_block_reason` / `note_try` / `try_window_left` |
|
||||
| 失败提示不泄漏信息 | 提示是「本来源连续失败 N 次」而不是「剩余 N 次」——不给攻击者倒计时 | `web/views.py:login` |
|
||||
| 响应头 | CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、`Referrer-Policy: same-origin`、COOP;`/api/*` 与 `/captcha*` 带 `no-store` | `security.apply_security_headers` |
|
||||
| **用户名枚举的时序侧信道** | 口令校验对**不存在的账号**也走一次同代价的哑哈希(`_DUMMY_HASH`)。否则「账号不存在」比「口令错误」快一到两个数量级,一个秒表就能枚举出哪些用户名真实存在 | `security.login_ok` / `_dummy_verify` |
|
||||
| 响应头 | CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、`Referrer-Policy: same-origin`、COOP、**`Permissions-Policy`(显式关掉地理位置/麦克风/摄像头/支付/USB)**;`/api/*` 与 `/captcha*` 带 `no-store` | `security.apply_security_headers` |
|
||||
| **HSTS 只在真 HTTPS 时下发** | `COOKIE_SECURE or FORCE_HTTPS` 才发 `Strict-Transport-Security`。纯 HTTP 部署下发会让浏览器强升 https,表现成白屏 —— 很难归因的一类故障 | `security.apply_security_headers` |
|
||||
| **访问日志** | waitress 自己不记 access log;本程序补上(跳过 `/static/` 与 `/captcha.png`),写进 `logs/app.log`,`WB_ACCESS_LOG` 控制 | `security._access_log` |
|
||||
|
||||
@@ -119,6 +139,19 @@
|
||||
| 出图限速 | 每来源 60 秒最多 40 张(不设限就是一条廉价的 CPU/带宽放大路径)。**来源按 `client_ip()` 判定**,伪造 XFF 无效 | `security.captcha_fetch_allowed` |
|
||||
| 注册配额 | 同 IP 每日最多注册 N 个(默认 3,可改);`allow_register=0` 可整体关闭。**同样按 `client_ip()` 判定** | `security.register_quota` |
|
||||
|
||||
### 信息泄漏收敛(v1.5.0)
|
||||
|
||||
不致命,但都是**踩点阶段最好用**的材料,所以统一收掉。
|
||||
|
||||
| 项 | 做法 | 位置 |
|
||||
|---|---|---|
|
||||
| **内部异常不外泄** | 接口的兜底 `except Exception` 不再回 `str(e)`(那会带绝对路径、SQL、`sqlite3` 报错),改为**完整堆栈进服务端日志 + 一个 8 位事件号**。面向用户写的业务异常(`BadParam` / `Busy` / `NotReady` / `ApiError` / `BackupError`)不受影响,它们本来就是给用户看的 | `web/api.py::_internal` |
|
||||
| 内部实现清单只给管理员 | `/api/manifest`、`/api/bundle` 的 `sources`(表名)与 `archive`(库文件路径)对普通账号为空;大屏页「数据源」卡片随之收起 | `query.manifest(sources=…)` |
|
||||
| **响应头注入收口** | 导出文件名一律先收敛成 ASCII 安全名(只留 `[A-Za-z0-9._-]`、打平 `..`),再按 RFC 5987 附上原名。含引号 / CR / LF 的用户名不可能再拼出畸形响应头 | `security.safe_filename` / `content_disposition` |
|
||||
| **导出路径穿越收口** | `export_csv` 的账号名 tag 同样收敛,且结果固定落在 `EXPORT_DIR` 之下。根因也堵了:`manage.py passwd` **建号时补上用户名校验**(与注册页同一套) | `collect.export_csv`、`manage.py` |
|
||||
| 上传体量反馈一致 | `413` 返回 JSON(与其它接口错误同形状)。不加这一层时 Flask 吐 HTML 页,前端 `r.json()` 会炸成「Unexpected token <」 | `workbuddy_portal/__init__.py` |
|
||||
| **访问日志不带查询串** | `_access_log` 记的是 `request.path` —— 若记 `?q=<搜索词>`,prompt 片段会顺着搜索词进日志文件 | `security._access_log` |
|
||||
|
||||
### 资源与频率限制(对外提供服务时)
|
||||
|
||||
单写者架构下**不加副本扛负载**,所以限制必须落在「单实例资源」与「单账号频率」两处。
|
||||
@@ -129,6 +162,7 @@
|
||||
| **采集跨度硬顶** | `COLLECT_MAX_RANGE_DAYS_HARD = 31`(= 1 个月)。`collect_max_range_days` 只是更严的旋钮,**改大也突破不了** —— 上限由代码兜底,不依赖写入校验。历史脏数据、手工改库都过不去 | `config.COLLECT_MAX_RANGE_DAYS_HARD`、`collect.max_range_days` |
|
||||
| 调度时刻数硬顶 | `SCHEDULE_SLOTS_HARD_MAX = 12`,另有可配置的更严上限 `max_schedule_slots_per_day`(默认 6)。时刻数量直接决定采集频次,是「一个账号能不能把云端打满」的开关 | `config.normalize_setting` |
|
||||
| 重操作最小间隔 | 采集 60s / CSV 导出 15s / **导出本人数据 10s** / `vacuum` 120s / 补全 prompt 60s / 重算 5s;超限 `429` 并给出剩余等待秒数 | `security.action_allowed` |
|
||||
| **读接口限速** | `/api/*` 按「账号」计数(未登录退化成来源 IP,**不做 DB 回查**)限速 **240 次 / 60 秒**,超限 `429`。重点挡的是 `/api/bundle` —— 它要算全量逐日聚合、还会下发最多 2 万条明细,把它放进 `for` 循环就能持续吃掉 CPU 与出口带宽(这不需要任何漏洞)。阈值刻意宽松:大屏切一次筛选只发 1~2 个请求 | `security.api_rate_ok` |
|
||||
| 容器资源上限 | `cpus: 1.0` / `mem_limit: 512m` / `memswap_limit` **与 `mem_limit` 相等**(= 禁用 swap:超限会「被 OOM 杀掉」而不是「越来越慢」,后者更难查)/ `pids_limit: 256`(挡 fork 炸弹)/ `ulimits.nofile 4096:8192`(SQLite 除主库外还持有 `-wal` `-shm`,恢复期还要开临时库 + `ATTACH` 源库) | `docker-compose.yml` |
|
||||
| 线程数受控 | `WB_THREADS`(容器默认 4,代码默认 8)决定单实例能同时吃进几个慢请求;1 核配 8 线程容易出现「都在等 CPU」的假并发 | `config.THREADS` |
|
||||
| SSRF 收口 | `api_base` 拒绝云元数据地址(`169.254.169.254` / `metadata.google.internal` / `[fd00:ec2::254]`)—— SSRF 拿云上临时凭证最经典的一跳,且无任何合法采集场景需要它 | `config.normalize_setting` |
|
||||
@@ -152,7 +186,9 @@
|
||||
|---|---|---|
|
||||
| 容器权限 | 运行层非 root(uid/gid 1000 `app`);`init: true` 让 tini 接管 PID 1,`docker stop` 能干净传到 python | `Dockerfile`、`docker-compose.yml` |
|
||||
| **密钥文件权限** | `data/instance.json` 在 POSIX 上显式 `chmod 0600`(默认 umask 022 会留下 0644,同机其他用户可读)。恢复时写回也走同一处理,并先把原文件另存 `.pre-restore-<ts>` | `config._instance_init`、`backup._restore_instance` |
|
||||
| 上传体量 | `MAX_CONTENT_LENGTH = 4 MiB` | `workbuddy_portal/__init__.py` |
|
||||
| **进程级落盘权限(v1.5.0)** | 启动时把 `umask` 收到 `0077`,此后**本进程新建的一切文件**天然是 owner-only:SQLite 的 `-wal` / `-shm`、导出 CSV、备份 zip、日志一并覆盖。刻意选「一条设置管全部」而不是逐个 `chmod` —— 逐点 `chmod` 一定会漏,而漏掉的那个文件里往往正好是最新的**对话正文**。配套 `harden_dir()` / `harden_file()` 收紧遗留的 0755 目录与已存在的文件 | `config.harden_process` / `harden_dir` / `harden_file` |
|
||||
| **调试器不绑对外地址** | `manage.py serve --debug` 在 `--host` 非回环时**直接拒绝启动**(退出码 2)。Werkzeug 的交互式调试器等于任意代码执行,而 `--host` 默认就是 `0.0.0.0` —— 这条把「本机调试习惯」和「公网部署」之间的那个坑堵死 | `manage.py cmd_serve` |
|
||||
| 上传体量 | `MAX_CONTENT_LENGTH = 4 MiB`(超限由 `413` 处理器转成 JSON) | `workbuddy_portal/__init__.py` |
|
||||
| 不索引 | 页面带 `noindex, nofollow` | `web/templates/base.html` |
|
||||
|
||||
**绝不入库**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、`data/usage.sqlite`、
|
||||
@@ -222,3 +258,9 @@
|
||||
「已保存但无法解密」
|
||||
- [ ] 升级到 1.4.0 后确认「任务管理」里调度时刻**对普通账号是只读的**,
|
||||
且访问 `/backups` 返回 403(用普通账号各试一次)
|
||||
- [ ] **升级到 1.5.0 后跑一次 `python tools/smoke.py`**:第 9 节专门验「对外暴露面」
|
||||
(响应头收敛 / 异常不外泄 / 读接口限速 / 安全响应头 / 普通账号拿不到数据源清单)
|
||||
- [ ] 确认容器里进程的落盘权限已收紧:`docker compose exec portal sh -c 'umask'` 应为 `0077`,
|
||||
且 `ls -l /app/data` 的目录权限是 `drwx------`
|
||||
- [ ] 确认从外部看到的响应头里有 `Permissions-Policy`(1.5.0 新增)
|
||||
- [ ] 确认 `--debug` 没有被用在对外地址上:`python manage.py serve --debug` 应当**直接报错退出**
|
||||
|
||||
在新工单中引用
屏蔽一个用户