强化 sgcWebSockets 中的 HTTP 客户端 | eSeGeCe 博客

强化 sgcWebSockets 中的 HTTP 客户端

· 组件

我们之前写过如何强化 HTTP 服务端。这篇文章讲的是故事的另一半。客户端通常被视为对话中可信的一方,但事实并非如此:它会发送你的 API 密钥,它会跟随远程主机返回的任何重定向,它会把整个响应读进内存,而且响应让它存什么 cookie 它就存什么。一个怀有恶意,或者只是被攻陷的端点,可以把这四种行为全都反过来对付你。

sgcWebSockets 2026.7 新增了一组客户端开关,逐一堵住这些漏洞。它们默认全部关闭,所以升级之后,现有应用不会有任何变化,除非你主动选择启用。这些开关可用于底层 HTTP 客户端、每一个开箱即用的 REST 和 AI 客户端,以及 HTTP/2 和 HTTP/3 客户端。

跟着重定向跑掉的凭据

这是经典的客户端泄漏。你带着 Authorization 头调用一个 API,服务器返回一个指向另一台主机的 302,客户端就老老实实地把请求连同请求头和 cookie 一起重发到那里。你的 bearer 令牌就这样被交给了一台你从未打算与之通信的机器。这个重定向甚至不需要是恶意的,攻击者只要能在合法主机上利用一个开放重定向就够了。

StripAuthOnCrossHostRedirect 会在重定向离开原始主机的那一刻,丢弃 AuthorizationCookie 头。请求仍然会跟随重定向,只是到达时不再携带你的凭据。

uses
  sgcHTTP_Client;

var
  oHTTP: TsgcIdHTTP;
begin
  oHTTP := TsgcIdHTTP.Create(nil);
  try
    oHTTP.StripAuthOnCrossHostRedirect := True;

    oHTTP.Request.CustomHeaders.Values['Authorization'] := 'Bearer ' + vToken;
    vResponse := oHTTP.Get('https://api.example.com/v1/account');
  finally
    oHTTP.Free;
  end;
end;

降级到明文 HTTP 的重定向

第二个重定向问题。你从 https:// 开始,服务器返回一个以 http:// 开头的 Location,客户端就欢快地用明文重发请求。链路上的任何人现在都能读取正文并改写响应。这正是浏览器里 HSTS 要防止的那种降级,而在此之前 HTTP 客户端没有对应的机制。

NoInsecureRedirect 会拒绝任何把请求从 HTTPS 移到 HTTP 的重定向。该重定向会被拒绝,而不是被跟随。

oHTTP.NoInsecureRedirect := True;

// A 302 from https://api.example.com to http://api.example.com
// is now blocked instead of silently downgraded.
vResponse := oHTTP.Get('https://api.example.com/v1/data');

这两个重定向开关是相互独立的,实际使用中你两个都想要。打开第一个,跨主机重定向就不再带着你的令牌。打开第二个,重定向就不能再剥掉你的传输层安全。

没有大小限制的响应

默认的 HTTP 客户端会读取服务器发来的一切。如果服务器发来一个十 GB 的响应正文,或者一个分块响应里某一块声明长度为两 GB,客户端就会尝试为它分配空间。这是针对你自己进程的一行式拒绝服务,而且攻击者不需要攻破任何东西,只需要用超出你预期的数据量回应你的请求。

两个上限堵住了这一点。MaxResponseSize 限制响应正文的总大小,MaxChunkSize 限制分块响应中单个块的大小。两者都以零表示"无限制",这也是默认值,所以在你设置之前什么都不会变。一旦超过限制,读取就会中止并抛出异常,客户端不会继续扩大缓冲区。

uses
  sgcHTTP_Client;

var
  oHTTP: TsgcIdHTTP;
