
Sandbox 中的程序需要调用 OpenAI、GitHub 或内部 API，但把 API Key 放进环境变量后，程序本身也能读取和泄漏它。CubeSandbox 采用了另一种办法：密钥不进入 Sandbox，而是在 HTTPS 请求离开 Sandbox 时由宿主机代理写入请求头。

本文先从 CubeSandbox 源码确认调用链，再用一个 Docker Compose 实验复刻核心机制。实验不包含控制面、数据库和动态证书缓存，只保留四个必要步骤：

1. 用 iptables 把 Sandbox 的 443 流量透明重定向到代理。
2. 让 Sandbox 信任代理 CA，由代理终止客户端 TLS。
3. 代理校验目标并注入 `Authorization`。
4. 代理验证真实上游证书，重新建立一条 TLS 连接。

完整实现见本文的 [Demo 源码](#demo-源码)章节。

<!--more-->

## 先给出结论

CubeSandbox 把密钥留在 Sandbox 外部，通过宿主机上的 HTTPS MITM 代理改写出站请求。

本文 Demo 对 CubeSandbox 的生产实现做了以下简化：

| 项目 | 透明引流 | HTTPS 代理 | CA | 注入位置 |
| --- | --- | --- | --- | --- |
| CubeSandbox | CubeVS/eBPF 选择 L7 流量，iptables TPROXY 送入代理 | OpenResty + Lua | 模板预置信任根 CA，运行时按 SNI 签叶子证书 | `access_by_lua` |
| 本文 Demo | Docker 共享网络命名空间中的 iptables OUTPUT REDIRECT | 单文件 Go 代理 | Docker 生成测试 CA，固定签发一个域名 | Go `http.Handler` |

本文核对的 CubeSandbox 源码版本是 `002e4049fff057afc789edda516285b7611dc27c`。

## 核心数据链路

```mermaid
flowchart LR
    C["Sandbox 内的客户端<br/>没有 API Key"]
    I["iptables / eBPF<br/>透明引流"]
    P["宿主机 MITM Proxy"]
    M{"SNI、Host、规则<br/>是否一致"}
    J["写入 Authorization"]
    U["真实 HTTPS 上游"]

    C -->|"TLS:443"| I
    I --> P
    P -->|"使用代理 CA 解密"| M
    M -->|"不符合"| R["拒绝或不注入"]
    M -->|"符合"| J
    J -->|"重新建立并校验 TLS"| U
```

## MITM Proxy 是什么

MITM Proxy 是 Man-in-the-Middle Proxy，即「中间人代理」。普通 HTTPS 只有客户端和服务端参与：

```text
Sandbox --TLS--> Real Upstream
```

引入 MITM Proxy 后，一条连接被拆成两条：

```text
Sandbox --TLS A--> MITM Proxy --TLS B--> Real Upstream
```

TLS A 终止在代理上，因此代理可以读取请求中的 Method、Path 和 Header；TLS B 由代理重新发起，用来连接真实服务。这里的「MITM」描述的是代理所处的位置，并不天然表示攻击。Charles、Fiddler 和企业 HTTPS 安全网关也使用类似机制，区别在于客户端是否经过授权并信任代理 CA。

代理能解密 HTTPS，是因为 Sandbox 提前信任了它的根 CA：

1. 代理持有根 CA 证书和私钥，Sandbox 只安装根 CA 公钥证书。
2. Sandbox 访问 `api.demo.local` 时，会在 TLS ClientHello 中发送 SNI。
3. 代理为 `api.demo.local` 签发叶子证书，并用它与 Sandbox 完成 TLS A 握手。
4. Sandbox 验证叶子证书由已信任的代理 CA 签发，因此接受这条连接。
5. 代理解密 HTTP 请求、匹配策略并写入密钥，然后通过 TLS B 转发给真实服务。

例如，Sandbox 发出的请求不包含认证信息：

```http
POST /v1/chat HTTP/1.1
Host: api.demo.local
```

代理在确认 Sandbox 身份、SNI、Host 和规则一致后，才把请求改成：

```http
POST /v1/chat HTTP/1.1
Host: api.demo.local
Authorization: Bearer <REAL_API_KEY>
```

因此 Sandbox 可以使用这把密钥调用服务，却不能从环境变量或文件中直接读取密钥。这个边界成立还依赖四个条件：CA 私钥不能进入 Sandbox；Sandbox 不能绕过代理直连公网；代理连接真实上游时必须验证正式证书；注入前必须验证 SNI、HTTP Host 和规则目标一致。

## CubeSandbox 的实现

CubeSandbox 把出站代理称为 CubeEgress。它使用 OpenResty 的两个透明监听端口：HTTP 使用 8080，HTTPS 使用 8443。iptables 的 `mangle/PREROUTING` 把从 `cube-dev` 进入的 80 和 443 流量送入这两个端口，相关规则见 [`cube-proxy-iptables-init.sh`](https://github.com/TencentCloud/CubeSandbox/blob/002e4049fff057afc789edda516285b7611dc27c/CubeEgress/scripts/cube-proxy-iptables-init.sh#L84-L117)。

HTTPS 请求进入 8443 后，OpenResty 从 ClientHello 读取 SNI，调用 `cert_signer.serve()` 签发叶子证书；转发真实上游时开启 `proxy_ssl_verify`，并把原始 SNI 设置为上游 TLS Server Name。配置见 [`nginx.conf`](https://github.com/TencentCloud/CubeSandbox/blob/002e4049fff057afc789edda516285b7611dc27c/CubeEgress/nginx.conf#L94-L100) 和 [`nginx.conf`](https://github.com/TencentCloud/CubeSandbox/blob/002e4049fff057afc789edda516285b7611dc27c/CubeEgress/nginx.conf#L139-L194)。

叶子证书的生成过程是：生成 ECDSA P-256 私钥，把 SNI 写入 SAN，使用 CubeEgress 根 CA 签名，然后把证书和私钥交给当前 TLS 握手。实现见 [`cert_signer.lua`](https://github.com/TencentCloud/CubeSandbox/blob/002e4049fff057afc789edda516285b7611dc27c/CubeEgress/lua/cert_signer.lua#L96-L157)。

请求解密后，`access_phase.lua` 按 Sandbox 来源 IP 查策略，逐条匹配 scheme、SNI、Host、method 和 path。只有规则允许，并且 HTTPS 的 Host 与 SNI 相等时，才通过 `ngx.req.set_header` 写入密钥。相关检查见 [`access_phase.lua`](https://github.com/TencentCloud/CubeSandbox/blob/002e4049fff057afc789edda516285b7611dc27c/CubeEgress/lua/access_phase.lua#L180-L220)，实际写 Header 的代码见 [`access_phase.lua`](https://github.com/TencentCloud/CubeSandbox/blob/002e4049fff057afc789edda516285b7611dc27c/CubeEgress/lua/access_phase.lua#L223-L275)。

因此，CubeSandbox 确实就是本文复刻的这条链路，只是生产实现还增加了 eBPF 流量选择、按 Sandbox IP 的策略、动态证书缓存和审计日志。

## Docker Demo 的组成

Demo 包含四个长期或一次性进程：

```mermaid
flowchart TB
    subgraph NS["共享的 Sandbox network namespace"]
        N["sandbox<br/>安装 iptables"]
        C["client<br/>curl / openssl"]
        P["proxy uid=1337<br/>监听 15001"]
    end

    U["upstream<br/>监听 HTTPS 443"]
    C -->|"访问 api.demo.local:443"| N
    N -->|"REDIRECT :15001"| P
    P -->|"uid 1337 不再重定向"| U
```

## Demo 源码

Demo 源码保存在本文的 `files/cubesandbox-credential-injection-demo/` 目录：

- [`compose.yaml`](https://jimyag.com/posts/sandbox-https-credential-injection-demo/files/cubesandbox-credential-injection-demo/compose.yaml)
- [`Dockerfile`](https://jimyag.com/posts/sandbox-https-credential-injection-demo/files/cubesandbox-credential-injection-demo/Dockerfile)
- [`go.mod`](https://jimyag.com/posts/sandbox-https-credential-injection-demo/files/cubesandbox-credential-injection-demo/go.mod)
- [`cmd/proxy/main.go`](https://jimyag.com/posts/sandbox-https-credential-injection-demo/files/cubesandbox-credential-injection-demo/cmd/proxy/main.go)
- [`cmd/upstream/main.go`](https://jimyag.com/posts/sandbox-https-credential-injection-demo/files/cubesandbox-credential-injection-demo/cmd/upstream/main.go)
- [`images/certs/Dockerfile`](https://jimyag.com/posts/sandbox-https-credential-injection-demo/files/cubesandbox-credential-injection-demo/images/certs/Dockerfile)
- [`images/certs/generate.sh`](https://jimyag.com/posts/sandbox-https-credential-injection-demo/files/cubesandbox-credential-injection-demo/images/certs/generate.sh)
- [`images/client/Dockerfile`](https://jimyag.com/posts/sandbox-https-credential-injection-demo/files/cubesandbox-credential-injection-demo/images/client/Dockerfile)
- [`images/client/run.sh`](https://jimyag.com/posts/sandbox-https-credential-injection-demo/files/cubesandbox-credential-injection-demo/images/client/run.sh)
- [`images/sandbox/Dockerfile`](https://jimyag.com/posts/sandbox-https-credential-injection-demo/files/cubesandbox-credential-injection-demo/images/sandbox/Dockerfile)
- [`images/sandbox/init.sh`](https://jimyag.com/posts/sandbox-https-credential-injection-demo/files/cubesandbox-credential-injection-demo/images/sandbox/init.sh)

`sandbox`、`client` 和 `proxy` 通过 Compose 的 `network_mode: service:sandbox` 共享同一个网络命名空间。这样在 `sandbox` 容器安装的 OUTPUT 规则也会作用于 `client` 发出的连接。

代理固定使用 UID 1337，iptables 先放过这个 UID，再把其余 443 连接重定向到 15001：

```sh
iptables -t nat -N CREDENTIAL_PROXY
iptables -t nat -A CREDENTIAL_PROXY \
  -p tcp -m owner --uid-owner 1337 -j RETURN
iptables -t nat -A CREDENTIAL_PROXY \
  -p tcp -j REDIRECT --to-ports 15001
iptables -t nat -A OUTPUT \
  -p tcp --dport 443 -j CREDENTIAL_PROXY
```

如果不排除代理 UID，代理创建的上游 TLS 连接也会再次进入代理，形成重定向循环。

## 生成两套测试证书

实验故意使用两套 CA：

- `Demo MITM Root CA`：客户端信任它，代理使用它签发 `api.demo.local` 的 MITM 证书；
- `Demo Upstream Root CA`：只由代理信任，用于验证真实上游证书。

先进入 Demo 目录：

```bash
cd content/blog/275-用\ Docker\ 复刻\ Sandbox\ 的\ HTTPS\ 密钥注入/demo
```

再使用 Docker 生成测试证书：

```bash
docker compose run --build --rm certs
```

这些私钥仅用于本地实验，并保存在 Docker named volume 中，不会写入文章目录。生成脚本为了让 UID 1337 的代理读取测试私钥，把它们设为可读文件；生产环境不能照搬这个权限设置，也不能把 CA 私钥交给 Sandbox。

## 启动透明代理实验

启动 Sandbox 网络命名空间、上游和代理：

```bash
docker compose up --build -d sandbox upstream proxy
```

检查透明重定向规则：

```bash
docker compose exec sandbox iptables -t nat -S OUTPUT
docker compose exec sandbox iptables -t nat -S CREDENTIAL_PROXY
```

可以看到：

```text
-A OUTPUT -p tcp --dport 443 -j CREDENTIAL_PROXY
-A CREDENTIAL_PROXY -p tcp -m owner --uid-owner 1337 -j RETURN
-A CREDENTIAL_PROXY -p tcp -j REDIRECT --to-ports 15001
```

客户端没有设置 `HTTPS_PROXY`，请求会完全依赖 iptables 透明进入代理：

```bash
docker compose run --rm client
```

实际输出：

```text
== client environment ==
DEMO_API_KEY is absent
== certificate seen by the client ==
subject=CN=api.demo.local
issuer=CN=Demo MITM Root CA
== normal request without an Authorization header ==
{"authenticated":true,"authorization_present":true,"host":"api.demo.local"}
== SNI/Host mismatch must not receive a credential ==
SNI and Host must match
mismatch status: 421
```

这几行分别证明：

1. 客户端环境中没有 `DEMO_API_KEY`。
2. 客户端看到的是 MITM CA 签发的证书，而不是真实上游证书。
3. 客户端未发送认证 Header，但上游收到了正确的 Bearer Token。
4. SNI 与 Host 不一致时，请求在读取密钥前被拒绝。

## 从计数器和日志确认数据路径

查看规则计数器：

```bash
docker compose exec sandbox \
  iptables -t nat -L CREDENTIAL_PROXY -n -v --line-numbers
```

本次实验中，三个客户端 TLS 连接被重定向，一个代理上游连接因为 UID 1337 被放行：

```text
pkts target    extra
1    RETURN    owner UID match 1337
3    REDIRECT  redir ports 15001
```

再查看代理和上游日志：

```bash
docker compose logs proxy upstream
```

```text
proxy: inject Authorization for sni=api.demo.local path=/hello
proxy: drop injection: SNI/Host mismatch sni="api.demo.local" host="attacker.example"
upstream: authorization_present=true valid=true
```

密钥值没有写入日志，只记录是否注入以及上游是否验证成功。

实验结束后清理容器：

```bash
docker compose down --volumes --remove-orphans
```

## Demo 省略了什么

这个 Demo 只用于解释机制，不能直接作为生产代理：

- 只支持一个固定域名和 HTTP/1.1；
- 预先生成一张叶子证书，没有按 SNI 动态签发和缓存；
- 规则和密钥来自环境变量，没有控制面或 KMS；
- 使用 OUTPUT REDIRECT，没有保留原始目的地址；
- 没有处理 HTTP/2、QUIC、证书固定、连接池隔离和审计；
- 没有提供多 Sandbox、多租户策略隔离。

但它已经覆盖了生产实现最关键的安全边界：客户端不持有密钥、流量无法绕过代理、代理能解密 HTTPS、注入前绑定 SNI 与 Host、上游 TLS 仍然需要验证。

## 总结

所谓 Sandbox 密钥注入，并不是一种特殊的密钥协议。它建立在透明代理和 TLS MITM 之上：

```text
iptables/eBPF 引流
    -> 受信任 CA 终止客户端 TLS
    -> 根据 Sandbox 身份和目标匹配规则
    -> 写入认证 Header
    -> 验证证书并重新连接真实上游
```

CubeSandbox 使用 CubeVS、TPROXY 和 OpenResty 实现这条链路；本文 Demo 则用 Docker 共享网络命名空间把相同原理缩减到可以在一台开发机上直接观察的程度。

