Opening the image URL directly displays it correctly. Requesting the same address from the server also returns HTTP 200, and the response body is an actual JPEG. However, the image element in the web page encounters a loading error.

To explain this phenomenon, we must break down the success of a request into several stages: whether bytes were received, whether they can be interpreted as an image, and whether the current document is allowed to use that response are separate conditions. Adding an image proxying path requires newly determining how much responsibility the server takes for each of these conditions.

This article examines that boundary from the perspectives of CORP, DNS rebinding, asynchronous streaming, and image decoding. Code examples are based on Python 3.11 or higher and HTTPX 0.28.1. The connection layer was reviewed against httpcore 1.0.9, and image processing explanations refer to the Pillow 13.0.0 official documentation. The documents were verified on November 4, 2026.

1. HTTP 200 Does Not Include Browser Usage Permission

Assume the response is as follows.

http
HTTP/1.1 200 OK
Content-Type: image/jpeg
Cross-Origin-Resource-Policy: same-site

If this response is loaded as an image by a document on a different site, the browser may block its use. CORP is a policy that restricts which origins are allowed to use a resource provider’s response. The Fetch standard defines a separate CORP check for responses to no-cors requests. The no-cors request mode does not mean that all security checks on the response are omitted. CORP definition in the Fetch standard

When diagnosing, it is better to separate the following three layers.

Layer to CheckMeaning of SuccessPossible Remaining Failures
HTTP ExchangeStatus code and response body receivedContent is an error page or does not match permissions
Format InterpretationImage decoder identifies the formatCorrupted frames, excessive pixel count, browser-specific support differences
Usage in DocumentResponse is usable in the security context of that documentCORP, document resource policies, etc.

CORS and CORP must also be distinguished. CORS is a protocol for sharing cross-origin responses, while CORP restricts the use of specific cross-origin, cross-site resources. General cross-origin image display does not always require CORS permission, but CORP can prevent that usage. Even if changing the image request to a CORS request by adding crossorigin, the origin server must still provide the necessary permission response headers. CORS protocol in the Fetch standard

The observation that the server successfully downloaded the file narrows the root cause to network and file format. Over-interpreting this as evidence that browser policies were also cleared causes the actual cause to be missed.

2. The Proxy Server Becomes the Issuer of a New Response

Choosing a structure that receives permitted external resources and serves them as previews changes the request path as follows.

text
브라우저
  ├─ 원본 이미지 직접 요청
  └─ 실패 시 인증된 미리보기 요청
        → 목적지 정책 검사
        → 검증된 주소로 연결
        → 제한된 크기의 본문 수신
        → 허용한 이미지 형식 검증
        → 새 응답 생성

What the proxy server returns is a new HTTP response issued from its own origin. Therefore, response headers must be explicitly constructed. Passing the original Set-Cookie header mixes the external server’s state configuration into the proxy service’s response. The original Content-Type cannot replace the proxy’s validation results. The MIME type and cache policy to return should be defined by the proxy path’s contract.

The direction of the request is similar. The user’s session cookies and service authentication headers are not sent to the origin image server. The action of authenticating the preview request and the action of forwarding credentials to an external destination are independent of each other.

This structure involves a shift in permissions. Because the server executes the external request on behalf of the browser, the server’s network reachability is used. Even an authenticated user does not automatically gain the permission to request an internal service or metadata address via an image URL.

3. DNS Checks Must Be Coupled to Actual Connections

Implementing a solution that resolves the URL host once and checks for private addresses seems natural. However, if the HTTP client re-resolves the same host after the check, a time gap occurs.

text
t0: 검증 단계의 DNS 응답 → 허용한 공인 주소
t1: 연결 단계의 DNS 응답 → 허용하지 않은 내부 주소

This is a TOCTOU problem where the validation result fails to constrain the actual socket destination. It must be coupled so that after validating the destination, the connection is made to that address. OWASP also considers DNS re-resolution and redirects as items to review in SSRF defense. SSRF Defense Guide

The invariant required here is simple.

The IP address selected when making a new connection must belong to the set of addresses validated against the policy applied to that connection.

