在开放 API 或微服务接口设计中,签名系统是防篡改、防重放、保证请求真实性的重要机制。然而,在多层代理环境(如 Nginx、CDN、负载均衡器)中,Host 和端口信息可能发生变化,从而导致签名验签失败。本文将系统分析:
- 为什么签名中 Host 处理需要考虑端口
- 默认端口与非默认端口的差异
- 多层代理对 Host 的影响
- 通用做法与生产级实践
- 背后的 HTTP 协议设计初衷与兼容性考虑
一、签名系统中 Host 的作用
在主流 API 签名算法中,通常会用到以下信息:
- HTTP 方法(GET/POST 等)
- 请求路径(Path)
- Query 参数
- Headers(Host 是核心之一)
- 请求体内容(Body)
- 时间戳、Nonce、AppId 等
其中 Host 是区分请求资源归属的重要信息:
- 对于多域名同服务的虚拟主机
- 对于多层代理环境,需要确保请求经过的入口和签名一致
签名系统中的 Host 通常来源于服务端:
java
ctx.setHost(request.getHeader("Host"));
或者更安全的做法是使用 canonicalHost 方法对 Host 进行统一规范化。
二、端口对签名的影响
HTTP/HTTPS 协议允许默认端口省略:
| 协议 | 默认端口 | 可以省略? |
|---|---|---|
| HTTP | 80 | ✅ |
| HTTPS | 443 | ✅ |
因此,在签名算法中通用做法是:
- 默认端口(80/443)忽略
- 非默认端口必须保留
示例:
为什么这样设计?
-
默认端口容易丢失
- 浏览器访问
https://api.example.com→ Host:api.example.com - Nginx 默认转发 Host 可能去掉端口
- 如果签名要求带默认端口 → 验签失败
- 浏览器访问
-
非默认端口必须保留
- 无法通过协议推断
- 保留端口可以确保请求路由到正确服务
三、多层代理(Nginx)对 Host 的影响
在现代架构中,服务通常不直接暴露给公网,而是通过:
text
客户端 → CDN → Nginx(公网入口) → 内网服务
Host Header 的处理尤为关键:
1. 默认行为
nginx
location / {
proxy_pass http://backend;
}
-
如果没有显式设置
proxy_set_header Host- Nginx 会默认使用 proxy_pass 的 host (
$proxy_host)作为 Host 转发 - 默认端口省略,非默认端口保留
- 这种情况下 Host 不一定等于客户端原始 Host → 不稳定,可能破坏签名
- Nginx 会默认使用 proxy_pass 的 host (
-
如果显式设置
proxy_set_header Host $host;(推荐做法)- Host 会透传客户端请求 Host
- 默认端口会被去掉,非默认端口保留
$http_host可以保留客户端原始 Host(含端口)- 这种方式稳定,用于签名系统最安全
2. 不同配置的差异
| 配置 | Host 值 |
|---|---|
proxy_set_header Host $host; |
默认端口去掉,非默认端口保留 |
proxy_set_header Host $http_host; |
保留客户端原始 Host(包括端口) |
proxy_set_header Host $proxy_host; |
使用后端服务地址和端口 |
如果 Host 被改写,服务端直接用 request.getHeader("Host") 验签 → 签名可能不一致。
四、签名系统的关键原则
1. 不依赖网络链路保证 Host 一致
Nginx/CDN/负载均衡器可能修改 Host,客户端也可能带或不带默认端口,因此签名成功的关键是:
客户端和服务端使用同一套 canonicalHost 规则
2. canonicalHost 规则(生产级做法)
- 解析 Host 和端口
- 默认端口 → 去掉
- 非默认端口 → 保留
- 小写化域名
- 优先使用
X-Forwarded-Host(如果存在)
示例代码:
java
public static String canonicalHost(HttpServletRequest request) {
String host = request.getHeader("X-Forwarded-Host");
if (host == null || host.isEmpty()) {
host = request.getHeader("Host");
}
try {
URL url = new URL(request.getRequestURL().toString());
int port = url.getPort();
int defaultPort = url.getDefaultPort();
if (port == defaultPort || port == -1) {
host = url.getHost().toLowerCase(); // 默认端口去掉
} else {
host = url.getHost().toLowerCase() + ":" + port; // 非默认端口保留
}
} catch (MalformedURLException e) {
host = host.toLowerCase();
}
return host;
}
客户端也需同样处理:
java
String canonicalHost = OpenApiSigner.canonicalHost(baseUrl);
五、为什么这样设计?(HTTP 协议与兼容性背景)
-
HTTP 协议允许省略默认端口
- RFC 7230 定义 Host 可带端口,也可省略
- 浏览器、HTTP 客户端都会自动省略默认端口
-
兼容性考虑
- HTTP 设计初衷是"最大兼容性",不强制所有请求都写端口
- 强制写端口会破坏旧客户端访问
-
多层代理不可控
- Nginx/CDN/负载均衡可能修改 Host
- 默认端口容易丢失,非默认端口必须保留
-
工程实践要求
- 签名要对不可控输入做容错
- 客户端和服务端统一 canonical → 避免因网络层 Host 改写导致验签失败
-
非默认端口必须保留
- 确保请求路由正确
- 保证签名唯一性和安全性
综上,这是 HTTP 协议设计约束 + 多层代理不可控性 + 签名系统工程实践综合产物。
六、生产级实践总结
在客户端访问公网入口 A,服务端部署在内网服务器 B 的场景下:
- 客户端签名使用 A 的公网 Host
- Nginx 转发推荐使用:
nginx
proxy_set_header Host $http_host;
- 服务端验签使用
canonicalHost(request) - 默认端口去掉,非默认端口保留
- 支持
X-Forwarded-Host优先使用
这样可以保证:
- 多层代理不破坏签名
- 默认端口和非默认端口处理一致
- 签名系统稳定可靠
七、示例表
八、总结
- Host canonical 是签名稳定性的核心
- 默认端口省略、非默认端口保留是通用做法
- Nginx 配置 Host 只是减少副作用,而不是根本保障
- 客户端和服务端统一 canonicalHost 规则才是关键
通过这种设计,签名系统既兼顾 HTTP 协议的兼容性,又保证在多层代理环境下的验签稳定性。