[L-04][high] NixMsg 直接终止 TLS 时 r.TLS 为空,后台 Cookie 不带 Secure #23

Closed
opened 2026-09-30 13:56:55 +08:00 by nixevol · 1 comment
Owner

编号:L-04 严重级:high 工作线:监听与 HTTP(internal/listener、internal/httpx、serve 的 HTTP 装配) 来源:审查 A-01、P-08
依赖:无 被依赖:无

结论与统一方案

两份审查结论一致:TLS 握手后内层连接又被 peekFirstByte 包成 bufferedConn 交给 http.Server,net/http 只在连接实现了 ConnectionState() 时才填 r.TLS,所以直连 TLS 时 httpx.IsHTTPS 恒为 false,setSessionCookie 不加 Secure(serve 也没设 SecureCookies)。按 DEVELOPMENT 11.3 推荐的"NixMsg 直接终止 TLS"部署时,浏览器会把管理员 Cookie 带到同主机的明文请求上。

统一方案采用审查 P-08 的做法(改动最小,覆盖所有依赖 r.TLS 的代码):

  1. TLS 分支返回专用包装 tlsBufferedConn{*bufferedConn; tc *tls.Conn},实现 ConnectionState() 返回 tc.ConnectionState()。明文连接继续用原来的 bufferedConn,不能给它加这个方法,否则明文请求也会被当成 TLS。
  2. 兜底:配置了证书且 allow_plaintext=false 时,serve 给 admin.Deps.SecureCookies 传 true。
  3. 可选加固:直连 TLS 的响应加 Strict-Transport-Security(只在 TLS 请求上)。

审查 A-01 提出的 ConnContext + 回填方案作为备选,不同时实现。严重级取 high:泄漏的是 12 小时有效的管理员会话。

改动文件

internal/listener/conn.go、server.go;cmd/nixmsg/serve.go(admin.Deps 的 SecureCookies 一行)。

与其他问题的交互 / 冲突说明

  • 与 L-01 同改 classify,监听线内顺序合入。
  • serve.go 的 admin.Deps 也会被 H-02(审计 logger)修改,合并时保留两处。

验收与测试

  • listener 测试中 TLS 请求的 handler 断言 r.TLS != nil,明文请求断言为 nil。
  • 通过真实监听器走 TLS 登录,Set-Cookie 同时含 Secure、HttpOnly、SameSite=Lax。注意 httptest.NewTLSServer 用的是原生 *tls.Conn,测不出这个问题。

问题明细(各区审查原文,证据含文件与行号)

以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。解决方案以本 issue 上方的"结论与统一方案"为准;原文里的方案与之不一致时,按上方执行。

[A-01] 直连 TLS 时后台 Cookie 不带 Secure

  • 严重级:high
  • 分类:安全
  • 现象与影响:NixMsg 自己终止 TLS 时,nixmsg_admin 的 Set-Cookie 没有 Secure。这正是 DEVELOPMENT 11.3 要求的生产形态(明确说不要用 OpenResty 终止 TLS)。
    • 之后浏览器对同一主机名的任何明文请求都会带上会话 Cookie。Cookie 不区分端口,比如被中间人重定向到 http://主机/。
    • 即使 allow_plaintext: false 时服务端读到首字节就断开,请求头也已经发出去了。
    • 拿到 Cookie 就拿到 12 小时的管理员权限。
  • 证据:
