Security: block private script source targets - #656
Conversation
|
PR Title: Security: block private script source targets Commit: 本变更在 pkg/compose/script_source.go 的 defaultScriptSourceResolver 中引入 SSRF 防护:新增 validateNetworkTarget 校验层(readHTTP 初始请求与 CheckRedirect 重定向均会调用),拒绝解析结果含私有/回环/链路本地/CGNAT 等被禁止地址的目标;克隆 http.DefaultTransport 并设置自定义 safeScriptDialContext,在拨号时重新解析并按公网 IP 直连,作为抵御 DNS 重绑定/TOCTOU 的最终兜底;同时禁用环境代理。测试新增了拒绝字面私网 HTTP 目标与私网重定向的用例,并把既有 HTTP 相关测试改为使用关闭网络校验的 newTestScriptSourceResolver。 整体评估:安全设计合理,双层防护(URL 校验 + 拨号过滤 + 直连已校验 IP)能有效覆盖常见 SSRF 与 DNS 重绑定绕过,常规私网段(10/8、172.16/12、192.168/16、169.254.169.254、127/8、::1、CGNAT 等)均被拦截,且 fail-closed。未发现可被直接利用的高危漏洞。需关注的主要是三类低危问题:(1) transport.Proxy = nil 静默改变了原有遵循 HTTP(S)_PROXY 的行为,可能影响依赖代理访问公网脚本的用户,建议确认意图并记录;(2) 安全关键的拨号层与 isPublicScriptAddress 边界缺少直接测试,DNS 重绑定兜底逻辑从未被验证;(3) isPublicScriptAddress 依赖 Go 内置分类器,未排除 fec0::/10、198.18.0.0/15、240.0.0.0/4、0.0.0.0/8 等 IANA 特殊用途/保留段,存在理论绕过空间。 |
|
已按最新评论修订:
本地验证:脚本源相关测试与 |
|
PR Title: Security: block private script source targets Commit: 本次改动针对 pkg/compose/script_source.go 的默认脚本源解析器:
地址段扩展与拨号层测试是对历史发现(未覆盖特殊用途地址、缺少拨号层测试)的正确修复,未发现问题。核心问题集中在“恢复代理 + 保留公网校验拨号器”的组合:Go http.Transport 在代理路径下用 DialContext 拨号的是代理地址而非目标,因此 (a) 新增注释的“代理无法绕过私有地址检查”断言在代理路径下不成立,拨号层针对 DNS 重绑定/TOCTOU 的 SSRF 兜底对代理目标失效;(b) 典型私网/内网转发代理地址会被 isPublicScriptAddress 拒绝,导致需要代理的环境脚本拉取必然失败,与改动意图相悖。共提交 2 条发现。 |
|
已继续按新增 Review 意见修订代理处理:
本地脚本源相关测试与 |
|
PR Title: Security: block private script source targets Commit: 本次改动针对 pkg/compose/script_source.go 的脚本拉取 SSRF 防护:在 http.Client 与底层 http.Transport 之间新增 scriptSourceTransport 包装器,当 http.ProxyFromEnvironment 检测到 HTTP(S)_PROXY 时,将代理端点 host 放入请求 ctx(scriptProxyAddressKey),并让 safeScriptDialContext 在拨号地址与 ctx 中代理地址字符串相等时直接放行拨号,从而允许被配置的内网/私网正向代理(此前会被公网地址过滤拒绝)。同步把注释从“拨号层校验每个目标、代理无法绕过私网校验”改为“代理是可信网络边界”,并新增一个仅覆盖放行原语的单元测试。 总体评估:改动意图明确(修复私网/内网转发代理场景下脚本拉取必然失败的问题),对直连(无代理)路径的拨号层校验保持不变;但存在几点需要关注:(1) 代理路径下拨号层对真实目标的二次校验彻底失效、且代理端点本身不再做任何地址校验,从 base 的 fail-closed 变为 fail-open,SSRF 防护在配置代理时退化为仅依赖请求前本地 DNS 校验(历史 finding #2 的核心风险未消除,反而把私网代理纳入放行);(2) 代理 URL 未显式带端口时,proxyURL.Host 与 Go 实际拨号地址(补默认端口后)字符串不匹配,修复对这类配置不生效;(3) 代理放行路径缺少端到端测试。 |
|
已定位并修复 CI 失败:失败并非生产代码编译问题,而是 CLI 测试仍使用 修复内容:
验证: |
|
更正:上一条说明中的测试修订已准确迁移到本 PR 分支,最新提交为 CI 根因与修复不变:CLI 测试使用 Loopback HTTP 脚本地址,而生产 SSRF 策略会正确拒绝该地址。测试现改为使用本地文件验证正常 CLI 流程,并单独验证私有 Loopback URL 在访问 daemon 前被拒绝。 |
|
PR Title: Security: block private script source targets Commit: 本次改动仅涉及两个 CLI 测试文件,目的是让测试适配脚本 URL 的私网/回环地址防护行为:1) cli_scheduler_test.go 将原本基于 httptest 回环服务的 URL 用例改写为 file provider 用例(TestConfigCommandExpandsSchedulerScriptFiles、TestUpResolvesSchedulerScriptFileBeforeApply),并删除 io/net/http/httptest 三个导入;2) cli_project_workflow_test.go 把 TestUpScriptURLFetchFailureDoesNotApply 改为 TestUpRejectsPrivateScriptURLBeforeApply,改用 http://127.0.0.1:1 断言 stderr 含 "prohibited address" 且 daemon 未被调用。 整体测试意图合理(回环地址的 httptest 源已无法用于拉取,改用本地文件验证展开/内联),断言与新防护行为一致。但改动存在两处会导致测试包编译失败的问题:cli_scheduler_test.go 删除了 net/http 导入,而文件内 TestIntegrationCLIUpAppliesInlineSchedulerScriptAndPSJSON 仍引用 http.StatusOK;cli_project_workflow_test.go 移除 fmt.Sprintf 后,fmt 导入在文件内不再被使用。两者都会让 go test ./cmd/agent-compose 直接编译报错,需修复后才可合入。 |
|
CI 失败已修复。 失败原因:测试适配时重命名了三个被仓库 coverage contract 明确引用的测试函数,导致 CI 编译报错:
修复内容:
修复提交: |
|
PR Title: Security: block private script source targets Commit: 本次改动仅涉及两个测试文件,全部为测试命名调整与导入清理,无任何生产代码变更:
评估:改动为纯测试层维护。经全文检索确认 cli_project_workflow_test.go 已无任何 fmt 引用,删除导入与现存 usage 一致,测试包可正常编译——历史确认的“fmt 导入未清理导致编译失败”问题在当前 head 已随导入删除一并解决,不再视为新问题。各测试重命名未削弱或改变任何断言与覆盖行为,未引入功能、安全、数据完整性或稳定性风险,未发现需要上报的可执行问题。 |
|
已修复 PR 遗留问题:
本地验证:
PR 已触发新一轮 CI。 |
|
PR Title: Security: block private script source targets Commit: 本次改动以安全修复为目标:在 pkg/compose/script_source.go 中彻底移除 HTTP(S) 脚本源对正向代理(HTTP_PROXY/HTTPS_PROXY)的使用——将 clone 自 http.DefaultTransport 的 transport 显式置 Proxy=nil,删除自定义 scriptSourceTransport RoundTripper、scriptProxyAddressKey 及拨号层对代理端点的放行逻辑。这样拨号层的公网地址校验重新成为"实际连接目标"的唯一闸口,修复了此前已确认的代理路径 SSRF 绕过问题(代理侧分裂 DNS/私网代理放行/端口不匹配等)。测试与 README(中英文)同步更新。 评估:安全姿态明显改善,重定向逐跳校验与 safeScriptDialContext 的二次解析/仅拨公网 IP 逻辑在直连模式下仍然生效,未发现新增安全漏洞。主要顾虑是功能回归:在必须经代理出网的企业/CI 环境,HTTP(S) 脚本源将直接失败且无明确诊断、无恢复开关;同时新增测试仅断言 Transport.Proxy 字段,较薄弱,无法拦截经自定义 RoundTripper 重新引入代理的回归路径。两处均为低严重度建议。 |
| // Do not use environment-configured proxies here. A forward proxy resolves | ||
| // and fetches the target outside this process, so the daemon cannot enforce | ||
| // the same public-address SSRF policy on the actual connection. | ||
| transport.Proxy = nil |
There was a problem hiding this comment.
移除环境代理后,依赖正向代理出网的环境将无法拉取 HTTP(S) 脚本源且无明确诊断或恢复开关
改动将 transport.Proxy 置为 nil 并删除 scriptSourceTransport 封装。对可直连公网的环境行为不变;但在必须经正向代理出网的环境(进程设置了 HTTP_PROXY/HTTPS_PROXY,且防火墙阻断直连)中,HTTP(S) 脚本源拉取会从原来的“经代理成功”变成“直连超时/连接被拒”。基线代码的注释与专测(TestSafeScriptDialContextAllowsConfiguredProxyEndpoint)表明这一场景此前是被有意支持的。现在既没有配置开关/白名单可恢复,用户侧也只看到通用的 dial 错误(如 i/o timeout),无法得知是脚本源有意禁用了代理,排障成本高。
Problem code:
Changed code at pkg/compose/script_source.go:52
Recommendation:
建议在初始化或 Resolve/readHTTP 出错时检测进程环境中的 HTTP_PROXY/HTTPS_PROXY,若存在则附加“脚本源出于 SSRF 防护有意不使用环境代理”的明确提示;如确有受信代理需求,评估增加默认关闭的显式受信代理配置项,并在文档中标注对应 SSRF 风险。
|
已处理最新评论建议:
本地相关脚本源测试与 |
|
PR Title: Security: block private script source targets Commit: 本次改动:1) 在 defaultScriptSourceResolver.readHTTP 的连接层错误分支追加 scriptProxyDisabledDiagnostic(),当进程配置了 HTTP_PROXY/HTTPS_PROXY/ALL_PROXY 时,在错误信息里提示该代理因 SSRF 防护被有意禁用,帮助用户排障;2) 重写 TestDefaultScriptSourceResolverDisablesEnvironmentProxy,由“直接断言 transport.Proxy==nil”改为“端到端设置 HTTP_PROXY 并验证零代理请求”;3) 新增 TestDefaultScriptSourceResolverDiagnosesDisabledEnvironmentProxy 验证诊断文案。 总体评估:生产实现改动小且无副作用,诊断函数只读环境变量;transport.Proxy=nil 仍保留,核心 SSRF 控制未被削弱。但有两处值得关注:(a) 重写后的“禁用代理”测试目标为 httptest 的 127.0.0.1,Go 的代理机制对 loopback/localhost 请求本就不走代理,因此即便未来回归把 transport.Proxy 改成 ProxyFromEnvironment,proxyRequests 仍恒为 0、断言无法检出,原测试中唯一有效的直接断言被删除,等于撤掉了对 SSRF 关键防护的回归保护;(b) scriptProxyDisabledDiagnostic 只检查大写代理变量名,忽略了 Linux/macOS 与 Go 代理解析同样支持的小写 http_proxy/https_proxy 形式,诊断在常见场景下不生效。 |
|
已完成最后两项低风险完善:
提交: |
|
PR Title: Security: block private script source targets Commit: 本变更聚焦 pkg/compose 的 HTTP(S) 脚本源解析器的环境代理诊断逻辑。源码层面仅改动 scriptProxyDisabledDiagnostic:将原本只检查大写 HTTP_PROXY/HTTPS_PROXY/ALL_PROXY 的范围,扩展为同时检查小写 http_proxy/https_proxy/all_proxy,从而在用户仅配置小写代理变量(Linux/macOS 常见)时也能正确给出"代理被有意禁用(SSRF 防护)"的诊断,修复了此前的大小写盲区。 测试方面做两处增强:1) TestDefaultScriptSourceResolverDisablesEnvironmentProxy 不再以回环地址 127.0.0.1 作为目标(该场景即使走了环境代理也不易暴露),改为请求 public.test 主机名并通过自定义 DialContext 重写到本地测试服务器,同时补设小写代理变量与 NO_PROXY/no_proxy,以更严格地验证 resolver 确实直连、未经过环境代理;2) TestDefaultScriptSourceResolverDiagnosesDisabledEnvironmentProxy 改为子测试循环,覆盖 HTTP_PROXY 与 http_proxy 两种变量名,断言诊断文本包含对应变量名。 总体评估:源码修复方向正确、与诊断场景自洽(诊断仅在传输层错误时追加,不会误伤 HTTP 状态码错误);测试增强后更有意义。发现的主要小问题是测试中为固定 IPv4 监听地址额外 net.Listen 了一个新监听器并直接覆盖 httptest.NewUnstartedServer 已创建的原监听器,原监听器未显式关闭,存在套接字资源泄漏与冗余代码。 |
| t.Fatalf("listen target: %v", err) | ||
| } | ||
| target.Listener = listener | ||
| target.Start() |
There was a problem hiding this comment.
测试重复创建监听器并覆盖 httptest 已创建的监听器,原监听器未关闭造成套接字泄漏
httptest.NewUnstartedServer 在构造时已通过 newLocalListener() 创建并绑定一个 127.0.0.1 随机端口监听器并存入 target.Listener。此处随后又 net.Listen("tcp", "127.0.0.1:0") 创建了第二个监听器,并用 target.Listener = listener 直接覆盖第一个,且从未关闭被覆盖的原始监听器。后续 target.Close() 只会关闭替换后的 listener,原始监听器保持 open,直到 GC 触发 netFD finalizer 才被回收。这使每次运行该测试都遗留一个未关闭的监听套接字(在 -count 重复运行或长驻测试进程下可能累积临时端口),属于冗余且可避免的资源泄漏。实际上 targetAddress/targetPort 完全可以直接取自 target.Listener.Addr(),无需额外创建监听器。
Problem code:
Changed code at pkg/compose/script_source_test.go:89-94
Recommendation:
删除多余的 net.Listen,直接复用 httptest.NewUnstartedServer 已创建的监听器,例如用 target.Listener.Addr() 推导目标地址与端口;若确需强制 IPv4 127.0.0.1,则在替换前先关闭原监听器(target.Listener.Close()),避免泄漏。
Suggested diff:
--- a/pkg/compose/script_source_test.go
+++ b/pkg/compose/script_source_test.go
@@ -86,14 +86,10 @@ func TestDefaultScriptSourceResolverDisablesEnvironmentProxy(t *testing.T) {
target := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("scheduler.interval('direct', 1000, main);"))
}))
- listener, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Fatalf("listen target: %v", err)
- }
- target.Listener = listener
target.Start()
defer target.Close()
- targetAddress := listener.Addr().String()
- targetPort := strings.TrimPrefix(target.URL, "http://127.0.0.1:")
+ targetAddress := target.Listener.Addr().String()
+ _, targetPort, err := net.SplitHostPort(targetAddress)
+ if err != nil {
+ t.Fatalf("parse target address: %v", err)
+ }
问题
HTTP(S) scheduler script URL 过去只校验 scheme 和 host,HTTP client 还允许默认代理与自动重定向到任意地址。攻击者可配置 Loopback、RFC1918、Link-local 或云元数据地址作为脚本源。
影响
daemon 会从宿主机网络访问受保护的内网服务,可能造成 SSRF、内网探测、云元数据读取和敏感信息进入脚本执行链路。涉及 CWE-918。
修复内容
验证
go test ./pkg/compose -run 'TestDefaultScriptSourceResolver|TestNormalizeResolvesUppercaseHTTPScriptURLScheme' -count=1git diff --check