If a host returns multiple addresses, IPv4 and IPv6 must be handled together. Whether to reject the entire request if some addresses are denied, or to leave only validated addresses as candidates, is an explicit policy. Conservative implementations may reject mixed responses entirely, but in environments like split DNS, legitimate requests might fail.

While fixing the IP address to connect to, the meaning of the original host must be preserved.

Connection ElementValue to Use
TCP Connection TargetValidated numeric IP
HTTP HostHost and required port from the original URL
TLS SNIOriginal host name
Certificate Validation TargetOriginal host name

An implementation that replaces the URL itself with an IP address can simultaneously change virtual host routing and TLS validation. Layer separation is required: fix only the network connection target while maintaining host identification in upper protocols.

Redirects follow the same principle. Disabling automatic redirects makes it easier to keep destination policies simple. If redirects are permitted, URL policies and new connection IP policies must be re-applied at each hop, and the number of hops and total time must be limited. The validation result of the first address does not carry over to the next address.

It is also crucial not to reuse internal address exceptions required for inter-service communication directly for external preview clients. Policies must be granted per function. Even when using the same network utility, the permitted scopes for collecting public resources and calling internal services must be separate.

4. Distinguish HTTPX’s Transport from httpcore’s Network Backend

HTTPX provides a Transport API at the request/response boundary. httpcore handles connection pools and actual network behavior, and allows connecting custom Network Backends. Fixing the destination IP takes effect only after descending to the point where the actual connection is created. HTTPX Transport API, httpcore Network Backend

In real implementations, distinguish between public extension points and library internal structures. For example, replacing _pool._network_backend within HTTPX 0.28.1’s Transport depends on internal attributes. Gather dependencies in a small adapter, pin the version, and verify that the actual connection path has not changed upon upgrade. The existence of a public Network Backend interface does not make every assembly method of the higher-level library a stable contract.

Environment proxies are also part of the connection model. Automatically using HTTP_PROXY or HTTPS_PROXY might connect to a proxy instead of the directly validated server. If designed assuming a direct connection, enforce that premise with trust_env=False etc. In environments requiring a proxy, the proxy’s DNS resolution and destination control must be designed separately. HTTPX Environment Variable Documentation

Sharing connection pools offers benefits in connection reuse and TLS costs. Simultaneously, consider per-user authentication states, destination policies, and how to handle existing connections when policies change. Per-request clients are simpler to isolate but reduce connection reuse benefits. If sharing pools for performance, first decide which security contexts are shared.

5. Response Size, Total Time, and Decoding Costs Are Separate Budgets

Content-Length can be used for pre-judgment but cannot replace cumulative incoming byte checks. The header may be missing, or it may differ from the actual body size to be processed due to HTTP content compression.

In HTTPX’s streaming API, size can be limited while reading chunks. The code below shows only the portion limiting body size and waiting time, assuming a client with the destination policy already applied. It is not the complete proxy code implementing URL validation or DNS pinning.

python
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 and 15 seconds are design examples. Adjust based on resource size distribution and the number of concurrent requests the server can handle.

HTTPX’s read timeout applies to the time waiting for the next chunk. If small amounts of data keep arriving, the total request time can become long. Wrapping the request and body reception with asyncio.timeout() provides a separate total wait budget. However, it does not force-terminate synchronous processing that does not cooperate with cancellation at a specified time. HTTPX timeout documentation, Python asyncio timeout documentation

The timing of applying size limits must also be considered. aiter_bytes() provides bytes with HTTP content decoding applied. The code above limits the size of the body stored by the application but does not limit temporary memory usage inside the decoder to the same value. A small chunk_size is also not a hard cap on total process memory. HTTPX Streaming Response Documentation

If strict resource limits are needed, distinguish bytes at the transport layer, bytes after HTTP decompression, and decoded pixels of the image. A whole-memory budget holds only when concurrency is limited alongside per-request limits.

text
메모리 수요 ≈ 동시 요청 수 ×
    (본문 버퍼 + 디코딩된 픽셀 + 코덱 작업 공간)

