diff --git a/docs/生产补充验收记录.md b/docs/生产补充验收记录.md index 585e5c28..65cad20e 100644 --- a/docs/生产补充验收记录.md +++ b/docs/生产补充验收记录.md @@ -16,7 +16,7 @@ - [✓] 核对历史阻塞、当前部署、隔离产物及证据边界。 - [✓] 最新构建 SQLite 空数据初始化、登录、安全设置、三协议认证与中文附件读取。 -- [✓] 最新构建独立 Nginx 反代、跳转、上游 TLS 校验、双来源限流和过载恢复。 +- [✓] 独立 Nginx 反代、跳转、上游 TLS 校验、双来源限流和过载恢复;新候选复验见后文。 - [✓] 三种数据库共十段真实迁移、正式配置事务切换、配置管理器重新加载和历史保真。 - [✓] 同机隔离冷备、全新目录还原、历史及新增邮件的正文和附件取回。 - [✓] 当前生效证书覆盖根域名与泛域名,管理入口和应用托管证书指纹一致。 @@ -24,10 +24,12 @@ - [✓] 前端生产依赖审计、静态检查、全量自动化和全量竞态回归。 - [✓] 四个 DNS 工具移除明文云密钥,新增脱敏扫描器及持续集成门禁。 - [!] 历史云密钥、服务器凭据的轮换、访问审查和泄露处置没有完成证明。 -- [!] 静态安全扫描报告 61 条告警,尚未逐条完成修复或可审计排除,不能生产放行。 -- [ ] MySQL、PostgreSQL 各自从空库完成首次安装及邮件业务闭环。 +- [!] 静态安全扫描尚未逐条完成修复或可审计排除;Windows 初轮 61 条、Linux 目标平台前轮 44 条,不能生产放行。 +- [✓] SQLite、MySQL、PostgreSQL 各自从空库完成首次安装、真实配置历史写入及邮件业务闭环。 +- [✓] 新候选在三种数据库下验证协议码和客户端码撤销:三协议旧连接操作及新登录均被拒绝。 +- [✓] HTTP 退出后原 Cookie 重放、账号禁用及修改密码后的旧会话拒绝;浏览器操作另行验收。 - [ ] 真正应用进程切库重启后的业务验收、故障注入和中断恢复。 -- [ ] 最新候选浏览器初始化、账号恢复、旧会话及真实协议连接撤销闭环。 +- [ ] 最新候选浏览器初始化、账号恢复及完整用户操作闭环。 - [!] Windows 提权本地通道验收需要管理员会话,当前终端未提权。 - [!] 生产 DNS 自动续期需要安全注入有效且已轮换的最小权限凭据,当前为手动托管证书。 - [!] 外部收件最终到达、真实恢复邮件、外部插件调用需要受控测试账号和收件确认。 @@ -62,7 +64,7 @@ | 正式主程序 SHA-256 | `c14ef1c5ba1dc2096c312c8357039a7330c985cbeac90765aed01d8151e05e1f` | | 正式运行目录 | `/opt/smail`,本轮未替换、未重启、未清库 | -候选没有注入正式发行版本号,不伪造发行标识。本轮后续改动属于测试、工具、持续集成及文档,不改变被测应用逻辑。 +上表为 9 月 20 日初轮候选,没有注入正式发行版本号,不伪造发行标识。9 月 21 日已修改 PostgreSQL 初始化及协议连接授权等应用逻辑,并重新构建候选;新旧版本证据不能混用,见后文独立记录。 每个隔离实例有独立挂载和网络命名空间;测试密码和协议凭据不输出,备份恢复所需测试密码仅在内存及匿名标准输入管道传递。 ## 云端实机结果 @@ -181,6 +183,59 @@ IMAP 相关转换优先复核位置:`services/list/imap_search.go:107`,`list ## 发现的问题及未关闭门禁 +### 9 月 21 日数据库与连接撤销整改 + +本节时间均为北京时间。应用候选提交为 `8899e74`,包含 `fa0cea9` 的 PostgreSQL 初始化修复;测试用版本标识为 `review-revocation-20260921`,不是正式发行号。 + +| 项目 | 证据 | +| --- | --- | +| 新候选 SHA-256 | `b969912d9713af4d0f20b0d91c9ce4baec38eea69a140497d59c81c33caeac44` | +| 云端候选路径 | `/var/tmp/smail-review-revocation-20260921/smail` | +| 本地原始证据 | `test-log/production-remediation-20260921/` | +| 新候选 SQLite | `evidence/smail-review-runtime-20260921-020012/` | +| 新候选 MySQL | `evidence/smail-review-runtime-20260921-020206/` | +| 新候选 PostgreSQL | `evidence/smail-review-runtime-20260921-020331/` | +| 新候选同机冷备恢复 | `evidence/smail-review-restore-20260921-020113/` | + +**数据库问题与修复:** PostgreSQL 首次安装产生的安全连接选项与旧配置解析不一致,真实安装未能进入正式服务。修复安装侧连接选项,并仅允许可由正式配置等价表示的 `sslmode=require`、`timezone=UTC`、`search_path=public`;未知、重复及降级 TLS 的选项仍拒绝自动迁移。没有放宽成接受任意连接字符串。 + +MySQL 首轮探针只验证配置历史表存在,没有实际生成配置记录;现通过正式配置 API 提交日志级别变更,检查配置代次推进。三库最终轮均执行同一业务闭环,SQL 模式配置历史记录为 1、SQLite 文件数为 0;SQLite 仅有 `smail.db`,包含业务及配置表,完整性为 `ok`。单数据库要求不是限定部署路径,也不是同时启用三种数据库。 + +上一候选 `fa0cea9` 的 PostgreSQL、MySQL、SQLite 通过证据分别位于 `012553`、`012640`、`012740` 实例,冷备位于 `012826` 实例;它们仅作为此前修复的证据,不能代替上述新候选结果。 + +**旧协议连接缺陷:** `013718` 实例真实确认,协议码撤销后 SMTP、IMAP、POP3 新登录均被拒绝,但三种旧连接仍可操作。应用现绑定认证时的用户与单个凭据快照,在数据命令入口复核数据库状态;数据库错误、凭据撤销或轮换、用户安全状态变化时拒绝旧授权。拒绝后的连接不因重新启用恢复权限,命令复核不重复执行密码哈希。 + +- SMTP 复核 `MAIL`、`RCPT`、`DATA`,并在 DATA 读取完成后再次复核;不允许失效的已认证连接降级成匿名收信。 +- IMAP 统一保护数据命令与 IDLE 轮询,失效监听连接不能阻断其他有效收件连接。 +- POP3 在事务命令前复核;撤销连接不能通过 `QUIT` 提交延迟删除。 +- 三种数据库实机测试均保持原连接跨越正式撤销 API 调用,分别验证协议码及客户端专属码的六个拒绝结果;两组结果全部为真。 +- 专项竞态回归及全量 `node test/run.mjs backend -race -count=1 -p 1 ./...` 通过;全量日志为 `test-log/run-2026-09-20T17-49-20-822Z-31240/`。 + +所有上述新候选实例均无 OOM、无资源采集或清理错误,正式服务及其他五个保护服务的进程、调用标识保持不变。测试 SQL 账号锁定、数据库桥接关闭。冷备恢复包含十八个文件,空目录还原字节一致,源目录未变,历史及新增中文邮件和附件均可读取;仍只计同机冷备恢复。 + +**新增 HTTP 会话验收:** 在隔离实例创建独立普通用户,先证明原 Cookie 可访问,再调用正式退出、禁用、修改密码 API。原 Cookie 重放和另一独立旧会话必须明确返回业务错误码 `403`,不接受服务器错误或单纯客户端清空 Cookie 作为通过。另验证无关会话保留、旧密码被拒绝、新密码可登录、超管会话不受影响。 + +首轮在三库通过,SQLite 同时复验独立 Nginx 与冷备恢复(`020610`、`020738` 实例)。复审后加强禁用再启用检查:使用另一条独立登录会话,在禁用期间完全不发请求,避免仅证明先前已销毁的会话不能恢复。最终通过目录如下: + +| 数据库与拓扑 | HTTP 最终证据目录 | +| --- | --- | +| SQLite、独立 Nginx、上游 TLS 校验 | `evidence/smail-review-runtime-20260921-021127/` | +| MySQL、直接 TLS | `evidence/smail-review-runtime-20260921-021257/` | +| PostgreSQL、直接 TLS | `evidence/smail-review-runtime-20260921-021440/` | + +三轮八项会话结果全部为真,同时再次通过协议码及客户端码撤销、邮件正文与附件读取、IMAP APPEND/EXPUNGE 和过载恢复。测试工具本地 runtime 回归 33 项通过,云端 Linux 部署回归最终 103 项通过,日志为 `linux-deployment-session-final.log`。这部分仅扩展验收工具,使用的应用二进制仍为上表同一个 SHA-256。 + +`evidence-revocation-index.json` 对十一份新候选有效结果目录记录文件摘要,逐份复核通过状态、正式服务不变、资源和清理错误为空;SQL 模式还复核单库配置历史及账号锁定。`live-final-revocation.json` 再次确认六个正式服务状态不变、本轮隔离测试单元全部停止、正式主程序摘要不变。 + +**保留失败及验证边界:** + +- 原 `013403` SMTP 探针依赖不存在的隔离域 MX,返回 `451`,未到达撤销阶段;后改用同连接 `MAIL/RCPT/RSET` 验证,不能把原失败算作撤销缺陷。 +- IMAP `TestIdleNotice` 首次专项回归超时;原测试把 IDLE 继续响应当成应用注册完成。增加等待实际注册后,专项与全量通过,未降低通知断言。 +- 构建首次因外层 PowerShell 把 Vite 警告视为错误中断;`build-revocation-retry.log` 对应完整重建成功,旧日志保留。 +- HTTP 反代首轮错误传入全局 `nginx.conf` 而非站点 `smail.conf`,验收器在启动服务前拒绝;改为正式站点模板后通过,失败日志为 `cloud-http-session-sqlite.log`。 +- 早期归档下载器对不存在的可选文件留下了零字节占位;这些不是有效证据,也没有删除。后续下载先确认远端文件存在、非空,再校验 JSON。 +- 以上均为隔离实机或自动化证据,不替代真实浏览器、外部邮件最终收件、自动证书续期或正式实例升级。 + ### 历史凭据:生产阻塞 四个 Git 跟踪的 DNS 工具原含历史明文云 API 凭据,现已改为读取 `SMAIL_ALIDNS_ACCESS_KEY_ID` 和 `SMAIL_ALIDNS_ACCESS_KEY_SECRET`。 @@ -206,11 +261,11 @@ IMAP 相关转换优先复核位置:`services/list/imap_search.go:107`,`list ### 其他未完成范围 -真实浏览器流程、超级管理员正式命令交互、旧会话与协议连接撤销、外部邮箱最终收件、插件外部调用、异机灾备等保留未完成状态。 +真实浏览器流程、超级管理员正式命令交互、外部邮箱最终收件、插件外部调用、异机灾备等保留未完成状态。HTTP 旧会话及协议旧连接的已验证范围见 9 月 21 日整改记录,不能继续全部标为未验证,也不能扩大为所有安全场景已通过。 Windows 受保护本地通道验收需要提权会话,不得降级成公开 TCP 或普通 HTTP 重置接口。 测试全部通过的限定项可以单独复核,但不能将此记录标题或部分勾选解释为整个项目审计结束。 ## 本轮收尾边界 -最终仅整理本轮明确列出的测试、工具、持续集成和本记录;原有 `docs/云端全项目测试报告.md` 的用户修改不加入本轮提交。 +本轮整理明确列出的应用整改、回归测试、验收工具、持续集成和本记录;原有 `docs/云端全项目测试报告.md` 的用户修改不编辑、不加入本轮提交。 本轮不执行生产版本替换,测试候选与正式实例保持分离。未关闭门禁继续保留,不能将本轮提交或推送成功解释为生产验收通过。 diff --git a/test/deployment/test_runtime_session.py b/test/deployment/test_runtime_session.py new file mode 100644 index 00000000..d8ad2ccf --- /dev/null +++ b/test/deployment/test_runtime_session.py @@ -0,0 +1,61 @@ +"""HTTP 撤销探针必须重放原 Cookie,不能把客户端清空 Cookie 视为服务端失效。""" + +from http.cookies import SimpleCookie +from pathlib import Path +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools')) +import runtime_probe + + +class RuntimeSessionTests(unittest.TestCase): + def test_session_snapshot_preserves_cookie_and_does_not_follow_logout(self): + original = runtime_probe.Client() + original.cookies = SimpleCookie('session=test-session; smail_csrf=test-csrf') + replay = runtime_probe.copy_session(original) + original.cookies.clear() + self.assertEqual(replay.cookies['session'].value, 'test-session') + self.assertEqual(replay.cookies['smail_csrf'].value, 'test-csrf') + + def test_only_explicit_need_login_is_accepted(self): + client = MagicMock() + for response in [(200, {'errorNo': 0}, None), (500, None, None), + (200, {'errorNo': 500}, None), (403, None, None)]: + with self.subTest(response=response): + client.request.return_value = response + with self.assertRaises(AssertionError): + runtime_probe.require_session_denied(client) + client.request.return_value = (200, {'errorNo': 403}, None) + runtime_probe.require_session_denied(client) + + def test_real_actions_are_required_and_passwords_are_not_in_result(self): + clients = [] + + def create_client(*args, **kwargs): + client = MagicMock() + client.cookies = SimpleCookie('session=test-session') + client.success.return_value = {'account': 'sessionreview'} + client.request.side_effect = lambda path, *args: (200, {'errorNo': 100 if path.endswith('/login') else 403}, None) + clients.append(client) + return client + + admin = MagicMock() + admin.success.return_value = {'account': runtime_probe.ACCOUNT} + with patch.object(runtime_probe, 'Client', side_effect=create_client), \ + patch.object(runtime_probe.secrets, 'token_urlsafe', side_effect=['TestPass1234', 'NewPass12345']): + result = runtime_probe.verify_http_session_revocation(admin, None) + self.assertTrue(all(result.values())) + self.assertEqual(len(result), 8) + edits = [call.args[1] for call in admin.success.call_args_list if call.args[0].endswith('/edit')] + self.assertEqual([item['disabled'] for item in edits], [1, 0]) + paths = [call.args[0] for client in clients for call in client.success.call_args_list] + self.assertIn('/api/v1/logout', paths) + self.assertIn('/api/v1/settings/modify_password', paths) + self.assertNotIn('TestPass1234', str(result)) + self.assertNotIn('NewPass12345', str(result)) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/tools/runtime_probe.py b/test/tools/runtime_probe.py index d2880d02..fcf46f80 100644 --- a/test/tools/runtime_probe.py +++ b/test/tools/runtime_probe.py @@ -4,6 +4,7 @@ import argparse import base64 from concurrent.futures import ThreadPoolExecutor from contextlib import ExitStack +from copy import deepcopy from email.message import EmailMessage from email.parser import BytesParser from email.policy import default as email_policy @@ -73,6 +74,66 @@ class Client: return data['data'] +def copy_session(client): + replay = Client(client.context, client.port) + replay.cookies = deepcopy(client.cookies) + return replay + + +def require_session_denied(client): + status, data, _ = client.request('/api/v1/user/info') + assert status == 200 and data is not None and data.get('errorNo') == 403, '旧会话未明确返回需要登录' + + +def verify_http_session_revocation(admin, context): + # 普通验收账号与超管分离,不改变备份恢复所需的超管密码。 + account = 'sessionreview' + password = secrets.token_urlsafe(9) + new_password = secrets.token_urlsafe(9) + admin.success('/api/v1/user/create', { + 'account': account, 'username': '隔离会话撤销验收', 'password': password, + 'disabled': 0, 'role': 'user', + }) + + def login(value): + client = Client(context) + identity = client.success('/api/v1/login', {'account': account, 'password': value}) + assert identity.get('account') == account and not identity.get('pending') + assert client.success('/api/v1/user/info').get('account') == account + return client + + first, independent = login(password), login(password) + replay = copy_session(first) + assert replay.success('/api/v1/user/info').get('account') == account + first.success('/api/v1/logout') + require_session_denied(replay) + assert independent.success('/api/v1/user/info').get('account') == account + result = {'logout_cookie_replay_denied': True, 'independent_session_preserved': True} + + # 此独立会话在禁用期间不发请求,避免只验证已被销毁的会话记录。 + replay_after_enable = login(password) + admin.success('/api/v1/user/edit', {'account': account, 'disabled': 1}) + require_session_denied(independent) + admin.success('/api/v1/user/edit', {'account': account, 'disabled': 0}) + require_session_denied(replay_after_enable) + result.update(disabled_session_denied=True, reenabled_old_session_denied=True) + + current, other = login(password), login(password) + replay = copy_session(current) + current.success('/api/v1/settings/modify_password', { + 'current_password': password, 'password': new_password, + }) + require_session_denied(replay) + require_session_denied(other) + status, data, _ = Client(context).request('/api/v1/login', {'account': account, 'password': password}) + assert status == 200 and data is not None and data.get('errorNo') == 100, '修改密码后旧密码未明确拒绝' + login(new_password) + assert admin.success('/api/v1/user/info').get('account') == ACCOUNT + result.update(password_change_sessions_denied=True, old_password_denied=True, + new_password_accepted=True, admin_session_preserved=True) + return result + + def smtp_connect(context): client = smtplib.SMTP('127.0.0.1', 25, timeout=30) client.ehlo('runtime.invalid') @@ -530,6 +591,8 @@ def main(): revoke = lambda: client.success('/api/v1/security/credentials/clients/revoke', {'client_id': credential['client']['id']}) result['client_revocation'] = verify_protocol_revocation(client, context, client_codes, revoke) assert all(result['client_revocation'].values()), '撤销客户端授权码后旧协议连接仍可访问业务数据' + result['stage'] = 'http_session_revocation' + result['http_session_revocation'] = verify_http_session_revocation(client, context) result['stage'] = 'complete' result['passed'] = True finally: