进阶 docs.cubesandbox.com 2026-10-08 23:44:25 · 0 阅读

第29章 安全代理

安全代理 ​Cube Sandbox 在每台宿主机上部署一个透明出网代理 —— CubeEgress —— 拦截沙箱发起的所有出方向 HTTP/HTTPS 请求,按运维侧定义的 L7 规则 做匹配,对每个请求选择放行、拒绝、或在转发前重写。数据面位于沙箱 的 TAP 设备和外网之间,沙箱无法绕过它(除非破坏宿主机隔离)。代理提供三类核心能力,全部由"创建沙箱时携带的同一份规则列表"驱动:域名过滤 —— 按 SNI / Host / 方法 / scheme / 路径放行或拒绝凭证注入 —— 自动追加固定 header(典型场景是 Authorization: Bearer …),密钥不进沙箱访问审计 —— 每一次决策(放行 / 拒绝 / 注入 / TLS 握手结果) 都落到主机本地的 JSONL 审计日志上游 proxy_read_timeout / proxy_send_timeout 为 2h(与 CubeProxy 同量级)。proxy_connect_timeout 仍是 10s。拦截链路 ​CubeEgress 是一个 host-network 容器,在面向沙箱的 IP 上 bind 两个 TPROXY listener —— 8080 上的 HTTP listener 和 8443 上的 HTTPS listener。哪些流量进哪个 listener,由每条规则声明的 port/scheme 映射决定(见自定义 L7 端口),而不再绑定固定的 目的端口:sandbox ──→ cube-dev (主机网卡) │ ├─ eBPF (mvmtap) 按 allow_out_v3 里的 (host, port)→scheme │ 映射,在出方向 SYN 上打 skb->mark (HTTP 或 HTTPS) │ ├─ iptables mangle/PREROUTING -m mark -j TPROXY │ HTTP mark → 192.168.0.1:8080 (HTTP) │ HTTPS mark → 192.168.0.1:8443 (HTTPS) │ ▼ CubeEgress (OpenResty + lua) │ ├─ ssl_certificate_by_lua → 按客户端 SNI 现场签发 │ 一张 leaf 证书,签名链根 │ 是 CubeEgress 的 root CA │ (在模板构建时已被烘进 │ 沙箱 rootfs 的系统 CA) │ ├─ access_by_lua → 匹配 L7 规则,放行 / 拒绝 / 注入 │ └─ proxy_pass → 原始目的 IP (依赖 IP_TRANSPARENT 保留)由于 leaf 证书的链路能被沙箱系统 CA 信任,工作负载的 TLS 客户端 看不到这次 MITM,代理也就能合法读写请求/响应。域名过滤 ​每条规则有一个 match(描述命中条件)和一个 action(描述命中后 做什么)。先到先得:第一条命中的规则决定结果;任何规则都不 命中的请求被默认拒绝。pythonfrom cubesandbox import Sandbox, Rule, Match, Action rules = [ # 用 host 精确匹配阻止 apex Rule( name="deny_example_apex", match=Match(scheme="https", host="example.com"), action=Action(allow=False), ), # 用 *. 形态的 SNI 通配放行所有子域 Rule( name="allow_example_subdomains", match=Match(scheme="https", sni="*.example.com"), action=Action(allow=True), ), ] with Sandbox.create(network={"rules": rules}) as sb: sb.commands.run("curl -s https://www.example.com") # → 上游 sb.commands.run("curl -s https://example.com") # → CubeEgress 返回 403匹配字段(全部可选,多个字段 AND 关系):字段类型说明scheme"http" / "https"大小写不敏感。与 port 的搭配语义见自定义 L7 端口。portint要拦截的目的 TCP 端口,1–65535。必须与 scheme 同时设置;见自定义 L7 端口。snistringTLS ClientHello 的 SNI;以 *. 开头时表示"任意子域"——*.example.com 同时命中 www.example.com 和 foo.bar.example.com,但不命中 apexhoststring匹配 HTTP Host: 头(自动去除端口部分);语义与 sni 相同 —— 支持精确匹配,或以 *. 开头的子域通配(大小写不敏感)method方法列表列表内 OR 关系(["GET", "POST"])pathstring匹配 ngx.var.uri;默认精确匹配,或以单个 * 结尾的前缀匹配(如 /v1/* 同时命中 /v1/chat 和 /v1/embeddings)请求要同时满足所有出现的字段;未出现的字段视作通配。自定义 L7 端口 ​默认情况下,一条规则拦截经典的 {80/http, 443/https} 集合。可选 的 port + scheme 组合可以收窄或扩展哪个 TCP 端口会被送进代理:portscheme拦截范围省略省略{80/http, 443/https} —— 默认集合(向后兼容)省略"http" / "https"仅该 scheme 的默认端口(http → 80,https → 443)设置设置精确的 (host, port, scheme) 组合 —— 例如 tcp/8443 上的 API设置省略非法 —— port 必须搭配 schemepythonfrom cubesandbox import Sandbox, Rule, Match, Action rules = [ # 拦截一个非标准 HTTPS 端口上的内部 API Rule( name="internal_api", match=Match(host="api.internal.example", port=8443, scheme="https"), action=Action(allow=True), ), # 拦截自定义端口上的明文 HTTP Rule( name="custom_http", match=Match(host="metrics.internal.example", port=18080, scheme="http"), action=Action(allow=True), ), # 只给 scheme 时仍走经典端口,但只匹配其中一侧 Rule( name="https_only", match=Match(host="public.example", scheme="https"), action=Action(allow=True), ), ] with Sandbox.create(network={"rules": rules}) as sb: sb.commands.run("curl -s https://api.internal.example:8443/health") sb.commands.run("curl -s http://metrics.internal.example:18080/") sb.commands.run("curl -s https://public.example/") # → 被代理约束(SDK 客户端先校验,服务端再校验一次):port 必须在 [1, 65535];scheme 必须是 http 或 https(大小写不敏感)。L7 规则的 host 必须是域名或单个 IP —— 不支持子网 CIDR(子网 无法出现在 HTTP Host: 头或 TLS SNI 里)。共享同一个 (host, port) 的所有规则必须对 scheme 达成一致; 冲突的 policy 会被整体拒绝。每个 host 最多 8 个不同的 (port, scheme) 组合。任何规则都没有覆盖的端口,仍回落到 L3/L4 的 allow_out / deny_out 策略,不会进入 CubeEgress。省略 port 对 allow 和 deny 规则的含义不同。allow 规则省略 port 时会收窄到默认集合 {80/http, 443/https} —— 这是 fail-closed 的选择,即自定义端口的流量只有被规则显式点名才会进 代理。deny 规则省略 port 时则在该 host 内与端口无关 —— 无论哪个端口进来的被拦截流量都会命中。这样可防止"较宽的 host deny 被较窄的自定义端口 allow 绕过":例如 deny host="*.example.com" 与 allow host="api.example.com", port=8443, scheme="https" 同时存在时,deny 仍会命中 api.example.com:8443,最终结果由规则顺序(先命中先生效)决定。 若想放行这个例外,把更具体的 allow 规则放在前面。同一个域名可以同时出现在 L3/L4 的 allow_out 列表和某条 L7 rule 里,此时两者并存:该域名学到的 IP 会同时得到一条纯 /32 任意端口放行条目(让非 rule 端口保留普通的 L3/L4 SNAT 访 问)和每个 rule 端口一条 L7 (ip, port) 条目(让这些端口被 送进 CubeEgress 代理)。更精确的 (ip, port) 匹配对 rule 端口生 效,/32 兜底其余端口。换句话说,给一个已在 allow_out 里的域 名再加 L7 rule,不会移除它原有的纯 L3 放行 —— 只是在 rule 的 端口上叠加了 L7 拦截。单层 vs 多层子域*.example.com 不区分子域层数,所有结尾命中的子域都算。 若想只放行单层子域(如 www、api),需要为不希望放过的嵌套 子域单独追加 host="…" 的 deny 规则。deny 命中时 CubeEgress 直接返 HTTP 403,完全不接触上游 —— 沙箱马上看到拒绝,没 DNS 泄漏、没 TCP 握手。凭证注入 ​inject 规则在匹配成功后向出方向请求追加固定 header。最经典的 场景是注入 API token:pythonfrom cubesandbox import Sandbox, Rule, Match, Action, Inject rules = [ Rule( name="deepseek_api", match=Match(scheme="https", host="api.deepseek.com", method=["POST"], path="/v1/chat", sni="api.deepseek.com"), action=Action( allow=True, audit="metadata", inject=[Inject( header="Authorization", format="Bearer ${SECRET}", secret="sk_xxxxxxxx", # 运维侧密钥,沙箱永不可见 )], ), ), ]行为:工作负载发请求时不带 Authorization header;CubeEgress 接到请求、走完 match、再按 inject 列表追加 Authorization: Bearer sk_xxxxxxxx,再 forward 给上游。format 默认 "${SECRET}"(即 raw secret 作为 header 整值); 非 bearer 方案使用 "Bearer ${SECRET}" 或任何含 ${SECRET} 占位符的模板。inject 仅在 action.allow=true 时生效;deny 规则带 inject=[…] 是配置错误,会被忽略。inject 路径的核心价值在于"密钥留在运维侧":它存在于规则列表里、 沙箱创建时被推到 CubeEgress、永远不会暴露给沙箱的环境变量、文件 系统或进程空间。安全提醒 ​HTTP 凭证注入是有风险的,应该只在受控的内网环境使用。除非必要, 都应该使用 HTTPS。明文 HTTP 无法验证对端身份。规则按 Host 匹配成功后,凭证会随 请求发往当时解析出的目的 IP。典型场景:沙箱创建时 allow_internet_access=true,并为 example.com 配置了凭证注入。攻击者如果能控制解析该域名的 DNS 服务器,就可以把 example.com 解析到自己的 IP,从而把注入的凭证 发往被控制的服务器。这不是 CubeSandbox 本身的问题,而是明文 HTTP 无法认证对端。优先使用 HTTPS为 inject 规则显式指定 scheme="https"。只有上游服务位于你完全 掌控的内网时,才使用 scheme="http"。省略 scheme 会同时匹配 HTTP 和 HTTPS,HTTP 一侧同样会注入凭证。访问审计 ​每个请求按规则上的 action.audit 字段在三种审计级别里走一种:级别落盘内容none不记录metadata(默认)时间戳、沙箱 IP、目的 IP/端口、scheme、host、method、path、status、收发字节、延迟、TLS 版本+cipher、上游 addrfull预留 —— 当前等同 metadata,未来用于完整请求/响应 body 抓取日志在主机 /data/log/cube-egress/access.jsonl,每行一条 JSON:json{ "ts": "2026-05-29T11:24:01+08:00", "sandbox_ip": "192.168.1.154", "dst_ip": "104.16.132.229", "dst_port": 443, "scheme": "https", "host": "api.deepseek.com", "method": "POST", "path": "/v1/chat", "status": 200, "bytes_in": 412, "bytes_out": 1856, "latency_ms": 384, "tls_version": "TLSv1.3", "cipher": "TLS_AES_128_GCM_SHA256", "upstream_status": "200", "upstream_addr": "104.16.132.229:443" }同一日志文件还落两类事件,schema 略有不同:security_event —— default-deny 拒绝、规则畸形短路、 host/SNI 不一致、inject 触发等。会带规则名 + reason 字段 描述哪个守卫触发了。tls_handshake —— TLS 握手本身失败时(leaf 签发失败、 客户端中途断开等)发出,此时还没有 HTTP 报文。用于把"TLS 没握上" 和"请求被解密后被拒"区分开。密钥被脱敏inject 的 secret 值在写入任何日志路径前都会被剥除。审计日志 仅记录规则名和 inject 已运行的事实;密钥本身从不离开 lua VM。不经过代理的路径 ​少数情形不走 CubeEgress,需要清楚边界:cube-dev 内部流量 —— 沙箱到沙箱、沙箱到集群内服务(Cube API 等)不进 TPROXY 链路,不受规则约束。没有被 L7 规则覆盖的 TCP/UDP —— TPROXY 链路只重定向被 L7 规则标记的流量:默认的 {80/http, 443/https} 集合,加上规则 声明的任意自定义 (port, scheme)。直连任何规则都没覆盖的端口 的 TCP,仍受 CubeNet 数据面的 L3/L4 allow_out / deny_out 策略约束,但 CubeEgress 看不到。没烘 CA 的模板 —— 如果模板用 --with-cube-ca=false 创建, 沙箱里的 TLS 客户端不信任 CubeEgress 签的 leaf 证书, HTTPS 在规则评估之前就会因 self-signed cert 报错。代理本身的部署(systemd 单元、CA 引导、iptables oneshot 服务)见 deploy/one-click 源码 —— 那一套会自动把 CubeEgress 接到 control 和 compute target 里。扩展代理 ​上面说的 match / inject 语法故意写得很窄 —— 它覆盖 80% 的常见 场景(放行某个 host、拒绝另一个、附加 token),仅靠运维侧配置就够。 当你需要规则表达不出的行为 —— 内容检查、跨请求状态、调外部分类 服务 —— CubeEgress 本质就是一个 OpenResty 服务,你可以自行修改 或新增 lua 脚本来扩展它。lua 文件布局 ​数据面按 nginx phase 拆成 CubeEgress/lua/ 下的多个文件:文件Phase作用cert_signer.luassl_certificate_by_lua按 SNI 现场签发 leaf 证书bootstrap.luainit_worker_by_lua(仅 worker 0)启动时从 Cubelet 内置 network runtime 拉初始策略access_phase.luaaccess_by_lua每个请求做 match → action → inject 决策policy.lua(模块)内存中的策略表,由 admin.lua 和 bootstrap.lua 写入admin.lua:9091 上的 content_by_lua(默认;CUBE_EGRESS_ADMIN_PORT)策略 CRUD admin APIaudit.lualog_by_lua落 JSONL 审计日志redactor.lua(helper)把敏感数据从用户可见路径里抹掉nginx.conf 在每个 phase block 里通过 require("…") 调它们; 比如 HTTP 和 HTTPS server 块的 access_by_lua_block 都调 require("access_phase").decide()。lua_package_path 配置为 /usr/local/openresty/nginx/lua/?.lua,所以放进这个目录的任何 新文件,nginx reload 之后就能 require("name") 用。添加一个新的 phase 钩子 ​最简单的扩展形态是 —— 写一个新模块,在 nginx.conf 的某个 phase block 里 require 它。下面的例子:一个 prompt 内容过滤器,拦截 发往 LLM 端点的请求体,扫 prompt 文本,命中黑名单就拒。1) 在 CubeEgress/lua/ 下加一个新模块:lua-- lua/prompt_filter.lua -- 第二阶段(在 access_by_lua 决定 allow 之后):检查发往 LLM 上游 -- 的 POST 请求体,命中禁用模式则拒,可选地重写有风险的内容。 local cjson = require("cjson.safe") local _M = {} -- 简陋启发式;真实部署应换成你安全团队给的方案。 -- 生产里通常用 lua-resty-http(openresty-tproxy 基础镜像已带) -- 调外部分类服务,或从 policy 加载一个正则集合。 local FORBIDDEN_PATTERNS = { "ignore previous instructions", "system prompt", "DAN mode", } local function is_target_endpoint(host, path) -- 只对已知 LLM chat 端点做过滤。可以加更多 host,或从一个 -- lua_shared_dict 读这份列表,让 admin API 可运行时改。 return host == "api.deepseek.com" and path == "/v1/chat/completions" end function _M.inspect() local host = ngx.var.cube_audit_host or ngx.var.http_host local path = ngx.var.uri if not is_target_endpoint(host, path) then return end -- 必须显式 read_body —— nginx 默认不把 POST body 给 lua。 -- nginx.conf 已经开了 proxy_request_buffering=on,所以这里 -- 读完之后,缓存的副本仍会发给上游。 ngx.req.read_body() local body = ngx.req.get_body_data() or "" local payload, err = cjson.decode(body) if not payload or err then return -- 非 JSON,放过 end -- 走 OpenAI 风格的 messages 数组 for _, msg in ipairs(payload.messages or {}) do local content = (msg or {}).content or "" for _, pat in ipairs(FORBIDDEN_PATTERNS) do if string.find(string.lower(content), pat, 1, true) then ngx.log(ngx.WARN, "prompt filter rejected: pattern=", pat, " sandbox=", ngx.var.remote_addr) -- 复用 audit 模块,让这次拒绝跟普通 deny 一样落到 -- access.jsonl local audit = require("audit") pcall(audit.write_security_event, "prompt_filter:" .. pat, ngx.ctx.cube_decision) return ngx.exit(ngx.HTTP_FORBIDDEN) end end end end return _M2) 接进 nginx.conf:HTTPS server 块已经有 access_by_lua_block { require("access_phase").decide() }。把新 模块挂在同一个 block 里,让它在规则放行之后才跑:nginxserver { listen 192.168.0.1:8443 ssl transparent reuseport; # ... location / { access_by_lua_block { require("access_phase").decide() require("prompt_filter").inspect() -- ← 新加这行 require("debug_dump").dump_request("https") } # ... } }3) 重新构建并重启:lua 文件是烘进 cube-egress 容器镜像的:bashcd CubeEgress && make build # 重新构建镜像 sudo systemctl restart cube-sandbox-cube-egress如果想不重新打镜像地快速迭代,把本地 lua/ 目录 bind-mount 进运行中的容器,然后 reload:bashsudo docker run -d --name cube-egress \ --network=host \ -v $PWD/CubeEgress/lua:/usr/local/openresty/nginx/lua:ro \ -v $PWD/CubeEgress/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro \ ...其它启动参数... sudo docker exec cube-egress nginx -s reload自定义 phase 里能拿到什么 ​你的模块跑在 access_by_lua 或更靠后的 phase 时,下列东西已经 就绪:来源取法用途ngx.ctx.cube_decisionaccess_phase 留下的决策结构:rule_name、allow、audit_level、inject_count按命中规则分支ngx.var.ssl_server_name握手时记下的原始 SNI身份锁定路由ngx.var.cube_audit_host匹配 host(SNI > Host header > dst IP)端点查找ngx.var.remote_addr沙箱 IP —— policy_store 的主键按沙箱维度做状态lua_shared_dict policy_store当前生效的 policy 表运行时读规则,例如把扩展字段塞进 rule body 里读出来lua_shared_dict cert_cacheleaf 证书缓存TLS 元信息audit.write_security_event(reason, decision)写一行 security_event JSONL让你的拒绝出现在审计里redactor.scrub(s)把字符串里的敏感数据抹掉日志任何用户可见的字段实现建议 ​挂在 access_phase 之后,不要在前。 这样 deny 规则先短路, 你的 hook 不会为已经会被拒的请求做昂贵计算。走 audit 模块、不要光 ngx.log。 手写日志不会进 access.jsonl,下游工具(SIEM ingest、SOC 大盘)按 JSONL schema 解析。控制延迟。 调外部分类服务时,lua-resty-http 的超时要 明显小于上游的 proxy_connect_timeout。沙箱在等你。失败要 fail-safe。 分类服务挂时,提前决定走"拒绝(安全 优先)"还是"放行(可用性优先)",但一定不要让 worker 崩。 外部调用包 pcall,无论结果如何写一条 security_event。别把密钥塞进 ngx.shared.*。 那些 dict 对所有 worker 可见; 敏感材料应该走 inject 路径(它只写出方向)或者 init_by_lua 的 per-worker 全局变量。更复杂的场景 —— 中间件链、内容改写、实时分类 —— 用同一套机制即可; OpenResty 的完整 lua API 都可用,含 lua-resty-http(出方向调用) 和 cjson(body 处理)。

评论 (0)