补齐管理员恢复验收证据并严格校验反代跳转
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-18 03:03:46 +08:00
parent 4c9490c6c5
commit a725aac6a7
5 changed files with 176 additions and 9 deletions

View File

@ -0,0 +1,37 @@
# 反代跳转验收整改记录
## 问题与修复
2026 年 9 月 18 日 02:56 的远程核查发现,`test/deployment/verify_nginx_tls.py` 仅断言公网 80 跳转地址,对于应用 8001 只要求返回 308。因此线上仍跳转至内部 8002 时,整体输出仍为 `passed`。这属于验收覆盖缺口,不代表线上问题已经解决。
修改前提交并推送基线 `4c9490c`。本轮收紧验收:80 和 8001 均必须返回 308,且完整地址为 `https://mail.solit.cn/acceptance?x=1`,保留路径及查询参数;端口泄露、错误协议或域名、缺少 Location、错误状态均记录失败。失败明细写入 `failures`,整体 `status=failed`、进程退出码 1;仍完成其他传输检查以保留诊断证据。
本轮只修改验收工具,没有修改线上反代配置、应用或数据库,没有启动本机测试,没有暂停任何业务服务。
## 远程隔离回归
目录 `/var/tmp/smail-review-redirect-acceptance-20260918`。
- [✓] 新增 `test_nginx_redirect_acceptance.py` 六项用例通过,覆盖双端口正确跳转、内部端口泄露、公网入口错误、缺少 Location、错误状态、路径及查询参数丢失和错误域名。
- [✓] 单元 `smail-redirect-tests-20260918-0300.service` 成功结束,运行 478 毫秒,峰值 16.7 MiB、交换空间为零;持久日志为 `tests.txt`。
- [✓] 使用独立网络、只读系统、私有临时目录、64 MiB 内存硬上限,线上应用、管理 socket、Nginx 配置和正式 ACME 状态不可访问。模拟测试以 root 运行,不宣称非特权测试或真实部署验收。
本机与远程 SHA-256 一致:
- 检查器:`8d22762796747d52d63000e15a39a40cfb99e50b75cc1e833bf014ccc2fe6afd`。
- 回归文件:`4553ede3be564f3b2b9f59e743dd99bc0dc7acd3ff7e33acbfdafec814fec64b`。
## 线上负向验证
03:00:10(Asia/Shanghai)执行修正版只读检查,输出保留在 `live-result.json`,进程退出码为 1。
- [✓] 443、8002、465、993、995、25 和 587 的系统信任及证书一致性检查通过;25/587 明文阶段不暴露 AUTH。
- [✓] 公网 80 返回正确的公开 HTTPS 地址,未知 Host/SNI 拒绝,页面及探活接口可用。
- [!] 8001 实际返回 `https://mail.solit.cn:8002/acceptance?x=1`,严格检查正确报告 `public_redirect` 失败,整体不再误报通过。
这里只验证 TLS/STARTTLS,没有通过 587 发信,没有发送业务邮件。线上证书指纹仍为 `608fb55e3b8add3426a63f672debd86a58389a1a78d0961b9603b2e1abecae43`。收尾状态记录在 `services.txt`。
## 后续
- [ ] 按《完整候选反代隔离验收记录》对候选建立可回退备份并正式部署,通过正常配置入口设置公开端口,再运行严格检查。
- [!] 本次修复的是验收误判,线上跳转整改尚未生效;更不能据此宣称证书续期、浏览器业务或整体发布已经通过。

View File

@ -2,6 +2,16 @@
## 范围与结论
### 当前进展:管理员恢复事务通过,线上跳转严格验收失败
- [✓] 2026 年 9 月 18 日恢复服务 24 项、协议凭据 8 项远程隔离回归通过,零失败、零跳过;真实 SQLite 和 Unix Socket 覆盖重置提交、四阶段撤销回滚及目标歧义拒绝。
- [✓] 02:48:57 测试单元正常退出,峰值 78.1 MiB、交换空间为零;终态、二进制摘要、双库完整性和线上服务收尾核对完成。
- [✓] 修复传输检查只记录而不拒绝 8001 内部端口跳转的验收缺口,六项远程隔离回归通过;严格检查保留失败明细并以非零退出码拒绝放行。
- [!] 03:00 严格线上验证明确失败:8001 仍跳向内部 8002。七个 TLS/STARTTLS 入口通过不等于公网配置通过,完整候选尚未正式部署。
- [!] 正式管理员恢复命令、浏览器会话退出、真实协议连接撤销、无停机续期和其他原发布门槛仍未完成,整体生产放行继续不通过。
详见《管理员恢复事务验收记录》和《反代跳转验收整改记录》。下方阶段记录保留历史结论,不得将旧检查器的 `passed` 当作严格跳转验收通过。
### 当前进展:无停机 HTTP 备用方案真实挑战受阻
- [✓] 候选支持显式 HTTP Webroot,不抢占端口、不停止 Nginx;41 项远程隔离回归通过,保留默认 DNS 及校验、回退机制。

