Cloudflare 宣布推出自助式 Cloudflare OHTTP Gateway 的封闭测试,同时将原来的 Privacy Gateway 更名为 Cloudflare OHTTP Relay。这个变化不只是换了产品名,而是更明确地把 Oblivious HTTP(OHTTP)链路中的两个角色拆开:Relay 负责接收客户端连接,Gateway 负责解封装并把请求送往目标服务。
对开发者而言,真正值得关注的是信任边界。普通 HTTPS 保护传输内容,但服务端仍能同时看到客户端网络身份和请求内容;OHTTP 则试图把这两类信息交给不同的基础设施处理。
Relay 和 Gateway 分别知道什么
一个典型的 OHTTP 请求可以抽象为:
Client
│ 封装并加密 HTTP 请求
▼
OHTTP Relay
│ 转发不透明消息
▼
OHTTP Gateway
│ 解封装请求
▼
Target Service
各角色的可见范围通常可以这样理解:
| 角色 | 可以看到 | 不应直接看到 |
|---|---|---|
| Relay | 客户端 IP、连接时间、加密消息大小、Gateway 地址 | 明文 HTTP 方法、路径、请求体 |
| Gateway | 解封装后的请求、目标服务、业务响应 | 客户端原始 IP |
| Target Service | 来自 Gateway 的业务请求 | 客户端与 Relay 之间的直接连接信息 |
因此,更名后的 Cloudflare OHTTP Relay 对应前一跳转发角色;新公布的 Cloudflare OHTTP Gateway 则承担解封装和访问目标服务的角色。自助式 Gateway 进入封闭测试,意味着接入方有机会更直接地配置这部分隐私基础设施,但实际 API、密钥格式和准入流程仍应以测试文档为准。
这种设计并不等于“完全匿名”。消息长度、时间相关性、流量模式、应用层标识符,以及 Relay 与 Gateway 是否独立运营,都会影响最终隐私强度。如果解封装后的请求仍携带账号 ID、广告标识符或稳定设备指纹,网络层隔离也无法消除业务层关联。
接入时最容易混淆的三个边界
1. OHTTP 不是普通反向代理
普通反向代理通常能够读取 HTTP 内容,并通过 X-Forwarded-For 等字段保留客户端地址。OHTTP Relay 的目标恰好相反:它转发的是封装后的消息,不应理解业务请求,也不应把客户端 IP 注入内层请求。
接入时应检查目标服务是否错误依赖以下字段:
X-Forwarded-For
Forwarded
CF-Connecting-IP
True-Client-IP
如果限流、风控或地域判断完全建立在终端 IP 上,引入 OHTTP 后就需要重新设计。可以考虑短期令牌、匿名配额凭证、会话级速率限制,或者由可信组件签发的不可链接证明,而不是试图恢复原始 IP。
2. Gateway 是一个新的高价值边界
Gateway 能看到解封装后的请求,因此需要像保护 API 入口一样保护它:
- 限定可访问的目标域名和端口,避免成为开放代理;
- 禁止访问环回地址、链路本地地址和云元数据地址;
- 对密钥进行轮换,并为旧配置保留有限的重叠时间;
- 日志中避免记录完整请求体、认证令牌和稳定用户标识;
- 为请求大小、超时、并发数和响应大小设置上限;
- 将 Gateway 到目标服务的认证与最终用户身份分开。
3. 目标服务仍需验证业务权限
OHTTP 隐藏网络层关联,不会自动完成用户认证和授权。目标 API 仍然要验证访问令牌、签名、作用域和重放保护。更稳妥的模式是使用短生命周期、窄权限令牌,并避免令牌本身成为跨请求追踪标识。
用本地脚本理解三段式请求路径
下面的 Python 程序会在本机启动 Target、Gateway 和 Relay 三个 HTTP 服务,然后由客户端通过 Relay 发出一次请求。它可以直接运行,用于观察调用顺序和各层日志。
重要说明:这个示例仅演示拓扑和职责划分。为了保持零依赖,它使用 Base64 模拟“封装”,不提供任何加密或隐私保护,也不是 OHTTP 协议实现。生产环境必须使用兼容 OHTTP 标准和 Cloudflare 测试接口的实现。
将代码保存为 ohttp_topology_demo.py:
import base64
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.request import Request, urlopen
TARGET_PORT = 9103
GATEWAY_PORT = 9102
RELAY_PORT = 9101
def post(url: str, body: bytes, content_type: str) -> bytes:
request = Request(
url,
data=body,
method="POST",
headers={"Content-Type": content_type},
)
with urlopen(request, timeout=5) as response:
return response.read()
class QuietHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
return
def read_body(self) -> bytes:
length = int(self.headers.get("Content-Length", "0"))
return self.rfile.read(length)
def reply(self, body: bytes, content_type="application/octet-stream"):
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
class TargetHandler(QuietHandler):
def do_POST(self):
body = self.read_body()
payload = json.loads(body)
print(f"[target] business path={payload['path']!r}")
result = json.dumps(
{"ok": True, "message": "processed by target"}
).encode()
self.reply(result, "application/json")
class GatewayHandler(QuietHandler):
def do_POST(self):
envelope = self.read_body()
# Demo only: Base64 is encoding, not encryption.
inner_request = base64.b64decode(envelope)
payload = json.loads(inner_request)
print(f"[gateway] decoded request for {payload['path']!r}")
target_response = post(
f"http://127.0.0.1:{TARGET_PORT}/execute",
inner_request,
"application/json",
)
wrapped_response = base64.b64encode(target_response)
self.reply(wrapped_response)
class RelayHandler(QuietHandler):
def do_POST(self):
opaque_message = self.read_body()
print(
"[relay] client=%s, opaque_bytes=%d"
% (self.client_address[0], len(opaque_message))
)
gateway_response = post(
f"http://127.0.0.1:{GATEWAY_PORT}/ohttp",
opaque_message,
"message/ohttp-req",
)
self.reply(gateway_response, "message/ohttp-res")
def start_server(port, handler):
server = ThreadingHTTPServer(("127.0.0.1", port), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server
def main():
servers = [
start_server(TARGET_PORT, TargetHandler),
start_server(GATEWAY_PORT, GatewayHandler),
start_server(RELAY_PORT, RelayHandler),
]
time.sleep(0.2)
inner_request = json.dumps(
{"path": "/v1/private-operation", "value": 42}
).encode()
envelope = base64.b64encode(inner_request)
wrapped_response = post(
f"http://127.0.0.1:{RELAY_PORT}/relay",
envelope,
"message/ohttp-req",
)
response = base64.b64decode(wrapped_response)
print("[client] response:", response.decode())
for server in servers:
server.shutdown()
if __name__ == "__main__":
main()
运行:
python3 ohttp_topology_demo.py
预期会看到类似输出:
[relay] client=127.0.0.1, opaque_bytes=68
[gateway] decoded request for '/v1/private-operation'
[target] business path='/v1/private-operation'
[client] response: {"ok": true, "message": "processed by target"}
把这个模型迁移到真实接入时,需要替换的不是简单的 Base64,而是完整的 OHTTP 封装、Gateway 公钥配置获取、响应解封装和密钥轮换逻辑。Relay 地址、Gateway 配置端点、媒体类型及访问凭证也需要以封闭测试提供的资料为准。
封闭测试阶段的接入清单
在申请或评估 Cloudflare OHTTP Gateway 时,可以先回答以下问题:
- 哪些请求确实需要拆分客户端 IP 与业务内容?
- Relay 和 Gateway 是否处于符合要求的独立信任边界?
- Gateway 允许访问哪些目标,是否默认拒绝未知目的地?
- 客户端如何发现并缓存 Gateway 公钥配置?
- 密钥轮换失败时,客户端采用失败关闭还是回退到普通 HTTPS?
- Relay、Gateway 和目标服务分别记录哪些日志,保留多久?
- 限流和反滥用机制是否会重新引入稳定身份标识?
- 超时、重试和消息填充策略是否可能泄露请求类型?
- 客户端不支持 OHTTP 时,产品是否允许降级,降级是否明确告知用户?
OHTTP 最适合那些“不希望单一中间方同时掌握来源和内容”的请求,例如遥测提交、隐私敏感查询或匿名令牌兑换。它会增加密钥管理、额外网络跳数、故障排查和反滥用设计的复杂度,因此不必机械地覆盖所有 API。
Cloudflare 这次产品拆分提供了更清晰的语言:Relay 处理来源侧连接,Gateway 处理内容侧请求。真正的落地重点不是名称,而是能否让这两个角色保持最小信息可见、最小日志记录和明确的失败策略。