func (h *Handler) setSessionCookie(w http.ResponseWriter, r *http.Request, value string, maxAge int) {
	secure := h.forceSec || httpx.IsHTTPS(r, h.trusted)
	http.SetCookie(w, &http.Cookie{
		Name:     cookieName,
		Value:    value,
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		Secure:   secure,
		MaxAge:   maxAge,
	})
		tlsConn := tls.Server(c, s.certs.TLSConfig())
		if err := tlsConn.Handshake(); err != nil {
			return KindClosed, nil, err
		}
		return s.classify(tlsConn, isAdmin, true)
	}
	// ...
	if b >= 'A' && b <= 'Z' {
		return KindHTTP, c, nil
	}
  • 第二次 classify 里的 peekFirstByte 把 *tls.Conn 包成 *bufferedConn(internal/listener/conn.go:22-42)。net/http 只在连接本身是 *tls.Conn 时才填 r.TLS,所以 httpx.IsHTTPS(internal/httpx/clientip.go:41-45,只看 r.TLS != nil)在直连 TLS 下恒为 false。
  • cmd/nixmsg/serve.go:177-226 没有设置 Deps.SecureCookies。
  • admin_test.go:379-400 只断言 Cookie 存在,不检查任何属性。
  • 文档依据:DEVELOPMENT §8(约 881 行)"HttpOnly,SameSite=Lax,HTTPS 时(含经受信任代理转来的 HTTPS)加 Secure";§12(约 1151 行);PRD §8 安全。
  • 为何不是故意设计:DEVIATIONS 没有相关条目。IsHTTPS 的注释写明要识别直连 TLS,只是在本项目的监听器下这条路径永远不成立。
  • 解决方案:
    1. 在 listener.Server.Start 给 clientSrv、adminSrv 设 ConnContext:连接是 *bufferedConn 且内层为 *tls.Conn 时,把 ConnectionState() 存进 context。
    2. 用一层很薄的包装 Handler,在 r.TLS == nil 时从 context 回填。这样所有依赖 r.TLS 的代码(httpx.IsHTTPS、ProxySet.IsHTTPS)一起恢复正常。
    3. 可选兜底:配了证书且 allow_plaintext=false 时,serve.go 传 SecureCookies: true。
  • 改动文件:internal/listener/server.go(N 线)、cmd/nixmsg/serve.go(总控,兜底可选)、测试。
  • 交互/冲突风险:需要和 N 线协调。allow_plaintext: true 时明文请求仍不带 Secure,这是正确的,否则浏览器会拒收。WS 路径如果读 r.TLS,行为会变成正确的。
  • 需补测试:
    • 在 listener 层用自签证书,TLS 请求回显 IsHTTPS 应为 true,明文应为 false。
    • 通过真实监听器走 TLS 登录,Set-Cookie 应同时含 Secure、HttpOnly、SameSite=Lax。
    • 注意 httptest.NewTLSServer 用的是原生 *tls.Conn,测不出这个问题。
  • 置信度:代码阅读确定

[P-08] 经 TLS 的请求 r.TLS 为空,直接用 HTTPS 访问时后台 Cookie 不带 Secure

  • 严重级:medium
  • 分类:安全
  • 现象与影响:
    • listener 在 TLS 握手后,对解密后的数据再 peek 一次首字节,然后把 *tls.Conn 包进 bufferedConn 交给 http.Server。这个包装只实现了 net.Conn,没有 ConnectionState() 方法。
    • Go 1.27 的 net/http 只在连接实现了 ConnectionState() 时才设置 Request.TLS,所以所有经 NixMsg 自己终止 TLS 的请求都是 r.TLS == nil。
    • 因此 httpx.IsHTTPS 返回 false,setSessionCookie 不加 Secure(serve 也没设 SecureCookies)。
    • 按 11.3 推荐的"1Panel 申请证书、NixMsg 直接终止 TLS"部署时,浏览器会把管理员 Cookie 发给同域的任何明文端口,比如 80 端口上的其他站点。
  • 证据:listener/server.go:298-302;conn.go:12-27;httpx/clientip.go:42-45;admin/login.go:12-22;Go 1.27 net/http/server.go:1956-1962、2022-2027。
  • 文档依据:DEVELOPMENT 第 8 节「HTTPS 时(含经受信任代理转来的 HTTPS)加 Secure」;PRD 第 8 节安全要求。
  • 为何不是故意设计:没有记录。
  • 解决方案:TLS 分支返回一个专用包装类型:tlsBufferedConn{*bufferedConn; tc *tls.Conn},实现 ConnectionState(),直接返回 tc.ConnectionState()。明文连接继续用原来的 bufferedConn;不能给它加这个方法,否则明文请求也会被当成 TLS。
  • 改动文件:internal/listener/conn.go、server.go
  • 与其他模块的交互/冲突风险:net/http 会进入它的 TLS 分支;因为包装类型没有 HandshakeContext 方法,不会重复握手;没有协商 ALPN,仍走 HTTP/1.1。
  • 需补测试:listener 测试里,TLS 请求的 handler 断言 r.TLS != nil,明文请求断言为 nil;经 TLS 登录后台,Set-Cookie 带 Secure。
  • 置信度:代码阅读确定

