Go 服务端约定
来源:
intranet-tunnel/docs/AGENTS-ARCHIVE.md→## 两步验证(2FA)从「形同虚设」到真正生效(v1.0.6,2026-09-13)(原文 3695 字符)
两步验证(2FA)从「形同虚设」到真正生效(v1.0.6,2026-09-13)
缺陷是什么(先独立验证,不采信文档)
docs/mobile-api-contract.md 把它记为「既有缺陷」,实测确认:
# SettingAuth2FAEnabled 全仓库只有「写」没有「读」
Get-ChildItem server -Recurse -File -Include *.go |
Select-String -Pattern 'SettingAuth2FAEnabled'
# settings.go:573 "true" ← 绑定确认时写
# settings.go:606 "false" ← 关闭时写
# manager.go:182 ← 首次启动的默认值
# ↑ 没有任何一处读取它而 handleLogin 在校验口令通过后直接 issueTokens,中间没有任何 TOTP 分支。 结论:设置页能把 2FA 绑上、开关也显示「已启用」,但登录完全不校验—— 开了 2FA 没有任何防护效果。
为什么不只修移动端入口
原契约写「这样 App 有完整的 2FA 能力,而 Web 端行为一字不改」。实施时判定这句话 做不到也不该做:只保护 /api/mobile/* 的话,拿到口令的人直接调 /api/auth/login 就能绕过 2FA,缺陷只修了一半;而 Web 端要能过第二步, 登录页就必须加输入步骤,谈不上「一字不改」。
最终实现(用户确认后按此执行):
| 位置 | 改动 |
|---|---|
api/handler.go | handleLogin 在签发令牌前插入第二因子分支;抽出 completeLogin 供两条出口复用 |
api/mfa.go(新增) | twoFactorRequired / startTwoFactorChallenge / handle2FALoginVerify / acceptTOTPStep |
auth/mfa.go(新增) | TicketStore(内存、一次性、TTL、错误次数上限)+ ReplayGuard(TOTP 时间步去重) |
settings/security.go | 新增 VerifyTOTPStep,VerifyTOTP 改为它的薄包装(判定必须一致,已加等价性测试) |
| 路由 | /api/auth/2fa/verify 与 /api/mobile/auth/2fa/verify,复用同一处理器 |
auth_sms.go | /api/auth/config 增加 two_factor_required |
| 设置页两个 2FA 接口 | 改用 VerifyTOTPStep + 同一个重放防护 |
web/src/views/LoginView.vue | 识别 mfa_required、切到第二步、失败可返回重登 |
票据为什么不用 JWT 承载:JWT 无状态、签发后无法作废,而票据的核心要求是 「用过即废」;否则被截获的票据可以在有效期内反复试 6 位验证码。改用 服务端内存里的随机 id + 载荷:天然一次性、无字段可篡改。
重放防护为什么必要:VerifyTOTPStep 允许 ±1 个时间步,一个验证码实际 有效窗口长达 90 秒。没有这层判断,任何看到过该验证码的人都能在此期间复用它。 ReplayGuard 记「每个账号最近接受的步」,要求严格递增。 所有消耗验证码的地方都走它(登录第二阶段、绑定确认、关闭 2FA)—— 只加在登录上会留下「先用于登录、再用于关闭 2FA」的窄缝。
⚠️ 丢了认证器怎么恢复(设置页关不掉)
「关闭 2FA」本身也要验证码,所以丢失认证器后无法从界面关闭。恢复只能改库:
-- 云上数据库是 postgres,容器名 tunnel-postgres
update system_settings set setting_value='false' where setting_key='auth.2fa_confirmed';
update system_settings set setting_value='false' where setting_key='auth.2fa_enabled';
update system_settings set setting_value='' where setting_key='auth.2fa_secret';cd /home/docker/intranet-tunnel && docker compose restart tunnel-server三个必须注意的点(都实测过):
- 改库后必须重启进程:设置管理器把整表读进内存缓存(
NewManager里一次
reload),直接写库绕过了缓存,不重启不会生效。
- 改
.env的AUTH_2FA_ENABLED=false没用:defaults()只在首次初始化
写默认值,已有的行不会被覆盖。这与 PANEL_OUTSIDE_ACCESS 那个坑是同一类。
- 不要用 SQL 手工写
auth.2fa_secret:它在敏感键名单里,配了
CONFIG_ENCRYPT_KEY 时是密文存储,明文写进去会被当成密文解密失败。 置空是安全的(空值不加密),重新绑定请走设置页。
验证(40 项断言,全部打真实 HTTP 路径)
环境:H:\Works\.recover\mfa-e2e\(独立端口 + sqlite + MOBILE_ENABLED=true + AUTH_CAPTCHA_ENABLED=false),脚本 verify.js。
关键设计:验证码由独立的 Node 实现算出(base32 + HMAC-SHA1 + 动态截断), 不复用 Go 侧任何代码——否则「服务端接受了它」不能证明任何事。
覆盖到的断言(节选):
- 未开 2FA 时口令登录直发令牌、不返回
mfa_required(回归:老行为不能变); - 开 2FA 后登录返回
mfa_required+mfa_ticket,且没有任何令牌; - 绑定、登录第二阶段、移动端第二阶段都能用独立实现的验证码通过;
- 同一张票据不能用第二次(一次性);
- 同一验证码不能重放(含「登录用过的码不能接着用于关闭 2FA」);
- 错误验证码 / 伪造票据 / 空票据一律 401;关闭 2FA 后登录恢复直发令牌;
- 内嵌前端确为新版:从二进制里取出 SPA,沿入口 chunk 找到懒加载的
LoginView-*.js,断言它含 mfa_ticket 与「返回重新登录」。
踩到的两个坑(都在测试脚本侧,值得一提):
- 字段名是
token不是access_token(tokenPair.AccessToken的 json tag)。
第一版脚本因此把「登录成功」误判成失败,进而 401 连锁失败。
- 重放防护会让「同一窗口内连续两次消费」失败——第一版脚本复用同一个
totp(secret) 去测移动端,被正确拒绝。这不是缺陷而是设计:脚本必须 等到新的 30 秒窗口再取码(现在用 nextCode() 等待窗口推进)。 ⚠️ 同一用户 30 秒内在两台设备登录时,第二台会看到「该验证码已被使用」, 属预期行为(RFC 6238 §5.2 建议的严格去重)。
时间步单调性的一个隐含要求
ReplayGuard 要求 step 严格递增,而 VerifyTOTPStep 返回的是匹配到的那个步 (可能是 now-1)。因此「先接受了 now-1,再接受 now」没问题,反向则会拒绝—— 这在正常时钟下不会发生,但服务器时钟被回拨时会出现「验证码正确却提示已被使用」。 判据:出现该提示时先核对服务器时间(timedatectl),而不是怀疑认证器。
来源:
intranet-tunnel/docs/AGENTS-ARCHIVE.md→## 并发写数据库导致 500 的两个缺陷(v1.0.7,2026-09-13 修复)(原文 4686 字符)
并发写数据库导致 500 的两个缺陷(v1.0.7,2026-09-13 修复)
同一类根因的两个缺陷:「事务内先查后改」的加锁方式在并发下出错。 两者都只在并发写时暴露,单次点按不会遇到——所以它们能长期潜伏。
缺陷一:SQLite 报 database is locked (5) (SQLITE_BUSY)
症状(DB_DRIVER=sqlite,也就是 .env.example 的默认值):
level=DEBUG msg="store/setting.go:112 database is locked (5) (SQLITE_BUSY)"
POST /api/settings/2fa/setup → 500 {"error":"保存 2FA 密钥失败"}如何发现的:本地 2FA e2e 里「登录成功后紧接着写设置」稳定复现—— 登录会 UPDATE last_login_at,紧接着的设置写入就撞上写锁。
⚠️⚠️ 根因更正:不是「连接池里的连接没带上 busy_timeout」
本条最早给出的根因是错的,显式更正(错误结论会把人引向改连接池, 而那是绕过而非修复):
✗ 旧结论:
DB_MAX_OPEN_CONNS默认 50,busy_timeout 是每连接参数, 池里后续连接未必带上它,所以第二个写者立即拿到 SQLITE_BUSY。
实测推翻:glebarez/go-sqlite 的 applyQueryParams(sqlite.go:873)在 每个新连接上都会执行 pragma BUSY_TIMEOUT(5000)——驱动自带这个默认值, 之后才逐条执行 DSN 里的 _pragma。验证方式:同时持有 6 个物理连接逐个读 pragma, 6/6 都是 busy_timeout=5000 且 journal_mode=wal。
真实根因:DEFERRED 事务的锁升级。GORM 的「先查后改」 (UpsertSettings:先 First 再 Updates)默认以读事务开始、写时再升级; 若此时快照已被其它连接修改,SQLite 返回 SQLITE_BUSY_SNAPSHOT 且不调用 busy handler——因为等待也解决不了问题,所以它根本不等待。 busy_timeout 在这条路径上形同虚设,这才是 783ms 就返回的原因。
对照实验(同一个库,8 并发 × 20 轮「事务内先查后改」)
| 配置 | 失败次数 | 耗时 |
|---|---|---|
| 默认 DEFERRED + busy_timeout 5s | 140/140 | 783ms(根本没等) |
_txlock=immediate + 5s | 1 | ~6s(确实在等) |
_txlock=immediate + 10s | 0 | — |
SetMaxOpenConns(1)(原候选修法) | 0 | 6.0s(把并发读也串行化了) |
⚠️ 实验还纠正了一条:纯写-写并发本身不失败(默认配置下 0 错误), 失败只出现在「先读后写」的升级路径上。所以只压 UPDATE 是复现不出来的。
修法
server/internal/store/store.go 的 SQLite DSN 改为: _pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_txlock=immediate。
_txlock 只影响显式事务,自动提交的纯 SELECT 不受影响,因此读性能不变; 全仓库只有两个显式事务(UpsertSettings、UpdateClientToken),都不是纯读。
缺陷二:postgres 报 deadlock detected
发现方式:修完 SQLite 后在容器烟测里加了一条「postgres 下并发保存设置」 断言,6 并发里 2 例 500,pg 日志给出真因:
ERROR: deadlock detected ← 对应请求耗时正好 1.006s1.006s 正好是 postgres 默认的 deadlock_timeout(1s),这是识别死锁最快的判据 ——普通行锁等待不会恰好是整数秒。
根因:settings.Manager.SetMany 的入参是 Go map, for key, value := range changes 的顺序随机;两个并发事务于是可能以 相反顺序锁同一批行(如 auth.2fa_secret 与 auth.2fa_confirmed), 互相等待成环。顺序随机是关键——同一份代码在单请求下永远正常。
修法:store.UpsertSettings 落库前按 setting_key 升序排序(在副本上排序, 不改调用方切片)。它是唯一的落库入口,覆盖全部调用者 (Manager.SetMany 与 Manager.EnsureDefaults)。
⚠️ 这条缺陷在 SQLite 下测不出来:_txlock=immediate 已把写事务串行化, 死锁失去了前提。所以它的回归验证只能靠 postgres 集成测试(容器烟测); 单测只能锁定「排序确实发生了」这一事实。
验证记录(v1.0.7)
| 层 | 方法 | 结果 |
|---|---|---|
| 单元 | server/internal/store/sqlite_test.go(4 项) | 全通过;并验证过「能失败」:临时改回 DEFERRED → 53/60 失败且复现原始报错 |
| 端到端(SQLite,连接池恢复默认 50) | 96 次并发「登录 + 设置写」 | 1.0.6:23 个 500;1.0.7:0 个非 2xx,服务端日志 SQLITE_BUSY 计数 0 |
| 端到端(回归) | mfa-e2e/verify.js(2FA 两阶段全套) | 40/40 通过,确认改事务模式无回归 |
容器(postgres,server:1.0.7 镜像) | 12 项烟测 | 12/12 通过;死锁日志:修复前 3 次(17:24:52–17:25:13),修复后容器 17:26:25 启动,之后 0 次 |
生产(公网 https://tunnel.sushike.cloud,postgres) | 19 项验收(含真实并发写) | 19/19 通过;并发 8 次写设置全部 200、用时 85ms、无 5xx;.env 升级前后 sha256 完全相同(未被改动) |
⚠️ 搭本地容器烟测环境的三个坑(v1.0.7 实测)
- bind mount 的目标不存在时,Docker Desktop 会把它建成一个目录。
首次 up 时 server.env 尚未生成,挂载失败并在宿主机创建了同名目录; 之后即便文件生成好了也会持续报 not a directory: Are you trying to mount a directory onto a file。 判据:先 Test-Path <path> -PathType Container 确认它是不是被建成了目录。
- compose 服务名必须与
.env里的DB_HOST一致。
为隔离把服务起名为 ctr107-postgres,而模板里是 DB_HOST=postgres, 容器内解析不到,报 lookup postgres on 127.0.0.11:53: server misbehaving ——这个报错指向 DNS,很容易误判成网络问题,实际是服务名不匹配。 隔离请用 container_name,服务名保持 postgres。
- 改了服务名之后
docker compose down找不到旧容器:
down 是按当前 compose 文件里的服务名找容器的,旧名字的容器成了孤儿, 下一次 up 直接报 container name is already in use。 只能 docker rm -f <容器名> 手工清理(网络与卷同理)。
⚠️ 注意 .env.docker 模板并不列出所有配置项: AUTH_CAPTCHA_ENABLED(默认 true)与 MOBILE_ENABLED(默认 false)就不在其中。 搭烟测环境时若只按模板改,会被这两个默认值挡住(登录要验证码、移动端路由 404), 最后以看似无关的断言失败收场。用脚本生成时应当「存在则替换、不存在则追加」, 并在写完后回读核对。
上线记录(2026-09-13,1.0.6 → 1.0.7 增量升级)
载体:/root/intranet-tunnel-server-upgrade-1.0.7.tar(13,494,784 字节)
sha256 本机与服务器一致(02a8b88a…ba5ac),传完即核对,不靠"上传成功"的提示
步骤:备份 compose 与 .env → docker load → sed 改 tag → compose up -d tunnel-server
结果:容器 healthy(5s),/healthz version=1.0.7,compose tag 与运行镜像一致
回滚:旧镜像 1.0.0~1.0.6 全部保留在服务器上,改回 tag 再 up 即可⚠️ 两个必须守住的操作纪律(本次都做了):
.env必须逐字节还原:生产验证需要临时关图形验证码(AUTH_CAPTCHA_ENABLED=true→false),
脚本先记 sha256 再改,验证完还原后再比对一次——本次升级前、验证前、恢复后三次 sha256 全为 d1d0e659…aae2,证明这一轮没有留下任何配置漂移。
- 升级脚本只 recreate
tunnel-server:这台机器的 80/443 由宝塔接管,
docker compose up -d(不带服务名)会连带启动 compose 里的 tunnel-nginx, 而它缺 nginx-compose.conf 会报 not a directory 失败。
来源:
intranet-tunnel/docs/security-audit.md(整篇)(原文 9941 字符)
安全审计与 DDoS 防护报告
审计对象:Intranet Tunnel 内网穿透系统(服务端 / 客户端 / Nginx 网关) 审计方式:白盒代码审计 + 黑盒渗透测试(自研工具,对自有部署实例发起受控攻击) 测试环境:Docker Desktop(Linux 容器)+ PostgreSQL 16 + 服务端 intranet-tunnel/server:1.0.0 报告日期:随仓库版本维护
一、结论摘要
| 维度 | 结论 |
|---|---|
| 功能类漏洞(认证/授权/注入/路径穿越) | 未发现可利用漏洞(19 项用例全部通过) |
| DDoS 防护有效性 | 8 项攻击用例全部被有效防护,攻击期间服务延迟稳定在 1-3 ms |
| 审计中发现并修复的缺陷 | 4 个(1 个 CRITICAL 配置风险、1 个 HIGH 协议层内存放大、2 个 LOW 加固项) |
| 残余风险 | 3 项,均需在目标环境结合实际拓扑确认(见第六节) |
二、攻击面分析(威胁模型)
系统对外暴露三个端口,按风险从高到低:
| 端口 | 暴露范围 | 主要威胁 |
|---|---|---|
| 47800 控制连接 | 公网 | 连接耗尽、慢速握手、协议层内存放大、凭据爆破 |
| 47801 管理 API | 仅回环(Nginx 代理) | 凭据爆破、JWT 伪造、SQL 注入、越权 |
| 48080 数据面 | 仅回环(Nginx 代理) | 慢速 HTTP(Slowloris)、连接洪水、由 Nginx 转发的攻击流量 |
| 48081-48100 TCP 隧道池 | 公网 | 连接洪水、端口扫描 |
关键风险点识别(审计前的代码审查结论):
- 协议帧内存放大:
ReadMessage若按帧头声明的长度预分配缓冲,攻击者用 4 字节帧头即可让服务端为每条连接预留 1 MiB,形成 1:N 的内存放大; - bcrypt CPU 放大:口令哈希「故意昂贵」(cost=12 约数百毫秒),若不限制并发,少量请求即可让 CPU 满载;
- 慢速握手 / 慢速 HTTP:超时过长时,攻击者可用极低带宽长期占用连接与协程;
- 单一隧道打满拖垮全站:缺少隧道级并发上限时,一条高流量隧道会耗尽服务端资源;
- 跟踪表膨胀:基于 IP 的限流若不设容量上限与回收,会被海量伪造源 IP 撑爆内存;
- NAT 环境下 IP 维度失效:经 Docker/Nginx 转发后所有来源折叠为网关地址,单 IP 限流失去区分度,必须依赖全局上限兜底。
三、DDoS 防护架构
新增 server/internal/guard 包,实现七层防护。三个入口(控制面、数据面、管理 API)共用同一套封禁状态——因此爆破管理后台的来源会同时失去建立隧道连接的资格。
┌─────────────────────────────────────────┐
控制连接 47800 ───▶│ 1. 全局并发上限(NAT 场景兜底) │
数据连接 48080 ───▶│ 2. 单 IP 并发上限 │
TCP 池 48081+ ───▶│ 3. 单 IP 连接建立速率(令牌桶) │
│ 4. 认证失败自动封禁(fail2ban 式) │
管理 API 47801 ───▶│ 5. 登录限速 + bcrypt 并发上限 │
│ 6. 在线客户端总数上限 │
│ 7. 单隧道并发上限 / 工作连接创建速率 │
└─────────────────────────────────────────┘
│
▼
shared/protocol:帧分块读取(消除内存放大)
+ 握手超时(消除慢速握手)3.1 关键实现要点
| 机制 | 实现位置 | 说明 |
|---|---|---|
| 连接准入 | control.Manager.handleIncoming | 在读取任何数据之前完成判断,被拒连接不分配缓冲与协程 |
| 握手超时 | GUARD_HANDSHAKE_TIMEOUT(默认 5s) | 独立于心跳超时;未完成登录的连接只允许短暂存在 |
| 帧分块读取 | shared/protocol.readBodyChunked | 内存占用与实际到达的数据量成正比,4 KiB 一块 |
| 登录双闸 | guard.AllowLogin + loginSem 信号量 | 前者限速率,后者限制并发 bcrypt 数量(按 CPU 核数缩放,封顶 8) |
| 自动封禁 | guard.RecordAuthFailure | 失败窗口内达阈值即封禁;认证成功会清零计数,避免误封 |
| 跟踪表保护 | MaxTrackedIPs + 定期空闲回收 | 防止海量伪造源 IP 撑爆内存 |
| 白名单 | GUARD_WHITELIST | 白名单来源不受任何限制(放行 Nginx、监控、办公网) |
| 运维接口 | GET /api/guard、POST /api/guard/ban | 查看按原因分类的拒绝统计;手动封禁并立即断开该来源的在线连接 |
3.2 修复的协议层缺陷(HIGH)
shared/protocol/message.go 原先按帧头声明的长度一次性分配缓冲:
body := make([]byte, size) // size 最大 1 MiB
io.ReadFull(r, body)攻击者只需发送 4 字节帧头(声明 1 MiB)而不发送任何载荷,服务端就会为每条连接分配 1 MiB 并阻塞在读取上。1 万条半开连接即可占用约 10 GiB 内存。
修复:改为 4 KiB 分块读取(readBodyChunked),内存占用与实际到达的数据量成正比——攻击者要消耗 1 MiB 就必须真的传输 1 MiB。配套回归测试见 shared/protocol/conn_test.go。
3.3 Nginx 与应用层防护的分工
| 层 | 负责 | 局限 |
|---|---|---|
| Nginx | 请求级限流(limit_req)、连接数限制(limit_conn)、慢速攻击超时、头部大小限制、方法白名单、IP 黑名单 | 只能看到经它代理的 HTTP 流量;TCP 隧道端口只能力度有限地限制并发 |
| 应用层 guard | 控制端口准入、认证失败封禁、协议层防护、隧道级并发、bcrypt 并发 | 经 NAT 后源 IP 可能被折叠成网关地址 |
| fail2ban + iptables | 把应用层识别出的恶意来源推到内核层持续阻断 | 需要额外部署;容器日志转义需注意 |
| 内核 sysctl | SYN flood、连接跟踪表、端口/文件描述符耗尽 | 属兜底,不替代应用层识别 |
四、渗透测试结果
工具:tools/pentest(自研,Go 实现,可复用于安全回归)
cd tools/pentest
go run . -mode all -pass '<管理员口令>' -domain app.example.com -concurrency 1504.1 信息泄露与加固项
| 编号 | 用例 | 结果 |
|---|---|---|
| INFO-01 | 安全响应头(X-Frame-Options / X-Content-Type-Options) | PASS(修复后) |
| INFO-02 | 是否暴露 Server 版本 | PASS:响应中不含 Server 头 |
| INFO-03 | 错误响应是否泄露堆栈/SQL/文件路径 | PASS:5 类错误路径均未泄露内部细节 |
| INFO-04 | 静态文件服务路径穿越 | PASS:无法读取 /etc/passwd |
| INFO-05 | 健康检查信息面 | OBSERVED:仅暴露版本/运行时长/在线数 |
| INFO-06 | 危险 HTTP 方法(TRACE) | PASS(修复后) |
| AUTH-00 | 8 个受保护接口的未授权访问 | PASS:无令牌时全部 401 |
4.2 认证与授权
| 编号 | 用例 | 结果 |
|---|---|---|
| AUTH-01 | 畸形令牌(空、非 JWT、两段、空签名、随机签名) | PASS:5 种全部拒绝 |
| AUTH-02 | alg=none 算法绕过 | PASS:未签名令牌返回 401 |
| AUTH-03 | JWT 签名篡改 | PASS:篡改后返回 401 |
| AUTH-04 | JWT 载荷篡改(提权 uid/role) | PASS:返回 401 |
| AUTH-05 | 常见弱密钥伪造(11 个字典项) | PASS(修复后):改用 32 字节随机密钥 |
| AUTH-06 | refresh 令牌越权当访问令牌 | PASS:返回 401(TokenType 校验有效) |
| AUTH-07 | 过期令牌 | PASS:返回 401 |
| AUTH-08 | 登录响应能否区分账号是否存在 | PASS:文案一致,耗时差异约 1% |
| AUTH-09 | 接口是否泄露客户端凭据 | PASS:列表不含 token / token_hash |
AUTH-08 的实现依据:账号不存在与口令错误返回完全相同的文案,且两种情况都执行一次等价的 bcrypt 比对,把时序差异压到约 1%(实测)。
4.3 注入类
| 编号 | 用例 | 结果 |
|---|---|---|
| INJ-01 | 过滤参数 SQL 注入(7 个 payload) | PASS:未触发 SQL 错误或 5xx |
| INJ-02 | 写入接口注入(以 payload 作为 client_id/name) | PASS:均以 4xx 正常拒绝 |
| INJ-03 | 隧道 local_addr 的 SSRF 面 | MANUAL(见残余风险 R1) |
| INJ-04 | CRLF 响应头注入 | PASS:客户端协议栈即拒绝,服务端未回显 |
| INJ-05 | 响应反射未转义脚本(XSS) | PASS:payload 未被原样回显 |
| INJ-06 | 畸形输入触发 5xx(5 类边界) | PASS:均以 4xx 拒绝 |
SQL 注入未被利用的根因:全部查询经 GORM 参数化;路径参数在使用前先做
strconv.ParseUint校验,非法输入在到达 ORM 之前即被拒绝。
4.4 DDoS 防护实测(并发 150,单模块 8s)
| 编号 | 攻击类型 | 结果 | 实测数据 |
|---|---|---|---|
| DOS-01 | 控制端口连接耗尽 | PASS | 150 条并发冲击:服务端接受 20 / 拒绝 130;服务延迟 2 ms |
| DOS-01c | 攻击期间正常业务可用性 | PASS | 正常隧道路由请求成功,耗时 3 ms |
| DOS-02 | 协议帧内存放大(150 条半开连接,各声明 1 MiB) | PASS | 理论放大 150 MiB 下服务延迟 2 ms |
| DOS-03 | 慢速握手(150 条,每 300 ms 发 1 字节) | PASS | 150 条全部在握手超时内被关闭,平均存活 389 ms |
| DOS-04 | 慢速 HTTP / Slowloris(100 条) | PASS | 100 条全部被回收,平均存活 4.70 s(= 5 s 请求头超时) |
| DOS-05 | 数据面连接洪水(150 条) | PASS | 服务延迟 1 ms |
| DOS-06 | 登录爆破(60 次错误口令) | PASS | 第 4 次起被限流,其后 57 次全部 429;限流期内正确口令同样被拒绝 |
| DOS-07 | 全部攻击后服务可用性 | PASS | 健康检查通过,延迟 1 ms |
服务端防护运行指标(GET /api/guard,一轮完整测试后):
{
"metrics": {
"active_control_conns": 1,
"tracked_ips": 1,
"banned_ips": 0,
"total_rejected": 1986,
"auth_failures": 19,
"rejected_by_reason": {
"conn_rate": 1702,
"login_rate": 196,
"per_ip_data_conns": 88
}
}
}观察:
auto_bans = 0。原因是限流先于封禁生效——被限流的请求在进入认证逻辑之前就被拒绝,失败计数累积速度受限,因此短时间内表现为令牌桶限流(每分钟自动恢复部分配额)。这是设计取舍:限流对正常用户的误伤更小,持续攻击才会逐步触发封禁。若需要立即阻断某来源,使用POST /api/guard/ban手动封禁(会同时断开其在线连接)。
4.5 测试工具自身的一个坑(记录备查)
渗透测试工具在验证「攻击期间正常业务可用性」时一度误报 VULNERABLE,排查发现是工具缺陷:Go 的 http.Request 中 Host 必须赋值给 req.Host 字段,写进 Header["Host"] 会被静默忽略,导致请求打到了默认 Host 上、命中 404。修复后该用例通过。
记录此事的价值:攻击工具本身也会出错,误报与漏报都要在结论前交叉验证(本例中 404 响应体明确写着「没有匹配的隧道」,与"防护误伤"的假设不符,因此转向排查工具)。
五、审计中发现并修复的问题
| # | 严重级别 | 问题 | 修复 |
|---|---|---|---|
| 1 | CRITICAL | JWT_SECRET 仍是示例值 change_me_to_a_random_string_at_least_32_chars,可被直接用于伪造管理员令牌 | 更换为 32 字节随机值;README 与检查清单强调 openssl rand -hex 32 |
| 2 | HIGH | 协议帧按声明长度预分配缓冲,存在 1:N 内存放大(4 字节换 1 MiB) | readBodyChunked 分块读取 + 回归测试 |
| 3 | LOW | 静态资源响应缺少 X-Content-Type-Options: nosniff(仅 JSON 响应设置了) | 移入全局 secureHeadersMiddleware,覆盖全部响应 |
| 4 | LOW | SPA 兜底路由匹配任意方法,TRACE 请求返回 200 | 新增 methodAllowlistMiddleware,非白名单方法返回 405 |
同时补齐的防护能力(属"审计驱动的加固",非缺陷):
- 控制面连接准入、握手超时、登录限速、认证失败自动封禁;
- 数据面连接准入、单隧道并发上限、请求头读取超时(消除 Slowloris);
- bcrypt 并发信号量(消除 CPU 耗尽放大);
- IP 跟踪表容量上限与空闲回收;
- Nginx 侧收紧超时、细化限流、方法白名单、黑名单 include;
- sysctl 抗 SYN flood / 连接跟踪表 / 端口耗尽参数;
- fail2ban filter 与 jail(应用层识别 → 内核层阻断)。
六、残余风险与建议
| # | 风险 | 级别 | 建议 |
|---|---|---|---|
| R1 | 隧道 local_addr 的 SSRF 面:持有客户端 Token 的主体可把公网流量转发到该客户端可达的任意内网地址,借此探测内网 | MEDIUM | ① 客户端 Token 按最小权限分发并定期轮换;② 若需强约束,可增加 local_addr 允许网段校验(服务端)或客户端侧目标白名单;③ 内网侧部署出向访问控制 |
| R2 | NAT 场景下 IP 维度限流失效:经 Docker 端口映射或 Nginx stream 转发后,服务端看到的是网关地址,所有来源被折叠为同一 IP | MEDIUM | ① 控制端口 47800 尽量直接暴露(Linux 下 iptables DNAT 会保留真实源 IP);② 若必须经 Nginx 转发,请看清 deploy/nginx/conf.d/stream/tunnel-stream.conf 中关于 limit_conn 与放宽 GUARD_MAX_CONTROL_CONNS_PER_IP 的说明;③ 云环境优先启用厂商 DDoS 高防 |
| R3 | 单机防护无法抵御大流量带宽型攻击 | HIGH | 应用层防护解决的是「连接耗尽」与「应用层洪水」;TB 级流量型攻击必须依赖云厂商高防 IP / CDN / 清洗中心,本项目不替代该能力 |
其他建议:
- 管理后台收敛:用
API_ALLOWED_CIDRS限制到办公网出口,并在 Nginx 层加allow/deny; - TLS 严格校验:生产环境把服务端证书分发给客户端并用
TLS_CA_CERT校验,避免长期使用TLS_INSECURE=true; - 告警对接:对
GET /api/guard的total_rejected与banned_ips设置阈值告警;Nginx 的 429/444 也应纳入监控; - fail2ban 白名单:
ignoreip必须包含 Nginx、监控探针与办公网出口,否则一次误判会导致整个办公网无法访问; - 定期回归:每次版本发布前运行
go run ./tools/pentest -mode all,把安全回归纳入 CI。
七、复现方法
# 1) 部署被测实例(含防护)
docker compose up -d --build
# 2) 准备数据面(创建客户端与 http 隧道,并启动客户端)
# 详见 README「快速开始」
# 3) 运行完整渗透测试
cd tools/pentest
go run . -mode all \
-api http://127.0.0.1:47801 \
-control 127.0.0.1:47800 \
-data 127.0.0.1:48080 \
-domain app.example.com \
-user admin -pass '<管理员口令>' \
-concurrency 150 -duration 10s
# 仅做 DDoS 防护验证(会触发登录限流)
go run . -mode dos -pass '<管理员口令>' -domain app.example.com
# 输出 JSON 便于接入 CI
go run . -mode all -pass '<口令>' -json退出码:0 表示未发现确认漏洞;2 表示存在确认漏洞(可直接用于流水线卡点)。
注意:-mode dos 中的登录爆破用例会耗尽本机来源的登录配额,并对该来源产生限流/封禁。测试后如需立即恢复,可等待 GUARD_BAN_DURATION,或从白名单来源执行,或调用 POST /api/guard/unban。
八、防护参数速查
| 参数 | 默认值 | 作用 |
|---|---|---|
GUARD_ENABLE | true | 总开关 |
GUARD_MAX_CONTROL_CONNS | 4096 | 全局并发控制连接上限(NAT 场景的兜底) |
GUARD_MAX_CONTROL_CONNS_PER_IP | 32 | 单 IP 并发控制连接上限 |
GUARD_CONTROL_CONN_RATE | 120/min | 单 IP 连接建立速率 |
GUARD_LOGIN_RATE | 20/min | 单 IP 登录尝试速率(应用层) |
GUARD_HANDSHAKE_TIMEOUT | 5s | 未完成登录的连接存活上限;也是数据面请求头读取超时 |
GUARD_BAN_THRESHOLD / GUARD_BAN_DURATION | 10 次 / 30min | 失败窗口内达阈值即封禁 |
GUARD_MAX_DATA_CONNS / _PER_IP | 8192 / 128 | 数据面并发连接上限 |
GUARD_MAX_CONNS_PER_TUNNEL | 256 | 单隧道并发上限 |
GUARD_MAX_CLIENTS | 1000 | 在线客户端总数上限 |
GUARD_WORK_CONN_RATE | 600/min | 单客户端建立工作连接的速率 |
GUARD_WHITELIST | 127.0.0.1/32,::1/128 | 不受限制的来源 |
调参原则:先把全局上限按机器规格定好,再按业务特征收紧单 IP 维度,最后用 GUARD_WHITELIST 放行可信来源。参数过低会误伤正常用户——渗透测试中的 DOS-01c(攻击期间正常业务可用性)就是用来守住这条底线的用例。
