站是 Bun + Hono 写的,跑在容器里,对外走 Cloudflare Tunnel。五天前连着两个 bug(be15f5a、2ea4f53,中间隔八分钟),都是「把本地开发时的假设带进了这套拓扑」。

第一个:origin 是 http

隧道从公网把请求送进来的时候,到容器这一跳是 http。所以凡是靠 request.url 拿 origin 的地方,拿到的都是 http://...。

多数地方无所谓,有两处不行:

  • cookie 的 Secure 属性、以及 OAuth 回调地址拼装——拼出来是 http 回调,回跳时浏览器按 https 算,对不上。
  • 生成绝对 URL 给外面用(分享链接、邮件里的链接)——发出去是 http,用户点了要么被重定向要么直接不通。

Cloudflare 会带 x-forwarded-host、x-forwarded-proto。那就按它还原?

不行。x-forwarded-host 是客户端可以伪造的头部,直连容器端口的时候更是完全由请求方说了算。无条件信它,就多了一个能改 origin 的口子。

写了个 publicRequest(request, configuredUrl)(src/server/public-request.ts,全文 10 行),规则是:

  1. 没有配置公开域名,原样返回。
  2. 请求的 host 与配置的 host 不一致 → 不动。说明是从别的入口进来的(回环预览、内网直连),那份 origin 是使用者自己的,改了反而错。
  3. host 一致,但协议已经相同 → 不动。别多此一举地复制请求。
  4. 剩下的唯一情况:host 一致、协议不同 → 只改 protocol,new Request() 重建一个。
const configured = new URL(configuredUrl);
const incoming = new URL(request.url);
if (incoming.host !== configured.host || incoming.protocol === configured.protocol)
  return request;
incoming.protocol = configured.protocol;
return new Request(incoming, request);

new Request(url, request) 会保留 method、body、headers 和所有其他属性,只在 origin 上做了替换。src/server/index.ts 里把 fetch: app.fetch 改成先过这道处理再转交:

fetch: (...[request, bindings, executionCtx]: Parameters<typeof app.fetch>) =>
  app.fetch(publicRequest(request, process.env.APP_URL), bindings, executionCtx),

于是「还原 https」只在确定是这个入口的时候发生,伪造的 x-forwarded-host 因为 host 对不上而被忽略。

tests/public-request.test.ts 三条覆盖的就是这件事:还原 https 的同时 POST 正文 / Cookie / 查询串一个不少;带 x-forwarded-host: af.saop.cc 但实际连的是 127.0.0.1:31270 时不被信任;同协议或没配置公开域名时返回同一个对象(而不是复制)。

第三条严格说不是安全要求,是可预期性——本地 curl 127.0.0.1:3000 调试时如果突然看到 https 形式的 origin,会怀疑半天。

第二个:容器的 127.0.0.1 不是宿主的

容器要访问外网,得走宿主机上的代理 127.0.0.1:11090。

compose.yaml 里这么写:

environment:
  HTTP_PROXY: ${OUTBOUND_PROXY:-}
  HTTPS_PROXY: ${OUTBOUND_PROXY:-}
  NO_PROXY: ${OUTBOUND_NO_PROXY:-}
  http_proxy: ${OUTBOUND_PROXY:-}
  https_proxy: ${OUTBOUND_PROXY:-}
  no_proxy: ${OUTBOUND_NO_PROXY:-}

大小写两套都写,因为 Bun/undici 认小写、很多 shell 工具只认大写。

有个坑得在 .env.production.example 里直接写清楚:

# 宿主机上的代理,不是容器自己的 127.0.0.1
OUTBOUND_PROXY=http://127.0.0.1:11090
OUTBOUND_NO_PROXY=localhost,127.0.0.1,postgres,redis

OUTBOUND_PROXY 填 127.0.0.1,指向的是宿主机的回环。 而容器里的 127.0.0.1 是容器自己,对不上就整个网都不通。同时 NO_PROXY 里必须带上 postgres、redis 这些服务名——它们在同一条 compose 网络里,直连比绕代理快,也免得代理不认识这些名字。

别写进镜像

代理是环境相关的:构建机上有、笔记本上可能没有、生产上端口可能不同。所以只用运行时 environment 注入,Dockerfile 里一个 ENV HTTP_PROXY 都不写。

写进镜像的话,镜像就被绑死在构建时的网络配置上。而且 Dockerfile 里的 ENV 会盖过运行时注入——想改只能重建镜像,明明是个配置问题。

两件事的共同点

两个 bug 的形状一样:代码按「请求直连、网络直连」写,部署拓扑不是那样。

隧道和容器各自都工作得很好,是两者的交界处缺了一层翻译——一个把入站 origin 从 http 翻回 https(且只在该翻的时候翻),一个把出站目标从容器回环翻到宿主回环。

这类交界处的东西有个特点:本地跑一切正常,因为本地既是"公网入口"也是"宿主机",两个假设同时成立。只有部署上去才炸,而且炸的位置离改动很远。

deploy/README.md 里现在有这两段的说明,摆在一起。下一回有人加新环境变量,至少能看到「容器和宿主是两个回环」这句话。