begin
  oHTTP := TsgcIdHTTP.Create(nil);
  try
    oHTTP.MaxResponseSize := 10 * 1024 * 1024;  // 10 MB total, 0 = unlimited
    oHTTP.MaxChunkSize    := 1024 * 1024;       // 1 MB per chunk, 0 = unlimited

    vResponse := oHTTP.Get('https://api.example.com/v1/report');
  finally
    oHTTP.Free;
  end;
end;

挑一个能反映该端点实际应该返回多少数据的数字。如果你调用的 JSON API 只回复几 KB,那么几 MB 的上限已经很宽松了,同时把一次无界读取变成了有界读取。

一个响应可以发送带 Domain 属性的 cookie。协议格式本身并不阻止它声明 Domain=comDomain=co.uk。接受这种 cookie 的客户端,就会愿意把它发给该后缀下的每一台主机,这在 cookie 世界里相当于把万能钥匙送人。多年来浏览器一直用公共后缀列表(Public Suffix List)拒绝这种做法。

RejectPublicSuffixCookies 把同样的规则带到了组件里。如果一个 cookie 的域是公共后缀而不是真正可注册的域名,它就会被丢弃。注意,启用它也会同时打开客户端的 cookie 管理,因为它过滤的正是 cookie 罐。

oHTTP.RejectPublicSuffixCookies := True;

// Set-Cookie: session=abc; Domain=co.uk   ->  rejected
// Set-Cookie: session=abc; Domain=example.co.uk  ->  accepted

2026.7 还修复了两个此前一直悄悄出错的 cookie 与重定向缺陷。相对的 Location 头现在能正确解析为绝对 URL,307 或 308 重定向现在会按规范要求保留请求方法和正文,而过期时间无法解析的 cookie 现在被当作会话 cookie 处理,而不是被直接丢弃。当两者同时存在时,Max-Age 仍然优先于 Expires

上传大正文:Expect: 100-continue 与分块请求

并非每个新选项都是关于攻击的,其中两个是关于不浪费带宽。如果你把一个很大的正文 POST 到一个注定会拒绝它的端点(凭据错误、内容类型不对、配额已经用光),默认行为是先把整个正文推上网络,然后再读到那个 401。

UseExpect100Continue 会先发送带 Expect: 100-continue 的请求头,等待服务器表示它愿意接收正文。如果服务器直接返回一个最终状态码,你就根本不会发送负载。如果完全没有收到放行信号,正文仍然会被发送,所以忽略这个头的服务器照样能正常工作。

UseChunkedTransferEncoding 覆盖的是相反的情况,也就是事先不知道大小的上传。正文会以分块方式发送,而不是带固定的 Content-Length,所以你可以直接从生成器、压缩器或管道流式发出,而不必先把整个内容缓冲起来。

uses
  Classes, sgcHTTP_Client;

var
  oHTTP: TsgcIdHTTP;
  oBody: TStream;
begin
  oHTTP := TsgcIdHTTP.Create(nil);
  try
    oHTTP.UseExpect100Continue       := True;   // ask before uploading
    oHTTP.UseChunkedTransferEncoding := True;   // size not known up front

    oBody := TFileStream.Create('large_upload.bin', fmOpenRead or fmShareDenyWrite);
    try
      vResponse := oHTTP.Post('https://api.example.com/v1/upload', oBody);
    finally
      oBody.Free;
    end;
  finally
    oHTTP.Free;
  end;
end;

开箱即用的 API 客户端上的同一批开关

有意思的部分在于这些属性所在的位置。它们被重新暴露在 TsgcHTTPAPI_client 上,也就是库中每一个开箱即用 REST 客户端的基类。这意味着你可以直接在你已经在用的组件上设置它们,而不必去碰底层的 HTTP 对象。

这覆盖了 AI 与 LLM 客户端(OpenAI、Anthropic、Gemini、DeepSeek、Grok、Mistral、Ollama)、OAuth2 客户端、Google Cloud、WhatsApp、AWS SQS 和 WebPush,以及 Amazon AWS 和 Google Cloud 的 API 单元。