This equation is an approximate budget model. It does not imply exact upper limits because buffer copies, connection states, and codec-specific behaviors add to it.

6. Pillow’s Identification, Verification, and Decoding Are Different Stages

In image processing, the scope of success for a single function must also be read precisely.

Pillow’s Image.open() uses lazy processing. Identifying the file format does not mean all pixels have been decoded. verify() is also an API that checks for issues in the file without fully decoding the image data. Interpreting this as a guarantee of complete decoding for all formats and frames leads to overestimating the verification scope. Pillow Image documentation, Image.verify documentation

Therefore, it is better to determine the preview path’s contract first. Costs and verification scopes vary based on whether only allowed raster formats are accepted, whether a single static frame is returned, or whether full animation is preserved.

For static previews, one can limit width, height, and total pixels before decoding the required frame and re-encoding to a specified format. If animation is preserved, frame count and cumulative processing volume are included in the budget. Re-encoding entails processing costs and representation changes, so it is difficult to see it as a default to unconditionally apply to all images.

Running the decoder in a separate thread can reduce event loop delays. However, do not assume that thread work already running terminates immediately even if the coroutine’s wait is cancelled. If killable execution time isolation is needed, consider separate processes and OS-level resource limits.

7. Recovery Paths Require Termination Conditions and Request Generations

The image’s onerror event does not provide sufficient information for root cause classification. Policy blocks, expired addresses, format errors, and network failures can enter as the same event.

Therefore, design a state transition to try server-side proxying when direct loading fails, but avoid concluding that the failure has a specific cause.

text
직접 요청 ─성공→ 표시
    │ 실패
    ▼
중계 요청 ─성공→ 표시
    │ 실패
    ▼
오류 안내와 사용자가 선택하는 재시도

If session recovery is added, target only the API addresses owned by the service, and separately limit the number of automatic attempts. If every external image failure triggers session renewal, unrelated outages propagate to session processing.

Another boundary is the temporal lifetime of a request. While waiting for image A’s recovery after a failure, the screen may change to image B. If A’s late-completing task re-requests B or clears B’s error state, the state becomes mixed. Use source-specific component lifetimes, generation numbers, and cancellation signals to prevent old results from being applied.

Arbitrarily modifying signed original URLs for recovery purposes should also be avoided. Retry identifiers can be appended to the proxy request itself, and the original URL passed as a separate parameter value. Preserving the original string and validating the destination are conditions that must be met together.

8. Tests Must Descend to the Layer Where Actual Policies Are Enforced

Tests mocking the destination validation function entirely fail to verify that validation results and socket connection targets match. Tests using HTTPX’s MockTransport to return fake HTTP responses also do not execute underlying network connections.

To verify connection boundaries, record the actual addresses requested in the underlying network backend, or use httpcore’s mock streams to execute behavior after the response. For example, verify that after receiving a redirect from a public address, the subsequent internal address connection is denied before the actual socket is created.

Validation SituationResult to Observe
Address change between DNS check and connectionIrrelevant re-resolution path cannot change the connection
Response with mixed public/unauthorized addressesHandled according to specified address set policy
Redirect from public to internal addressDenied before internal socket connection
Chunk exceeding size limitDenied before being added to buffer
Slow but unbroken responseTerminated according to total wait budget
HTML masquerading as MIME type or corrupted imageNot issued as image response
Old recovery result arriving after image source changeNot applied to current image state

Browser verification is also required. The image element’s complete can be true even after a loading failure, so do not determine success based on it alone. For raster images, verify conditions like naturalWidth > 0 alongside the actual screen, and also check paths that create separate requests like zoomed views.

Finally, even if transmission and rendering are normal, the question of whether the image’s content matches the topic remains. Reliability of network destinations, suitability of file formats, and semantic appropriateness of content are separate verification targets.

The design criteria for external image proxying are not based on the success of any single function. At each boundary, we must be able to explain whether it connected to a validated destination, processed within set budgets, issued only permitted expressions as new responses, and prevented old recovery results from altering the current state.