View File

@ -0,0 +1,55 @@
# 管理员恢复事务验收记录
## 范围与结论
测试只在 `47.108.78.12`、主机 `ikun2` 执行。本机仅做源码复审、交叉编译、Git 和文件传输,不运行测试。远程日期为 2026 年 9 月 18 日,时区为 Asia/Shanghai。
- [✓] 恢复服务 24 项、协议凭据服务 8 项顶层用例通过,零失败、零跳过;恢复服务包含四个撤销故障子用例。
- [✓] 真实 Unix Socket 请求完成真实 SQLite 事务,新密码可用、旧密码失效,管理员恢复及三协议凭据撤销,普通用户不受影响。
- [✓] 四个撤销阶段分别注入失败,要求命中指定错误并回滚密码、安全状态及凭据;多个超级管理员时安全拒绝。
- [✓] 测试单元最终退出成功;终态、持久日志、产物摘要和线上服务状态已核对。
- [!] 不等同于正式 `smail-admin` 密码代理交互验收,也没有验证浏览器旧会话、已建立邮件连接的运行态撤销或 MySQL 兼容性。
测试使用临时空 SQLite 数据库,不清理线上双库或其他项目数据;IPC 夹具仅信任测试进程 UID,不改变生产通道只允许 root 的权限要求。没有更改线上应用、证书、管理命令或续期任务,没有暂停共享服务。
## 新增覆盖
文件为 `backend/services/adminreset/reset_integration_linux_test.go`,中文基线提交 `4c9490c` 已推送。
1. `TestUnixResetCommitsPasswordAndRevokesAllProtocolCodes` 经 `SendReset`、Unix Socket、`ResetSuperAdmin` 完成重置。检查密码、安全版本、会话版本及改密状态;检查恢复码、密保、WebAuthn 和协议凭据撤销;通过实际凭据服务验证 SMTP、IMAP、POP3 旧授权码失效,普通用户的凭据仍可用。回调中重新查询数据库,确认运行态撤销回调在提交之后执行。
2. `TestResetSuperAdminRollsBackEveryRevocationStage` 在恢复码、密保、WebAuthn、协议凭据四个表的撤销操作上分别使用 SQLite 触发器注入失败。断言错误含 `injected revocation failure`,避免密码校验提前失败造成假通过;检查两用户状态及有效凭据数量未变,回调未执行。
3. `TestResetSuperAdminRejectsAmbiguousTargetWithoutChanges` 在存在两个超级管理员时要求返回明确歧义错误,不改变用户或凭据,不执行回调。
## 运行证据
最终目录为 `/var/tmp/smail-review-adminreset-final-20260918`,结果为 `results-20260918-024726/results.json`,每包完整日志在同目录。
| 测试包 | 通过 | 失败 | 跳过 | 用时 |
| --- | --- | --- | --- | --- |
| adminreset | 24 | 0 | 0 | 35.21 秒 |
| credentials | 8 | 0 | 0 | 55.62 秒 |
`smail-tests-20260918-024726.service` 于 02:47:26 启动、02:48:57 正常退出,退出码 0。墙钟时间 91.082 秒、CPU 时间 22.788 秒、峰值内存 78.1 MiB、交换空间为零。执行采用逐顶层用例独立进程、非 root、独立网络、192 MiB 内存硬上限,禁止访问线上目录。收尾确认 `inactive/dead`、`MainPID=0`;不能把此前运行中的 `Result=success` 当作终态。
本机及远程实际二进制 SHA-256 一致:
- adminreset:`df7b1ced593bbc49af82dbaa2eef6e165a312aa306f0ec8e26c72961e5291036`。
- credentials:`b6a79a99e0ae97a79850b762c3e4cc4e5fe3e4235ee31d0a987d8b3664ad1805`。
## 失败记录
首次 `smail-tests-20260918-024358` 因归档中的测试程序没有可执行权限,在用例执行前失败。仅修正对应测试二进制权限,没有修改线上程序权限。
第二次目录 `/var/tmp/smail-review-adminreset-20260918/results-20260918-024417` 保留原始失败:恢复服务 22 项通过、2 项失败,协议凭据 8 项通过。新测试密码夹具超过现有长度限制;随后改成符合限制的测试密码,并通过 `HashUserPassword` 创建夹具。回滚测试同时收紧为必须命中指定注入错误,避免将无关错误计作事务回滚成功。最终结果不能覆盖或隐藏这些失败记录。
## 线上收尾及下一门槛
02:56 的只读核查保存在最终目录 `terminal-review.json`、`terminal-transport.json`。双库完整性为 `ok`,管理 socket 权限 0600,未认证接口拒绝访问,无新增 OOM。四个线上服务保持运行,Smail PID 574584、Nginx PID 578671、Scert PID 589832、MySQL PID 589785,InvocationID 与前序记录一致。
七个 TLS/STARTTLS 入口通过系统信任和同证书检查。旧传输检查仍将内部 8001 跳向 8002 的情况记录为通过,该结论仅能支持原检查覆盖范围;随后的严格验收已将其标为失败,详见《反代跳转验收整改记录》。线上管理工具版本仍为 `review-2263779`,不得将测试候选当作已部署版本。
- [ ] 在受保护隔离部署中完成正式管理命令及系统密码代理交互。
- [ ] 验证新密码登录、旧密码拒绝、浏览器旧会话退出和真实协议连接及授权码撤销。
- [ ] 完成数据库兼容、候选可回退部署及原发布清单中的其他门槛。
整体生产放行仍不通过,不因本次后端集成回归而缩减验收范围。

