补充三种数据库的HTTP旧会话撤销实机验收与证据记录
This commit is contained in:
parent
30e86af15d
commit
5d6931fbcd
@ -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` 的用户修改不编辑、不加入本轮提交。
|
||||
本轮不执行生产版本替换,测试候选与正式实例保持分离。未关闭门禁继续保留,不能将本轮提交或推送成功解释为生产验收通过。
|
||||
|
||||
61
test/deployment/test_runtime_session.py
Normal file
61
test/deployment/test_runtime_session.py
Normal file
@ -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()
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user