复审基线:main 4059a15(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。

**编号**:L-04 **严重级**:high **工作线**:监听与 HTTP(internal/listener、internal/httpx、serve 的 HTTP 装配) **来源**:审查 A-01、P-08 **依赖**:无 **被依赖**:无 ### 结论与统一方案 两份审查结论一致:TLS 握手后内层连接又被 `peekFirstByte` 包成 `bufferedConn` 交给 `http.Server`,net/http 只在连接实现了 `ConnectionState()` 时才填 `r.TLS`,所以直连 TLS 时 `httpx.IsHTTPS` 恒为 false,`setSessionCookie` 不加 Secure(serve 也没设 `SecureCookies`)。按 DEVELOPMENT 11.3 推荐的"NixMsg 直接终止 TLS"部署时,浏览器会把管理员 Cookie 带到同主机的明文请求上。 统一方案采用审查 P-08 的做法(改动最小,覆盖所有依赖 `r.TLS` 的代码): 1. TLS 分支返回专用包装 `tlsBufferedConn{*bufferedConn; tc *tls.Conn}`,实现 `ConnectionState()` 返回 `tc.ConnectionState()`。明文连接继续用原来的 `bufferedConn`,不能给它加这个方法,否则明文请求也会被当成 TLS。 2. 兜底:配置了证书且 `allow_plaintext=false` 时,serve 给 `admin.Deps.SecureCookies` 传 true。 3. 可选加固:直连 TLS 的响应加 `Strict-Transport-Security`(只在 TLS 请求上)。 审查 A-01 提出的 ConnContext + 回填方案作为备选,不同时实现。严重级取 high:泄漏的是 12 小时有效的管理员会话。 ### 改动文件 `internal/listener/conn.go`、`server.go`;`cmd/nixmsg/serve.go`(`admin.Deps` 的 SecureCookies 一行)。 ### 与其他问题的交互 / 冲突说明 - 与 L-01 同改 `classify`,监听线内顺序合入。 - serve.go 的 `admin.Deps` 也会被 H-02(审计 logger)修改,合并时保留两处。 ### 验收与测试 - listener 测试中 TLS 请求的 handler 断言 `r.TLS != nil`,明文请求断言为 nil。 - 通过真实监听器走 TLS 登录,Set-Cookie 同时含 Secure、HttpOnly、SameSite=Lax。注意 `httptest.NewTLSServer` 用的是原生 `*tls.Conn`,测不出这个问题。 --- ### 问题明细(各区审查原文,证据含文件与行号) > 以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。**解决方案以本 issue 上方的"结论与统一方案"为准**;原文里的方案与之不一致时,按上方执行。 #### [A-01] 直连 TLS 时后台 Cookie 不带 Secure - 严重级:high - 分类:安全 - 现象与影响:NixMsg 自己终止 TLS 时,`nixmsg_admin` 的 Set-Cookie 没有 `Secure`。这正是 DEVELOPMENT 11.3 要求的生产形态(明确说不要用 OpenResty 终止 TLS)。 - 之后浏览器对同一主机名的任何明文请求都会带上会话 Cookie。Cookie 不区分端口,比如被中间人重定向到 `http://主机/`。 - 即使 `allow_plaintext: false` 时服务端读到首字节就断开,请求头也已经发出去了。 - 拿到 Cookie 就拿到 12 小时的管理员权限。 - 证据: ```12:22:e:\code\NixMsg\internal\admin\login.go func (h *Handler) setSessionCookie(w http.ResponseWriter, r *http.Request, value string, maxAge int) { secure := h.forceSec || httpx.IsHTTPS(r, h.trusted) http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: value, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: secure, MaxAge: maxAge, }) ``` ```298:311:e:\code\NixMsg\internal\listener\server.go tlsConn := tls.Server(c, s.certs.TLSConfig()) if err := tlsConn.Handshake(); err != nil { return KindClosed, nil, err } return s.classify(tlsConn, isAdmin, true) } // ... if b >= 'A' && b <= 'Z' { return KindHTTP, c, nil } ``` - 第二次 `classify` 里的 `peekFirstByte` 把 `*tls.Conn` 包成 `*bufferedConn`(`internal/listener/conn.go:22-42`)。net/http 只在连接本身是 `*tls.Conn` 时才填 `r.TLS`,所以 `httpx.IsHTTPS`(`internal/httpx/clientip.go:41-45`,只看 `r.TLS != nil`)在直连 TLS 下恒为 false。 - `cmd/nixmsg/serve.go:177-226` 没有设置 `Deps.SecureCookies`。 - `admin_test.go:379-400` 只断言 Cookie 存在,不检查任何属性。 - 文档依据:DEVELOPMENT §8(约 881 行)"HttpOnly,SameSite=Lax,HTTPS 时(含经受信任代理转来的 HTTPS)加 Secure";§12(约 1151 行);PRD §8 安全。 - 为何不是故意设计:DEVIATIONS 没有相关条目。`IsHTTPS` 的注释写明要识别直连 TLS,只是在本项目的监听器下这条路径永远不成立。 - 解决方案: 1. 在 `listener.Server.Start` 给 `clientSrv`、`adminSrv` 设 `ConnContext`:连接是 `*bufferedConn` 且内层为 `*tls.Conn` 时,把 `ConnectionState()` 存进 context。 2. 用一层很薄的包装 Handler,在 `r.TLS == nil` 时从 context 回填。这样所有依赖 `r.TLS` 的代码(`httpx.IsHTTPS`、`ProxySet.IsHTTPS`)一起恢复正常。 3. 可选兜底:配了证书且 `allow_plaintext=false` 时,`serve.go` 传 `SecureCookies: true`。 - 改动文件:`internal/listener/server.go`(N 线)、`cmd/nixmsg/serve.go`(总控,兜底可选)、测试。 - 交互/冲突风险:需要和 N 线协调。`allow_plaintext: true` 时明文请求仍不带 Secure,这是正确的,否则浏览器会拒收。WS 路径如果读 `r.TLS`,行为会变成正确的。 - 需补测试: - 在 listener 层用自签证书,TLS 请求回显 `IsHTTPS` 应为 true,明文应为 false。 - 通过真实监听器走 TLS 登录,Set-Cookie 应同时含 `Secure`、`HttpOnly`、`SameSite=Lax`。 - 注意 `httptest.NewTLSServer` 用的是原生 `*tls.Conn`,测不出这个问题。 - 置信度:代码阅读确定 #### [P-08] 经 TLS 的请求 r.TLS 为空,直接用 HTTPS 访问时后台 Cookie 不带 Secure - **严重级**:medium - **分类**:安全 - **现象与影响**: - listener 在 TLS 握手后,对解密后的数据再 peek 一次首字节,然后把 `*tls.Conn` 包进 `bufferedConn` 交给 http.Server。这个包装只实现了 net.Conn,没有 `ConnectionState()` 方法。 - Go 1.27 的 net/http 只在连接实现了 `ConnectionState()` 时才设置 `Request.TLS`,所以所有经 NixMsg 自己终止 TLS 的请求都是 `r.TLS == nil`。 - 因此 `httpx.IsHTTPS` 返回 false,`setSessionCookie` 不加 Secure(serve 也没设 `SecureCookies`)。 - 按 11.3 推荐的"1Panel 申请证书、NixMsg 直接终止 TLS"部署时,浏览器会把管理员 Cookie 发给同域的任何明文端口,比如 80 端口上的其他站点。 - **证据**:`listener/server.go:298-302`;`conn.go:12-27`;`httpx/clientip.go:42-45`;`admin/login.go:12-22`;Go 1.27 `net/http/server.go:1956-1962`、`2022-2027`。 - **文档依据**:DEVELOPMENT 第 8 节「HTTPS 时(含经受信任代理转来的 HTTPS)加 Secure」;PRD 第 8 节安全要求。 - **为何不是故意设计**:没有记录。 - **解决方案**:TLS 分支返回一个专用包装类型:`tlsBufferedConn{*bufferedConn; tc *tls.Conn}`,实现 `ConnectionState()`,直接返回 `tc.ConnectionState()`。明文连接继续用原来的 `bufferedConn`;不能给它加这个方法,否则明文请求也会被当成 TLS。 - **改动文件**:`internal/listener/conn.go`、`server.go` - **与其他模块的交互/冲突风险**:net/http 会进入它的 TLS 分支;因为包装类型没有 `HandshakeContext` 方法,不会重复握手;没有协商 ALPN,仍走 HTTP/1.1。 - **需补测试**:listener 测试里,TLS 请求的 handler 断言 `r.TLS != nil`,明文请求断言为 nil;经 TLS 登录后台,Set-Cookie 带 Secure。 - **置信度**:代码阅读确定 --- <sub>复审基线:main `4059a15`(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。</sub>
nixevol added the P1-highlane/listenerreview-2026-09-30 labels 2026-09-30 13:56:55 +08:00
Author
Owner

已合入 origin/main 0c9b459。落地提交 7c926a0 fix: 修复 TLS ConnectionState、SPA 回退、健康检查与监听小问题 (#23)。

已合入 origin/main `0c9b459`。落地提交 `7c926a0` fix: 修复 TLS ConnectionState、SPA 回退、健康检查与监听小问题 (#23)。
Sign in to join this conversation.