直接打开图片 URL 是正常的。从服务器请求同一地址也返回 HTTP 200,且响应体是真实的 JPEG 数据。然而,在网页的图片元素中却发生加载错误。
要解释这一现象,需要将请求的成功划分为多个阶段。是否接收到字节数据、能否解析为图像、当前文档能否使用该响应,各自对应不同的条件。若添加图像中继路径,则还需重新确定服务器在哪些范围内承担责任。
本文从 CORP、DNS 重绑定、异步流式处理以及图像解码的角度探讨这一边界。代码示例以 Python 3.11 及以上版本和 HTTPX 0.28.1 为基准。连接层参考了 httpcore 1.0.9,图像处理说明参考了 Pillow 13.0.0 官方文档。文档确认日期为 2026 年 11 月 4 日。
1. HTTP 200 不包含浏览器使用许可
假设响应如下:
HTTP/1.1 200 OK
Content-Type: image/jpeg
Cross-Origin-Resource-Policy: same-site若在其他站点的文档中将该响应作为图像加载,浏览器可能会阻止其使用。CORP 是资源提供方限制其响应可在哪些源中使用的策略。Fetch 标准为 no-cors 请求的响应定义了单独的 CORP 检查。no-cors 请求模式并不意味着省略响应的所有安全检查。Fetch 标准中的 CORP 定义
诊断时,最好将以下三层分离。
| 确认层级 | 成功的含义 | 仍然可能的失败 |
|---|---|---|
| HTTP 交换 | 接收到了状态代码和响应体 | 内容为错误页面,或不匹配权限 |
| 格式解析 | 图像解码器识别格式 | 损坏的帧、过度的像素数量、浏览器支持的差异 |
| 文档内使用 | 可在该文档的安全上下文中使用响应 | CORP、文档的资源策略等 |
还需区分 CORS 和 CORP。CORS 是共享跨源响应的协议,而 CORP 限制特定跨源、跨站资源的使用。普通的跨源图像显示并不总是需要 CORS 许可,但 CORP 可以阻止其使用。即使将图像附加 crossorigin 以转换为 CORS 请求,原始服务器也必须提供所需的许可响应。Fetch 标准中的 CORS 协议
服务器下载成功的观察结果是缩小网络和文件格式范围的依据。如果将其扩大解释为通过浏览器政策的证据,就会忽略原因。
2. 中继服务器成为新的响应发布者
若选择一种结构,即从服务器获取允许的外部资源并提供预览,则请求路径将变为如下所示。
브라우저
├─ 원본 이미지 직접 요청
└─ 실패 시 인증된 미리보기 요청
→ 목적지 정책 검사
→ 검증된 주소로 연결
→ 제한된 크기의 본문 수신
→ 허용한 이미지 형식 검증
→ 새 응답 생성中继服务器返回的是从其自身源发布的新 HTTP 响应。因此,必须显式地构建响应头。如果转发原始的 Set-Cookie,就会将外部服务器的状态设置混入中继服务的响应中。原始的 Content-Type 也不能替代验证结果。要返回的 MIME 类型和缓存策略必须根据中继路径的契约来确定。
请求方向也是相同的。用户的会话 cookie 和服务认证头不会发送到原始图像服务器。验证预览请求的行为与向外部目的地传递凭证的行为是相互独立的。
此结构中存在权限的移动。由于服务器代替浏览器执行了原本由浏览器执行的外部请求,因此使用了服务器的网络可达范围。即使是已认证的用户,也不意味着获得了将内部服务或元数据地址作为图像 URL 请求的权限。
3. DNS 检查必须与实际连接结合
解析 URL 的主机一次并检查其是否为私有地址的实现是自然的。然而,如果检查后 HTTP 客户端再次解析同一主机,就会产生时间差。
t0: 검증 단계의 DNS 응답 → 허용한 공인 주소
t1: 연결 단계의 DNS 응답 → 허용하지 않은 내부 주소这是一个 TOCTOU 问题,即验证结果无法约束实际套接字的目的地。必须将验证目的地与连接到该地址的行为绑定。OWASP 也将 DNS 重解析和重定向视为 SSRF 防御中需要审查的对象。SSRF 防御指南
此处所需的不变条件是简单的。
创建新连接时选择的 IP 必须属于已应用连接策略进行验证的地址集合。
如果主机返回多个地址,则必须同时处理 IPv4 和 IPv6。当部分地址是拒绝对象时,是拒绝整个请求,还是仅保留已验证的地址作为候选,也是显式的策略。保守的实现可能会整体拒绝混合响应,但在分割 DNS 等环境中,正常请求也可能失败。
在固定连接 IP 的同时,必须保留原始主机的含义。
| 连接的要素 | 应使用的值 |
|---|---|
| TCP 连接目标 | 已验证的数字 IP |
HTTP Host | 原始 URL 的主机和所需的端口 |
| TLS SNI | 原始主机名 |
| 证书验证对象 | 原始主机名 |
将 URL 本身替换为 IP 地址的实现可能会同时改变虚拟主机路由和 TLS 验证。需要分层,即仅固定网络连接目标,而保留上层协议的主机标识。
重定向也遵循相同的原则。如果禁用自动跟踪,则更容易保持目的地政策的简单。如果允许跟踪,则必须重新应用每次重定向的 URL 策略和新连接的 IP 策略,并限制重定向次数和总时间。第一个地址的验证结果不会继承到下一个地址。
同样重要的是,不要将服务间通信所需的内部地址例外原样复用于外部预览客户端。策略应按功能授予。即使使用相同的网络实用工具,公开资源收集和内部服务调用的允许范围也必须是可分离的。
4. 区分 HTTPX 的 Transport 和 httpcore 的 Network Backend
HTTPX 提供了请求响应边界的 Transport API。httpcore 处理连接池和实际的网络行为,并可以连接自定义的 Network Backend。固定目的地 IP 必须一直下达到实际连接创建点才能生效。HTTPX Transport API、httpcore Network Backend
在实际实现中,必须区分公开扩展点和库的内部结构。例如,在 HTTPX 0.28.1 的 Transport 内部替换 _pool._network_backend 的方式依赖于内部属性。必须通过小适配器来收集依赖范围,固定版本,并在升级时验证实际连接路径是否未改变。存在公开 Network Backend 接口这一事实,并未使上层库的所有组装方法都成为稳定契约。
环境代理也包含在连接模型中。如果自动使用 HTTP_PROXY 或 HTTPS_PROXY,则可能连接到代理而非直接验证的服务器。如果设计基于直接连接,则必须通过 trust_env=False 等强制该前提。在需要代理的环境中,必须单独设计代理执行的 DNS 解析和目的地控制。HTTPX 环境变量文档
共享连接池在连接复用和 TLS 成本方面具有优势。同时,必须考虑每个用户的认证状态、目的地策略,以及策略更改时对现有连接的处理。按请求隔离客户端虽然简单,但减少了连接复用的优势。如果为了性能而共享池,则必须首先确定哪些安全上下文可以共享。
5. 响应大小、总时间和解码成本是不同的预算
Content-Length 可用于预先判断,但不能替代累计接收量检查。由于可能没有该头或由于 HTTP 内容压缩,它可能与实际处理的正文大小不同。
在 HTTPX 的流式 API 中,可以在读取块时限制大小。下面的代码仅展示了在已经传入应用了目的地政策的客户端的前提下限制正文大小和等待时间的部分。这不是实现 URL 验证或 DNS 固定的完整代理代码。
import asyncio
import httpx
class PreviewTooLarge(Exception):
pass
async def read_bounded(
client: httpx.AsyncClient,
url: str,
*,
max_bytes: int = 10 * 1024 * 1024,
budget: float = 15.0,
) -> bytes:
async with asyncio.timeout(budget):
async with client.stream("GET", url) as response:
response.raise_for_status()
body = bytearray()
async for chunk in response.aiter_bytes(
chunk_size=64 * 1024
):
if len(body) + len(chunk) > max_bytes:
raise PreviewTooLarge()
body.extend(chunk)
return bytes(body)10 MiB 和 15 秒是设计示例。必须根据资源大小分布和服务器可承受的并发请求数进行调整。
HTTPX 的 read timeout 适用于等待下一个块的时间。如果数据持续少量到达,则整个请求时间可能会变长。如果使用 asyncio.timeout() 包裹请求和正文接收,则可以设置单独的总等待预算。但是,这并不强制在指定时间终止不配合取消的同步处理。HTTPX timeout 文档、Python asyncio timeout 文档
还必须关注大小限制的适用时机。aiter_bytes() 提供应用了 HTTP 内容解码的字节。上面的代码限制了应用程序保存的正文大小,但并没有将解码器内部的临时内存使用也限制为相同的值。较小的 chunk_size 也不是整个进程内存的上限。HTTPX 流式响应文档
如果需要严格的资源限制,则必须分别区分传输层的字节、HTTP 解压后的字节以及图像解码后的像素。除了单个请求的限制外,还必须限制并发处理量,才能使整体内存预算成立。
메모리 수요 ≈ 동시 요청 수 ×
(본문 버퍼 + 디코딩된 픽셀 + 코덱 작업 공간)该公式是大致的预算模型。由于会添加缓冲区复制、连接状态以及编解码器的行为,因此并不意味着精确的上限。
6. Pillow 的识别、验证、解码是不同的阶段
在图像处理中,也必须准确地读取单一函数的成功范围。
Pillow 的 Image.open() 使用延迟处理。识别出文件格式并不意味着已解码所有像素。verify() 也是一个在不完整解码图像数据的情况下检查文件问题的 API。如果将其解释为保证所有格式和帧的完全解码,就会高估验证范围。Pillow Image 文档、Image.verify 文档
因此,最好首先确定预览路径的契约。根据是只接受允许的栅格格式、返回静态单帧还是保留整个动画,成本和验证范围会有所不同。
对于静态预览,可以限制宽、高和总像素,然后解码所需的帧,并以规定的格式重新编码。如果保留动画,则帧数和累计处理量也计入预算。由于重新编码伴随处理成本和表示变更,因此难以将其视为无条件应用于所有图像的默认值。
在单独的线程中运行解码器可以减少事件循环的延迟。然而,即使协程的等待被取消,也不应假设已经运行的线程任务会立即结束。如果需要可强制终止的执行时间隔离,则必须审查单独进程和操作系统的资源限制。
7. 恢复路径需要终止条件和请求的世代
图像的 onerror 没有提供足以进行分类的信息。政策阻断、过期的地址、格式错误、网络故障可能会作为同一事件进入。
因此,如果直接加载失败,则应有一个状态转移来限制性地尝试服务器中继,但最好不将失败断定为特定原因。
직접 요청 ─성공→ 표시
│ 실패
▼
중계 요청 ─성공→ 표시
│ 실패
▼
오류 안내와 사용자가 선택하는 재시도如果添加会话恢复,则必须仅以服务拥有的 API 地址为对象,并单独限制自动尝试次数。如果所有外部图像失败都启动认证更新,则无关的故障会扩散到会话处理中。
另一个边界是请求的时间寿命。在图像 A 失败并等待恢复期间,屏幕可能会切换到图像 B。如果延迟完成的 A 的工作重新请求 B,或擦除 B 的错误状态,则状态会混合。必须使用每个源的组件寿命、世代编号、取消信号等来防止旧结果的适用。
还必须避免为了恢复而任意更改签名的原始 URL。可以将重试标识符附加到其中继请求,并将原始 URL 作为单独的参数值传递。保留原始字符串和验证目的地必须同时满足。
8. 测试必须下达到政策实际执行的层级
完全模拟目的地验证函数的测试无法确认验证结果与套接字连接目标是否一致。使用 HTTPX 的 MockTransport 代替 HTTP 响应的测试也不会执行底层网络连接。
为了验证连接边界,可以在底层网络后端记录实际请求的地址,或者使用 httpcore 的模拟流使响应后的操作继续进行。例如,在从公开地址接收重定向响应后,检查下一个内部地址的连接是否在实际创建套接字之前被拒绝。
| 验证情况 | 必须观察的结果 |
|---|---|
| DNS 检查与连接之间的地址更改 | 与检查无关的重解析路径无法改变连接 |
| 包含公有和私有地址混合的响应 | 按照规定的地址集合政策进行处理 |
| 从公有地址重定向到内部地址 | 在内部套接字连接之前被拒绝 |
| 超过大小限制的块 | 在添加到缓冲区之前被拒绝 |
| 缓慢但未中断的响应 | 根据总等待预算终止 |
| 伪装成 HTML 或损坏图像的 MIME 类型 | 未作为图像响应发布 |
| 图像源更改后到达的旧恢复结果 | 未应用到当前图像状态 |
还需要浏览器验证。由于图像元素的 complete 在加载失败后仍可能为 true,因此不能仅凭此判断成功。如果是栅格图像,则必须结合 naturalWidth > 0 等条件和实际屏幕进行确认,还必须确认生成单独请求的路径(如放大查看)。
最后,即使传输和渲染正常,图像内容是否符合主题仍是一个问题。网络目的地的可信度、文件格式的适用性、内容的语义适用性是各自不同的验证对象。
外部图像中继的设计标准不在于任何一个函数的成功。必须能够在每个边界解释:是否连接到了验证过的目的地、是否在规定的预算内处理、是否仅将允许的表现形式作为新响应发布、旧的恢复结果是否改变了当前状态。