uses
  sgcHTTP_API_OpenAI;

var
  oOpenAI: TsgcHTTP_API_OpenAI;
begin
  oOpenAI := TsgcHTTP_API_OpenAI.Create(nil);
  try
    oOpenAI.StripAuthOnCrossHostRedirect := True;   // never leak the API key
    oOpenAI.NoInsecureRedirect           := True;   // never fall back to http
    oOpenAI.RejectPublicSuffixCookies    := True;
    oOpenAI.MaxResponseSize              := 32 * 1024 * 1024;
    oOpenAI.MaxChunkSize                 := 1024 * 1024;

    // ... use the client as usual
  finally
    oOpenAI.Free;
  end;
end;

发送给 LLM 服务商的 API 密钥,恰恰是你最不希望跟着重定向跑出服务商域名的那种凭据,所以这里正是打开这些开关几乎不需要付出代价、却收益巨大的地方。

HTTP/2 与 HTTP/3

HTTP/2 客户端通过它的选项对象 TsgcWSHTTP2Client_Options 获得同样的保护,而 HTTP/3 客户端直接暴露了响应大小上限。

uses
  sgcHTTP;

var
  oHTTP2: TsgcHTTP2Client;
begin
  oHTTP2 := TsgcHTTP2Client.Create(nil);
  try
    oHTTP2.HTTP2Options.StripAuthOnCrossHostRedirect := True;
    oHTTP2.HTTP2Options.NoInsecureRedirect           := True;
    oHTTP2.HTTP2Options.MaxResponseSize              := 10 * 1024 * 1024;
  finally
    oHTTP2.Free;
  end;
end;

// HTTP/3
oHTTP3.MaxResponseSize := 10 * 1024 * 1024;

一行代码得到安全的 TLS 基线

如果 TLS 层根本不做任何验证,上面这些都帮不上忙。sgcWebSockets 2026.7 为此新增了一个预设。库中每一个 TLS 选项对象,无论是 WebSocket 还是 HTTP 组件,都发布了一个类型为 TsgcTLSPresetPreset 属性。它默认为 tlspCustom,也就是保留你手工配置的一切。

把它设为 tlspSecureDefaults,就能用一行代码打开证书验证、TLS 1.2 或更高版本,以及主机名验证。

uses
  sgcWebSocket_Types;

begin
  oHTTP.TLSOptions.Preset := tlspSecureDefaults;
  // certificate verification on, TLS 1.2+, hostname verification on
end;

把这个预设与重定向开关和响应大小上限结合起来,你就得到了一个客户端:它会验证自己在和谁通信,拒绝降级,不会把凭据交给第三方,也不会被诱导去分配无限的内存。

.NET

托管版在 2026.7 中提供了同样的保护。重定向凭据剥离、HTTPS 降级阻断、单块与总响应大小上限、公共后缀 cookie 拒绝、Expect: 100-continue 以及分块请求正文全都具备,包括 HTTP/2 客户端系列。名称相同,采用 C# 的大小写风格。

client.StripAuthOnCrossHostRedirect = true;
client.NoInsecureRedirect           = true;
client.RejectPublicSuffixCookies    = true;
client.MaxResponseSize              = 10 * 1024 * 1024;
client.MaxChunkSize                 = 1024 * 1024;

可用性

HTTP 客户端强化选项在 sgcWebSockets 2026.7 中提供,覆盖 Delphi 7 到 13 和 C++Builder(Win32/Win64、Linux64、macOS、Android 和 iOS),以及 .NET 版。这里描述的一切都是可选启用且默认关闭的,所以升级是无缝的。

拥有有效订阅的客户可以从客户专区下载新版本。试用用户可以在 esegece.com/products/websockets/download 获取更新后的安装程序。

有疑问、反馈,或者想知道你的应用应该打开其中哪些开关?联系我们,回复你的将是编写这些代码的人。