HTTP 请求注入可以从换行符开始吗?
HTTP 请求注入可能从隐藏的换行符开始。了解如何在带凭据的请求渲染前,拒绝目标和请求头中的 CRLF。

凭据注入器可以让 API 密钥远离代理上下文,却仍然把密钥发送到错误的请求中。问题出在网关把代理输入当作无害文本,然后在凭据层完成工作后,将这些输入粘贴进 HTTP 语法。
我见过一些团队在保管库存储、审批提示和审计日志上投入大量精力,却在代理和网络之间留下一个字符串格式化器。这个格式化器也属于安全边界的一部分。如果它允许目标、自定义请求头名称或代理提供的请求头值中出现回车或换行,就可能让数据创建请求结构。
修复方案有意保持简单:在渲染请求前拒绝回车和换行,解析结构化输入,不要用字符串拼接来组合请求,并测试真正离开进程的字节。不要试图通过去除首尾空白或替换来清理这类输入。拒绝请求才是诚实的处理方式。修复后的请求可能已经变成了另一个请求。
HTTP 请求注入可以从换行符开始吗?
可以。在 HTTP/1.1 中,换行符用于分隔消息的不同部分。如果代码把不可信文本放入请求行或请求头行,却没有强制执行这一边界,攻击者就可以使用 CRLF,也就是回车后跟换行,结束当前行并开始另一行。
一个简化的易受攻击渲染器看起来往往没有问题:
GET {path} HTTP/1.1\r\n
Host: {host}\r\n
Authorization: Bearer {vault_token}\r\n
{custom_name}: {agent_value}\r\n
\r\n
假设代理提供了以下值:
blue\r\nX-Forwarded-Host: internal.example
渲染后的字节现在包含了一个额外的请求头。凭据没有泄露到代理的提示词中,但代理仍然以设计者没有预料到的方式影响了已认证请求。
当网关自行构造第一行时,或某一层解析 URL、随后另一层又通过文本格式化组合路径、主机或代理目标时,目标字符串中也会出现同类问题。具体结果取决于 HTTP 库和中间层的行为。这种不确定性不能成为防御理由。不同组件对消息边界产生分歧,正是小小的验证遗漏演变成安全事件的方式。
RFC 9110 将 HTTP 字段行定义为由字段名、冒号和字段值组成的语法。它也将换行视为消息成帧的一部分,而不是字段内部的普通内容。这个区别比笼统地提醒人们“转义用户输入”更重要。对于这个问题,字段中不存在有用的换行转义形式。拒绝它。
保管库保护凭据,不保护请求结构
凭据注入和请求注入解决的是两个不同的问题。前者让机密远离代理,后者确保接收机密的请求具有人工批准的结构。
设想一个工具允许代理使用保管库中的 bearer token 调用计费 API,同时接受可选的自定义请求头。开发者可能会认为这个请求头风险较低,因为令牌从未出现在工具参数中。这种推理忽略了相邻内容的影响。注入的一行可以添加转发请求头、修改内容长度、复制应用请求头,或污染下游使用的审计标签。凭据仍然保密,但操作已经变得不安全。
目标同样值得怀疑。看起来像数据的主机名可能会变成 authority 语法。路径可能会变成请求目标。代理选择器可以改变客户端连接的位置。如果代理能够影响这些值中的任何一个,网关就必须在附加凭据之前决定哪些形式有效。
因此,允许使用的主机名列表虽然有用,却仍然不够。它可以限制解析后的 URL 指向哪里,但不能证明每个组件收到的是同一个解析后的 URL,也不能阻止用户信息中的换行、请求头覆盖,或在允许列表检查后追加的字符串。边界必须拒绝控制字符,并让结构化值一直保持到 HTTP 客户端。
在创建请求对象前拒绝 CR 和 LF
最安全的规则可以用一句话说清楚:代理控制的目标字段、自定义请求头名称和自定义请求头值,在解码后都不得包含 \r 或 \n。
在构建 URL、创建请求头、选择客户端选项,或写入之后可能用于请求重试的日志之前应用这条规则。检查原始字符串,以捕获字面控制字符。按照输入契约完成一次解码,然后再次检查解码后的字符串。不要反复解码直到内容不再变化。反复解码会让清晰的契约变成猜测,也可能产生意外结果。
拒绝修复输入的紧凑验证函数应当类似这样:
validateRequestText(field, value):
if value contains "\r" or "\n":
fail(field + " contains a line break")
return value
在实际代码中,要验证语言真正的字符串值,而不是打印出来的表示。JSON 载荷可能包含 "\n",JSON 解析后它会变成一个换行符。检查两个可见字符反斜杠和 n,会漏掉这个危险值。
不要调用 trim() 后继续执行。大多数实现中的 trim 只会移除两端的内容,因此中间的换行仍然存在。把换行替换为空格则会以另一种方式造成更糟的问题:它改变了请求的操作,却没有给操作人员留下清晰记录,无法知道代理曾请求使用被禁止的语法。应将输入视为无效,并在网络 I/O 之前停止。
拒绝响应应指出具体字段,但不要回显敏感内容。custom header value contains a line break 已经足够让调用者了解问题。回显完整值可能会把凭据或私人数据写入终端记录。
请求头名称需要比请求头值更严格的契约
请求头值可以合理地包含空格和许多可见字符,但请求头名称不应如此。允许代理选择任意名称,会给大多数操作网关带来不必要的歧义。
采用保守的名称规则:只允许 ASCII 字母、数字和连字符,并设置合理的长度限制。除非有明确记录的理由支持某个特定扩展,否则拒绝冒号、空白字符、控制字符和非 ASCII 字节。目标不是模仿互联网上每个宽松的解析器,而是生成一个明确无歧义的请求。
即使表面上的值很普通,下面的输入也必须失败:
{
"name": "X-Trace\r\nAuthorization",
"value": "debug"
}
下面这个也必须失败:
{
"name": "X-Trace: injected",
"value": "debug"
}
冒号属于渲染器,不属于提供的名称。如果网关接受它,一个解析器可能将其视为名称,而另一个解析器可能将其视为完整的字段行。带前导空白的请求头名称也会引发类似分歧。
除了 CRLF,请求头值还需要单独的策略。确定网关是否接受重复请求头、是否允许逗号,以及代理可以覆盖哪些请求头。除非请求头的语义明确允许,否则不要通过拼接文本来合并重复名称。Set-Cookie、Content-Length、Host、Authorization 和转发请求头都需要特殊处理或直接拒绝。我更倾向于让网关完全保留协议请求头和凭据请求头,然后只提供一小组允许代理提供的应用请求头。
有人有时会反对这一建议,因为任意自定义请求头能让通用 HTTP 工具显得更加灵活。灵活性受欢迎,是因为 API 需要多一个请求头时,不必再增加工具选项。但对于带凭据的代理流量,这仍然不是正确的默认设置。像 idempotency_key 或 request_id 这样的命名选项有明确的格式和负责人,而不受限制的请求头集合两者都没有。
只解析一次目标,并让各部分保持结构化
网关接受目标后,它就不再是一个字符串,而是由协议、主机、端口、路径、查询参数,有时还包括用户信息或片段组成的结构化值。使用了解标准的 URL 解析器解析一次,拒绝不允许的组成部分,然后将解析后的值直接交给 HTTP 客户端,不要再把它转换回模板字符串。
具体的允许列表取决于操作。调用某个供应商 API 的工具可能只需要 HTTPS、一个主机名和 443 端口。更宽泛的工具可能允许多个预配置来源。无论哪种情况,都应在创建请求前拒绝嵌入式凭据、片段、格式错误的主机名,以及任何解码后的回车或换行。片段不会进入网络,但接受它会给日志和审批页面增加不必要的混乱。
一个有用的请求边界如下:
input destination
-> decode once
-> reject CR and LF
-> parse URL
-> require HTTPS
-> require allowed host and allowed port
-> reject user info and fragment
-> pass parsed URL to HTTP client
这个顺序是经过考虑的。对原始字符串应用主机允许列表,可能会被类似 https://[email protected]/ 的用户信息绕过。只在库规范化格式错误的值之后检查换行,可能会漏掉早期解析器已经接受的内容。应先按含义解析,同时在任何组件可能将控制字符渲染成语法之前拒绝它们。
不要通过拼接 scheme + "://" + host + path 来构建 HTTP 请求。这个模式会在最需要解析器保证的时刻,把结构化值重新变回文本。如果库要求分别提供主机和路径参数,就按照同样的控制字符规则验证每个参数,并使用库提供的结构化 API。
编码会在各层意见不一致时造成绕过
字面 CRLF 是每个人都会记得的测试。编码后的 CRLF 才能找出真正的漏洞。
代理可能会在 URL 组成部分中发送 %0d%0a。如果网关检查原始字符串,随后又在交给自定义渲染器之前进行 URL 解码,那么检查保护的就是错误的表示形式。换行会在验证之后才出现。如果不同层分别进行解码,双重编码,例如 %250d%250a,也会变得重要。
选择一个明确的规范化边界。例如,只解析一次 JSON,只在 URL 标准要求的地方进行百分号解码,验证渲染器实际要使用的表示形式,并禁止之后再进行通用解码。为每次转换保留测试。这比大型清理器看起来普通,但能让审查者有据可循。
每个代理控制字段的测试表至少应涵盖以下输入:
- 字面
\r、字面\n,以及两者相邻形成的 CRLF - 在接受百分号编码的地方测试
%0d、%0a和%0d%0a - 如果其他组件可能稍后解码,测试
%250d%250a - 包含冒号或空白的请求头名称
- 包含用户信息、意外端口或未批准主机的目标
不要只测试错误消息。断言对于被拒绝的输入,客户端传输从未运行。一个在创建或排队请求后才报告错误的验证器,已经违反了你真正关心的安全属性。
配置格式中还有一个相关陷阱。环境变量、YAML、JSON 和 shell 参数各自有不同的转义规则。一个看起来包含 \n 的测试夹具,可能只是两个无害字符,而生产环境中的 JSON 解码却会把它变成换行符。应通过接受真实代理调用的同一条解析路径来构建测试。
问题通常藏在便利代码里
危险代码往往是在最初的安全审查之后出现的。有人为新 API 添加自定义请求头,让调试代理可以配置,或编写一个为日志重新构造请求的重试函数。每项改动单独看都合理,但合在一起,就可能在原始网关已经使用安全客户端 API 后重新引入字符串渲染。
我发现这类问题时,会从输入一直追踪到套接字,而不是只搜索 header 这个词。查找围绕 Host:、Authorization:、Cookie:、Content-Length:、请求目标、代理命令、原始 HTTP 测试辅助工具和日志重放工具的字符串插值。也要搜索解码函数。入口处的安全验证器作用有限,因为后续路径可能接受另一种表示形式。
一个典型的故障包含四个部分。网关根据允许列表验证目标主机。它将目标的其余部分作为文本保存,交给低级客户端。重试辅助函数对这段文本进行百分号解码,让日志更容易阅读。随后辅助函数为重试操作写入原始请求行,于是 %0d%0aX-Test:%20yes 已经变成了消息语法。
这些代码行中没有任何一行写着“允许请求注入”。真正的问题是缺少一个不变量:当代码渲染 HTTP 消息时,不可信内容绝不能包含换行字符。将这个不变量放进一个共享验证器,并要求测试为原始渲染提供正当理由。
如果使用高级 HTTP 库,就始终使用它提供的高级接口。不要为了支持某个特殊端点而退回原始套接字。如果某个 API 确实要求异常的网络格式,就为该集成提供专用实现和严格的输入模型。不要因此削弱通用路径。
日志应记录被拒绝的意图,但不要重复机密
验证拒绝请求时,记录足够调查的信息:时间、代理会话或进程身份、操作名称、字段类别、拒绝原因,以及所提供值的安全摘要或长度。默认不要保存完整的请求头值。请求头经常包含令牌、个人数据或签名材料,一旦复制到日志中,就会变成另一个机密管理问题。
将尝试执行操作的审计事件与实际执行操作的事件分开。被拒绝的请求不应看起来像一次失败的网络调用,因为它从未到达网络。这一区分有助于事件响应,也能避免操作人员去追查根本没有收到请求的远程服务。
Sallyport 为代理运行保留 Sessions 日志,为调用保留 Activity 日志,两者都从加密的哈希链式审计日志投影而来。这为网关展示被拒绝的操作提供了合理位置,但输入规则仍然必须在 HTTP 渲染之前执行。
离线验证属性在这里很重要。sp audit verify 可以在没有保管库密钥的情况下验证密文上的链,从而确认记录的事件没有被篡改。但它无法告诉你验证器是否过于宽松。事件模式和测试必须让被拒绝的字段类别清晰可见,同时不保留危险数据。
测试序列化后的请求,而不只是验证器
针对 contains('\r') || contains('\n') 的单元测试既必要又不充分。它们只能证明某个函数拒绝两个字符,无法证明实际传输不会收到由解码、默认值、重试或自定义适配器引入的换行符。
使用本地测试服务器或记录传输来捕获传出的请求对象,并在技术栈允许时捕获其序列化字节。通过代理实际使用的同一个工具入口发送允许的输入。断言方法、权限来源、路径和完整请求头集合都正确。然后发送被拒绝的用例,断言记录传输没有看到任何请求。
一个紧凑的测试矩阵,比一长串含糊的安全测试更能发现回归:
field input expected result
custom header value "ok\r\nX-Added: yes" reject before transport
custom header name "X-Mode: injected" reject before transport
destination path "/v1/a%0d%0aX-Added:%20yes" reject after decoding
allowed destination "https://api.example/v1/a" one request, expected host
如果语言适合,可以添加属性测试。围绕每个允许的字符类别生成控制字符,然后断言输入会被拒绝。模糊测试只有在契约明确后才有帮助。模糊测试可以发现奇怪的解析器情况,却无法告诉你任意请求头名称是产品决策还是意外结果。
测试失败时,先检查最终字节,再修改验证器。我见过团队不断添加替换规则,直到某个用例通过,最后才发现客户端折叠了空白或进行了两次解码。正确的修复通常是移除让原始语法成为可能的格式化器或解码器。
人工审批不能为格式错误的操作开脱
逐会话审批回答的是某个代理进程是否可以执行操作。逐次调用审批回答的是这次使用受保护凭据是否值得人工决定。两者都无法让含义不明确的请求变得安全。人无法可靠地检查很长目标或请求头值中的隐藏控制字符,而审批疲劳会把一个微妙的提示变成机械点击。
审批数据也应保持结构化。显示规范化后的主机、方法、路径、命名的请求头类别,并明确标出操作何时会添加凭据。在审批卡出现之前拒绝格式错误的输入。让人审批无效请求,相当于把解析器漏洞搬进了用户界面。
Sallyport 的保管库闸门、会话授权和逐次调用密钥设置,为人工控制提供了不同时间点。HTTP 操作边界仍然需要严格的渲染器,因为审批决定应当覆盖格式正确的操作,而不是一个点击后含义才会改变的字符串。
第一个实际改动不需要全面重写。找出所有接受代理控制的目标文本或自定义请求头的路径,在渲染前无条件拒绝 CRLF,并编写传输测试,证明被拒绝的输入不会发送任何内容。然后移除那些用字符串重新构造 HTTP 的便利代码。这个缺陷总会从那里重新出现。
常见问题
我应该拒绝 HTTP 请求头值中的 CRLF 吗?
在任何可能进入请求语法的位置都拒绝这两个字符,包括目标字符串、请求头名称、请求头值、方法覆盖值、Cookie 片段和代理设置。对百分号解码后的数据也采用同样的规则。必须在插值、解析或可能规范化输入的库调用之前完成验证。
安全的 HTTP 库能防止请求走私吗?
常规 HTTP 客户端库会有所帮助,但不能取代边界验证。你的网关可能会在输入到达库之前解码、组合或格式化代理输入,而且某些输入会先影响另一个解析器。在边界拒绝换行符,可以明确规定预期行为。
为什么回车符会让 API 请求变得危险?
不能。换行符会分隔 HTTP 消息语法,因此允许它们出现在目标或请求头字段中,可能会把数据变成另一个字段或请求组件。存在漏洞的代码仍可能发送语法有效的请求,所以这类问题容易在常规测试中被忽略。
百分号编码的 CRLF 能绕过验证吗?
解析器应先完成一次解码,再验证解码后的值。如果它接受百分号编码的换行符,而后续层又将其解码,验证就发生得太早了。让解码后的值和最终渲染的请求值保持分离,避免后续代码再次解码。
应该如何验证自定义请求头名称?
请求头名称应使用小型允许列表,例如只允许字母、数字和连字符,然后拒绝其他字符。这样可以防止换行符、插入冒号、空白字符技巧,以及不同组件产生不同解释。不要因为请求头值已经过检查,就接受代理任意提供的请求头名称。
仅靠 URL 解析就能保护代理目标吗?
不能。URL 解析器可以告诉你主机和路径的边界在哪里,但无法决定某个目标是否被允许。先解析,再自行规定协议、主机、端口、用户信息、片段和解码后控制字符的规则。
操作网关发现换行符时应该怎么做?
在创建任何请求对象之前拒绝它。不要对换行符执行去除首尾空白、替换为空格或静默删除,因为这些修复会隐藏原始操作,还可能改变已签名或经过身份验证的输入含义。返回能够指出具体字段的错误,但不要回显机密。
验证应该在 URL 解码前还是解码后进行?
检查原始输入,然后检查一次解码后的表示,最后在测试中检查最终序列化的字节。第一次检查可以发现明显的恶意输入,解码后的检查可以发现编码绕过,序列化测试则能发现不安全的格式化代码。单独依赖其中一种检查,都可能让后续转换改变值。
凭据注入会导致请求注入吗?
即使凭据由受信任代码添加,请求周围的内容仍可能由代理控制。恶意请求头值可以在注入的凭据旁边放入第二个请求头,恶意目标则可以重定向带凭据的请求。保护机密和保护请求结构是两项不同的工作。
哪些测试用例可以发现 HTTP 请求头注入?
对每个允许输入的位置测试字面回车符、字面换行符、CRLF、百分号编码形式、双重编码形式、前导空白,以及请求头名称中的冒号。断言网关会在网络 I/O 之前拒绝每种情况。然后检查允许输入对应的捕获请求,确认其中只有预期字段。