View File

@ -0,0 +1,56 @@
'''验证反代验收同时约束公网与上游跳转,不访问真实网络。'''
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent))
import verify_nginx_tls as verify
class RedirectAcceptanceTest(unittest.TestCase):
expected = 'https://mail.solit.cn/acceptance?x=1'
def check(self, first, second):
results = {'checks': []}
with patch.object(verify, 'request', side_effect=[first, second]) as request:
failures = verify.check_redirects(results)
self.assertEqual(request.call_count, 2)
self.assertEqual([call.args[0] for call in request.call_args_list], [80, 8001])
self.assertEqual(len(results['checks']), 2)
return failures
def response(self, location=None, status=308):
return status, {'Location': location or self.expected}, b''
def test_both_ports_use_public_address(self):
self.assertEqual(self.check(self.response(), self.response()), [])
def test_internal_https_port_is_failure(self):
failures = self.check(self.response(), self.response('https://mail.solit.cn:8002/acceptance?x=1'))
self.assertEqual(len(failures), 1)
self.assertEqual(failures[0]['port'], 8001)
def test_public_port_does_not_skip_validation(self):
failures = self.check(self.response('http://mail.solit.cn/acceptance?x=1'), self.response())
self.assertEqual([item['port'] for item in failures], [80])
def test_missing_location_records_failure(self):
failures = self.check(self.response(), (308, {}, b''))
self.assertIsNone(failures[0]['actualLocation'])
def test_wrong_status_is_failure(self):
failures = self.check(self.response(), self.response(status=200))
self.assertEqual(failures[0]['actualStatus'], 200)
def test_path_and_query_must_be_preserved(self):
for location in ('https://mail.solit.cn/', 'https://mail.solit.cn/acceptance',
'https://other.invalid/acceptance?x=1'):
with self.subTest(location=location):
self.assertTrue(self.check(self.response(), self.response(location)))
if __name__ == '__main__':
unittest.main()

View File

@ -28,6 +28,20 @@ def request(port, path='/', host=HOST, tls=True):
stream.close()
def check_redirects(results):
expected = 'https://' + HOST + '/acceptance?x=1'
failures = []
for port in (80, 8001):
status, headers, _ = request(port, '/acceptance?x=1', tls=False)
location = headers.get('Location')
results['checks'].append({'port': port, 'redirect': location, 'status': status})
if status != 308 or location != expected:
failures.append({'port': port, 'check': 'public_redirect',
'expectedStatus': 308, 'actualStatus': status,
'expectedLocation': expected, 'actualLocation': location})
return failures
def main():
results = {'time': datetime.now(timezone.utc).isoformat(), 'checks': []}
leaf = subprocess.run(['openssl', 'x509', '-in', '/opt/smail/ssl/custom.crt', '-outform', 'DER'],
@ -60,13 +74,7 @@ def main():
results['checks'].append({'port': port, 'pageStatus': status, 'pageBytes': len(body)})
status, _, body = request(port, '/api/ping')
assert status == 200
for port in (80, 8001):
status, headers, _ = request(port, '/acceptance?x=1', tls=False)
assert status == 308
location = headers['Location']
if port == 80:
assert location == 'https://mail.solit.cn/acceptance?x=1'
results['checks'].append({'port': port, 'redirect': location})
results['failures'] = check_redirects(results)
assert request(80, host='untrusted.invalid', tls=False)[0] == 404
results['checks'].append({'unknownHttpHost': 'rejected'})
try:
@ -80,9 +88,10 @@ def main():
assert root['http']['port'] == 8001 and root['http']['httpsPort'] == 8002
assert root['security']['webAuthnRpOrigins'] == ['https://mail.solit.cn']
results['fingerprintSHA256'] = expected
results['status'] = 'passed'
results['status'] = 'failed' if results['failures'] else 'passed'
print(json.dumps(results, ensure_ascii=False, indent=2))
return int(bool(results['failures']))
if __name__ == '__main__':
main()
raise SystemExit(main())