Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions crates/trusted-server-core/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, S
/// Chunk size used for streaming content through the rewrite pipeline.
const STREAMING_CHUNK_SIZE: usize = 8192;

/// Fallback `Content-Type` for image-like responses missing an origin type.
const IMAGE_FALLBACK_CONTENT_TYPE: &str = "application/octet-stream";

#[derive(Deserialize)]
struct ProxySignReq {
url: String,
Expand Down Expand Up @@ -256,7 +259,7 @@ fn finalize_proxied_response(
);
}

// Image handling: set generic content-type if missing and log pixel heuristics
// Image handling: set a valid fallback content type if missing and log pixel heuristics
let req_accept_images = req
.get_header(HEADER_ACCEPT)
.and_then(|h| h.to_str().ok())
Expand All @@ -265,7 +268,7 @@ fn finalize_proxied_response(

if ct.starts_with("image/") || req_accept_images {
if beresp.get_header(header::CONTENT_TYPE).is_none() {
beresp.set_header(header::CONTENT_TYPE, "image/*");
beresp.set_header(header::CONTENT_TYPE, IMAGE_FALLBACK_CONTENT_TYPE);
}

// Heuristics to log likely tracking pixels without altering response
Expand Down Expand Up @@ -344,7 +347,7 @@ fn finalize_proxied_response_streaming(

if ct.starts_with("image/") || req_accept_images {
if beresp.get_header(header::CONTENT_TYPE).is_none() {
beresp.set_header(header::CONTENT_TYPE, "image/*");
beresp.set_header(header::CONTENT_TYPE, IMAGE_FALLBACK_CONTENT_TYPE);
}

let mut is_pixel = false;
Expand Down Expand Up @@ -685,9 +688,9 @@ async fn proxy_with_redirects(
/// - Proxies the decoded URL via a dynamic backend derived from scheme/host/port.
/// - If the response `Content-Type` contains `text/html`, rewrites the HTML creative
/// (img/srcset/iframe to first-party) before returning `text/html; charset=utf-8`.
/// - If the response is an image or the request `Accept` indicates images, ensures a
/// generic `image/*` content type if origin omitted it, and logs likely 1×1 pixels
/// using simple size/URL heuristics. No special response (still proxied).
/// - If the response is an image or the request `Accept` indicates images, ensures an
/// `application/octet-stream` content type if origin omitted it, and logs likely 1×1
/// pixels using simple size/URL heuristics. No special response (still proxied).
///
/// # Errors
///
Expand Down Expand Up @@ -1168,7 +1171,7 @@ mod tests {
copy_proxy_forward_headers, handle_first_party_click, handle_first_party_proxy,
handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, is_host_allowed,
rebuild_response_with_body, reconstruct_and_validate_signed_target, redirect_is_permitted,
ProxyRequestConfig, SUPPORTED_ENCODINGS,
ProxyRequestConfig, IMAGE_FALLBACK_CONTENT_TYPE, SUPPORTED_ENCODINGS,
};
use crate::error::{IntoHttpResponse, TrustedServerError};
use crate::test_support::tests::create_test_settings;
Expand Down Expand Up @@ -1625,8 +1628,9 @@ mod tests {

// --- Finalization path tests (no network) ---

// Access the finalize function within the crate for testing
// Access the finalize helpers within the crate for testing
use super::finalize_proxied_response as finalize;
use super::finalize_proxied_response_streaming as finalize_streaming;

#[test]
fn html_response_is_rewritten_and_content_type_set() {
Expand Down Expand Up @@ -1728,20 +1732,36 @@ mod tests {
}

#[test]
fn image_accept_sets_generic_content_type_when_missing() {
fn image_accept_sets_fallback_content_type_when_missing() {
let settings = create_test_settings();
let beresp = Response::from_status(StatusCode::OK).with_body("PNG");
let mut req = Request::new(Method::GET, "https://edge.example/first-party/proxy");
req.set_header(HEADER_ACCEPT, "image/*");
let out = finalize(&settings, &req, "https://cdn.example/pixel.gif", beresp)
.expect("finalize should succeed");
// Since CT was missing and Accept indicates image, it should set generic image/*
// Since CT was missing and Accept indicates image, it should set a valid fallback.
let ct = out
.get_header(header::CONTENT_TYPE)
.expect("Content-Type header should be present")
.to_str()
.expect("Content-Type should be valid UTF-8");
assert_eq!(ct, "image/*");
assert_eq!(ct, IMAGE_FALLBACK_CONTENT_TYPE);
}

#[test]
fn streaming_image_accept_sets_fallback_content_type_when_missing() {
let beresp = Response::from_status(StatusCode::OK).with_body("GIF");
let mut req = Request::new(Method::GET, "https://edge.example/first-party/proxy");
req.set_header(HEADER_ACCEPT, "image/*");

let out = finalize_streaming(&req, "https://cdn.example/pixel.gif", beresp);
let ct = out
.get_header(header::CONTENT_TYPE)
.expect("Content-Type header should be present")
.to_str()
.expect("Content-Type should be valid UTF-8");

assert_eq!(ct, IMAGE_FALLBACK_CONTENT_TYPE);
}

#[test]
Expand Down
6 changes: 3 additions & 3 deletions docs/guide/first-party-proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ GET /first-party/proxy?tsurl=https://example.com/ad.html&tstoken=signature
4. **Processes** response based on content type:
- **HTML** (`text/html`) - Rewrites all URLs, returns `text/html`
- **CSS** (`text/css`) - Rewrites `url()` values, returns `text/css`
- **Images** - Detects pixels, sets `image/*` if missing
- **Images** - Detects pixels, sets `application/octet-stream` if missing
- **Other** - Passthrough without modification

**Example**:
Expand Down Expand Up @@ -291,12 +291,12 @@ For the detailed signing algorithm, validation steps, and security notes, see [P

**Triggers**:

- Response `Content-Type: image/*`, OR
- Response `Content-Type` starts with `image/`, OR
- Request `Accept` header contains `image/`

**Process**:

1. Set `Content-Type: image/*` if missing
1. Set `Content-Type: application/octet-stream` if missing
2. Detect likely pixels with heuristics:
- `Content-Length` ≤ 256 bytes
- URL contains `/pixel`, `/p.gif`, `/1x1`, `/track`
Expand Down
Loading