diff --git a/CHANGELOG.md b/CHANGELOG.md index 4152df917..b6c1aee9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- `/first-party/sign` now rejects valid targets outside `proxy.allowed_domains` before minting a proxy token. The creative runtime keeps image and iframe assignments blocked after this `403` policy response instead of loading the rejected URL directly; fetch-time checks still cover the initial target and every redirect. - Reserved the complete admin namespace at the publisher-fallback boundary. Percent-encoded separators (`/_ts/admin%2Fec`, `%2f`, and double-encoded forms) matched the `^/_ts/admin` Basic-auth handler but escaped the literal-slash namespace check, so an authenticated request fell through to publisher fallback and forwarded its `Authorization` header and body to the publisher origin. The reservation now spans the whole `/_ts/admin` prefix plus the retired `/admin/keys` aliases — including trailing, descendant, and encoded-separator forms — evaluated on the raw path and on each of its bounded percent-decodings, so multi-encoded separators such as `/admin%252Fkeys/rotate` cannot survive to fallback for a proxy or origin to decode again, and applies to every adapter. - Validate synthetic ID format on inbound values from the `x-synthetic-id` header and `synthetic_id` cookie; values that do not match the expected format (`64-hex-hmac.6-alphanumeric-suffix`) are discarded and a fresh ID is generated rather than forwarded to response headers, cookies, or third-party APIs diff --git a/crates/trusted-server-core/src/error.rs b/crates/trusted-server-core/src/error.rs index 2804afeca..69cfdd68a 100644 --- a/crates/trusted-server-core/src/error.rs +++ b/crates/trusted-server-core/src/error.rs @@ -76,8 +76,8 @@ pub enum TrustedServerError { #[display("Forbidden: {message}")] Forbidden { message: String }, - /// A redirect destination was blocked by the proxy allowlist. - #[display("Redirect to `{host}` blocked: host not in proxy allowed_domains")] + /// A proxy host was blocked by `proxy.allowed_domains`. + #[display("Proxy host `{host}` blocked: host not in proxy.allowed_domains")] AllowlistViolation { host: String }, /// Settings parsing or validation failed. diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 14485328a..59017cf97 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -1280,11 +1280,11 @@ fn append_ec_id(req: &Request, target_url_parsed: &mut url::Url) { } } -/// Returns `true` when a redirect to `host` should be followed. +/// Returns `true` when `host` is permitted by the proxy host policy. /// /// When `allowed_domains` is empty every host is permitted (open mode). /// When non-empty the host must match at least one pattern via [`is_host_allowed`]. -fn redirect_is_permitted>(allowed_domains: &[S], host: &str) -> bool { +fn is_host_permitted>(allowed_domains: &[S], host: &str) -> bool { allowed_domains.is_empty() || allowed_domains .iter() @@ -1352,7 +1352,7 @@ async fn proxy_with_redirects( })); } - if !redirect_is_permitted(redirect_policy.allowed_domains, host) { + if !is_host_permitted(redirect_policy.allowed_domains, host) { log::warn!( "request to `{}` blocked: host not in proxy allowed_domains", host @@ -1513,7 +1513,7 @@ async fn proxy_with_redirects( })); } }; - if !redirect_is_permitted(redirect_policy.allowed_domains, next_host) { + if !is_host_permitted(redirect_policy.allowed_domains, next_host) { log::warn!( "redirect to `{}` blocked: host not in proxy allowed_domains", next_host @@ -1673,7 +1673,8 @@ pub async fn handle_first_party_click( /// /// # Errors /// -/// Returns an error if JSON parsing fails, the URL cannot be parsed, or the URL uses an unsupported scheme. +/// Returns an error if JSON parsing fails, the URL cannot be parsed, the URL uses an +/// unsupported scheme, the URL lacks a host, or the host violates `proxy.allowed_domains`. pub async fn handle_first_party_proxy_sign( settings: &Settings, _services: &RuntimeServices, @@ -1748,6 +1749,21 @@ pub async fn handle_first_party_proxy_sign( })); } + let host = parsed.host_str().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "missing host".to_string(), + }) + })?; + if !is_host_permitted(&settings.proxy.allowed_domains, host) { + log::warn!( + "sign request for `{}` blocked: host not in proxy.allowed_domains", + host + ); + return Err(Report::new(TrustedServerError::AllowlistViolation { + host: host.to_string(), + })); + } + let now = SystemTime::now(); let expires = now.checked_add(Duration::from_secs(30)).unwrap_or(now); let tsexp = expires @@ -2208,8 +2224,8 @@ mod tests { build_asset_proxy_target_url, clear_s3_credentials_cache_for_tests, handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, is_host_allowed, - proxy_request, rebuild_response_with_body, reconstruct_and_validate_signed_target, - redirect_is_permitted, stream_asset_body, + is_host_permitted, proxy_request, rebuild_response_with_body, + reconstruct_and_validate_signed_target, stream_asset_body, }; use crate::cache_policy::{CachePolicy, EdgeCacheHeader}; use crate::constants::{HEADER_ACCEPT, HEADER_X_FORWARDED_FOR}; @@ -2289,6 +2305,16 @@ mod tests { .expect("should build http post request") } + fn build_proxy_sign_request(method: &Method, uri: &str, target: &str) -> HttpRequest { + if method == Method::GET { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("url", target); + return build_http_request(method.clone(), format!("{uri}?{}", serializer.finish())); + } + + build_http_post_json_request(uri, &serde_json::json!({ "url": target })) + } + fn build_http_post_streaming_request(uri: impl AsRef) -> HttpRequest { let stream = futures::stream::iter(vec![Bytes::from_static(b"{}")]); HttpRequest::builder() @@ -2550,6 +2576,152 @@ mod tests { }); } + #[test] + fn proxy_sign_enforces_allowed_domains_for_get_and_post() { + struct Case { + name: &'static str, + allowed_domains: &'static [&'static str], + target: &'static str, + signing_uri: &'static str, + expected_base: Option<&'static str>, + permitted: bool, + } + + let cases = [ + Case { + name: "exact match", + allowed_domains: &["cdn.example.com"], + target: "https://cdn.example.com/asset.js", + signing_uri: "https://edge.example.com/first-party/sign", + expected_base: None, + permitted: true, + }, + Case { + name: "rejected host", + allowed_domains: &["allowed.example.com"], + target: "https://blocked.example.com/asset.js", + signing_uri: "https://edge.example.com/first-party/sign", + expected_base: None, + permitted: false, + }, + Case { + name: "wildcard match", + allowed_domains: &["*.example.com"], + target: "https://static.cdn.example.com/asset.js", + signing_uri: "https://edge.example.com/first-party/sign", + expected_base: None, + permitted: true, + }, + Case { + name: "protocol-relative match", + allowed_domains: &["cdn.example.com"], + target: "//cdn.example.com/asset.js", + signing_uri: "http://edge.example.com/first-party/sign", + expected_base: Some("http://cdn.example.com/asset.js"), + permitted: true, + }, + Case { + name: "open mode", + allowed_domains: &[], + target: "https://unlisted.example.com/asset.js", + signing_uri: "https://edge.example.com/first-party/sign", + expected_base: None, + permitted: true, + }, + Case { + name: "user information cannot bypass", + allowed_domains: &["allowed.example.com"], + target: "https://allowed.example.com@blocked.example.com:9443/path", + signing_uri: "https://edge.example.com/first-party/sign", + expected_base: None, + permitted: false, + }, + Case { + name: "non-host URL parts are ignored", + allowed_domains: &["allowed.example.com"], + target: "https://user@allowed.example.com:9443/path?cache=1#section", + signing_uri: "https://edge.example.com/first-party/sign", + expected_base: None, + permitted: true, + }, + ]; + + futures::executor::block_on(async { + for case in cases { + for method in [&Method::GET, &Method::POST] { + let label = format!("{} {}", method.as_str(), case.name); + let mut settings = create_test_settings(); + settings.proxy.allowed_domains = case + .allowed_domains + .iter() + .map(|domain| (*domain).to_string()) + .collect(); + let req = build_proxy_sign_request(method, case.signing_uri, case.target); + let result = + handle_first_party_proxy_sign(&settings, &noop_services(), req).await; + + if case.permitted { + let response = result.unwrap_or_else(|error| { + panic!("{label} should sign target: {error:?}") + }); + assert_eq!( + response.status(), + StatusCode::OK, + "{label} should return 200" + ); + let body: serde_json::Value = + serde_json::from_str(&response_body_string(response)) + .expect("should parse sign response JSON"); + let href = body["href"] + .as_str() + .expect("should include string href in sign response"); + let signed_url = + url::Url::parse(&format!("https://edge.example.com{href}")) + .expect("should parse signed proxy URL"); + assert_eq!( + signed_url.path(), + "/first-party/proxy", + "{label} should return a first-party proxy href" + ); + let signed_params: HashMap<_, _> = signed_url.query_pairs().collect(); + for parameter in ["tsurl", "tstoken", "tsexp"] { + assert!( + signed_params.contains_key(parameter), + "{label} should include {parameter} in signed href" + ); + } + if let Some(expected_base) = case.expected_base { + assert_eq!( + body["base"].as_str(), + Some(expected_base), + "{label} should inherit the signing request scheme" + ); + } + } else { + let error = match result { + Ok(response) => { + panic!("{label} should reject target, got {response:?}") + } + Err(error) => error, + }; + assert!( + matches!( + error.current_context(), + TrustedServerError::AllowlistViolation { .. } + ), + "{label} should return AllowlistViolation, got {error:?}" + ); + assert_eq!( + error.current_context().status_code(), + StatusCode::FORBIDDEN, + "{label} should map allowlist rejection to 403" + ); + } + } + } + }); + } + #[test] fn proxy_sign_rejects_invalid_url() { futures::executor::block_on(async { @@ -5408,11 +5580,11 @@ mod tests { } #[test] - fn redirect_empty_allowlist_permits_any() { + fn empty_allowlist_permits_any_host() { let allowed: [String; 0] = []; assert!( - redirect_is_permitted(&allowed, "evil.com"), - "empty allowlist should not block any redirect host" + is_host_permitted(&allowed, "evil.com"), + "empty allowlist should not block any host" ); } @@ -5427,72 +5599,72 @@ mod tests { ); } - // --- redirect_is_permitted (full guard: empty-list bypass + is_host_allowed) --- + // --- is_host_permitted (full guard: empty-list bypass + is_host_allowed) --- #[test] - fn redirect_chain_allowed_when_host_matches_allowlist() { + fn host_is_permitted_when_it_matches_allowlist() { let allowed = vec!["ad.example.com".to_string(), "cdn.example.com".to_string()]; assert!( - redirect_is_permitted(&allowed, "ad.example.com"), - "should permit redirect to exact-match host" + is_host_permitted(&allowed, "ad.example.com"), + "should permit exact-match host" ); assert!( - redirect_is_permitted(&allowed, "cdn.example.com"), - "should permit redirect to second allowed host" + is_host_permitted(&allowed, "cdn.example.com"), + "should permit second allowed host" ); } #[test] - fn redirect_chain_allowed_when_host_matches_wildcard() { + fn host_is_permitted_when_it_matches_wildcard() { let allowed = vec!["*.example.com".to_string()]; assert!( - redirect_is_permitted(&allowed, "sub.example.com"), - "should permit redirect to wildcard-matched subdomain" + is_host_permitted(&allowed, "sub.example.com"), + "should permit wildcard-matched subdomain" ); } #[test] - fn redirect_chain_blocked_when_host_not_in_allowlist() { + fn host_is_blocked_when_not_in_allowlist() { let allowed = vec!["ad.example.com".to_string()]; assert!( - !redirect_is_permitted(&allowed, "evil.com"), - "should block redirect to host not in allowlist" + !is_host_permitted(&allowed, "evil.com"), + "should block host not in allowlist" ); } #[test] - fn redirect_chain_allowed_when_allowlist_is_empty() { + fn any_host_is_permitted_when_allowlist_is_empty() { let allowed: Vec = vec![]; assert!( - redirect_is_permitted(&allowed, "any-host.com"), - "should allow any redirect when allowlist is empty (open mode)" + is_host_permitted(&allowed, "any-host.com"), + "should allow any host when allowlist is empty (open mode)" ); } #[test] - fn redirect_chain_blocked_when_host_is_empty() { + fn empty_host_is_blocked_when_allowlist_is_non_empty() { let allowed = vec!["example.com".to_string()]; assert!( - !redirect_is_permitted(&allowed, ""), - "should block redirect with empty host when allowlist is non-empty" + !is_host_permitted(&allowed, ""), + "should block empty host when allowlist is non-empty" ); } #[test] - fn redirect_is_permitted_accepts_str_slices() { + fn is_host_permitted_accepts_str_slices() { // Verifies the &[impl AsRef] bound works with &str literals, // not just Vec. let allowed: &[&str] = &["example.com", "*.cdn.example.com"]; assert!( - redirect_is_permitted(allowed, "example.com"), + is_host_permitted(allowed, "example.com"), "should permit exact match via &str slice" ); assert!( - redirect_is_permitted(allowed, "static.cdn.example.com"), + is_host_permitted(allowed, "static.cdn.example.com"), "should permit wildcard match via &str slice" ); assert!( - !redirect_is_permitted(allowed, "evil.com"), + !is_host_permitted(allowed, "evil.com"), "should block host not in &str slice allowlist" ); } @@ -5501,19 +5673,19 @@ mod tests { fn ip_literal_blocked_by_domain_allowlist() { let allowed = vec!["*.example.com".to_string()]; assert!( - !redirect_is_permitted(&allowed, "169.254.169.254"), + !is_host_permitted(&allowed, "169.254.169.254"), "should block cloud metadata IP" ); assert!( - !redirect_is_permitted(&allowed, "127.0.0.1"), + !is_host_permitted(&allowed, "127.0.0.1"), "should block loopback IPv4" ); assert!( - !redirect_is_permitted(&allowed, "[::1]"), + !is_host_permitted(&allowed, "[::1]"), "should block loopback IPv6" ); assert!( - !redirect_is_permitted(&allowed, "::1"), + !is_host_permitted(&allowed, "::1"), "should block bare loopback IPv6" ); } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index ddc8ac612..9f7612f74 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1573,15 +1573,15 @@ pub struct Proxy { /// Set to false for local development with self-signed certificates. #[serde(default = "default_certificate_check")] pub certificate_check: bool, - /// Permitted redirect target domains for the first-party proxy. + /// Permitted signing, initial fetch, and redirect target domains for the + /// first-party proxy. /// /// Supports exact hostname match (`"example.com"`) and subdomain wildcard /// prefix (`"*.example.com"`, which also matches the apex `example.com`). /// Matching is case-insensitive. /// - /// When empty (the default), redirect destinations are not restricted. - /// Configure this in production to prevent SSRF via redirect chains - /// initiated by signed first-party proxy URLs. + /// When empty (the default), proxy hosts are not restricted. Configure this + /// in production to constrain signed and fetched first-party proxy targets. #[serde(default, deserialize_with = "vec_from_seq_or_map")] pub allowed_domains: Vec, /// Path-prefix-based asset proxy routes evaluated before publisher fallback. @@ -1638,7 +1638,7 @@ impl Proxy { if self.allowed_domains.is_empty() { log::debug!( - "proxy.allowed_domains is empty: all redirect destinations are permitted (open mode)" + "proxy.allowed_domains is empty: all signing, initial fetch, and redirect hosts are permitted (open mode)" ); } diff --git a/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts b/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts index 2fc216c32..d5d726bd3 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts @@ -1,5 +1,6 @@ import { log } from '../../core/log'; import { createMutationScheduler } from '../../shared/scheduler'; +import type { ProxySignOutcome } from './proxy_sign'; type ElementWithSrc = Element & { src: string }; @@ -23,7 +24,7 @@ export interface DynamicSrcProxyOptions { resourceName: string; logPrefix: string; shouldProxy(raw: string, element: E): boolean; - signProxy(raw: string, element: E): Promise; + signProxy(raw: string, element: E): Promise; } export function createDynamicSrcProxy( @@ -84,12 +85,19 @@ export function createDynamicSrcProxy( log.info(`${options.logPrefix}: signing ${options.resourceName} ${attr}`, { raw }); void options .signProxy(raw, element) - .then((signed) => { + .then((result) => { const current = assignments.get(element); if (!current || current.requestId !== requestId) return; assignments.delete(element); - const finalUrl = signed || raw; - if (signed) { + if (result.outcome === 'blocked') { + log.warn(`${options.logPrefix}: blocked dynamic ${options.resourceName} ${attr}`, { + raw, + }); + return; + } + + const finalUrl = result.outcome === 'signed' ? result.href : raw; + if (result.outcome === 'signed') { log.info(`${options.logPrefix}: proxied dynamic ${options.resourceName}`, { base: raw, finalUrl, diff --git a/crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts b/crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts index 5af2ea5e2..2ea3f7ec9 100644 --- a/crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts +++ b/crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts @@ -20,8 +20,15 @@ export function shouldProxyExternalUrl(raw: string): boolean { } } -export async function signProxyUrl(raw: string): Promise { - if (typeof fetch !== 'function') return null; +export type ProxySignOutcome = + | { outcome: 'signed'; href: string } + | { outcome: 'fallback' } + | { outcome: 'blocked' }; + +const FALLBACK: ProxySignOutcome = { outcome: 'fallback' }; + +export async function signProxyUrl(raw: string): Promise { + if (typeof fetch !== 'function') return FALLBACK; // A sandboxed srcdoc creative without `allow-same-origin` has an opaque // origin: this JSON POST would preflight with `Origin: null` and fail, so // skip the doomed request and leave the resource URL unsigned. Dynamic @@ -30,12 +37,12 @@ export async function signProxyUrl(raw: string): Promise { // https://github.com/IABTechLab/trusted-server/issues/982. Until then, // dynamically inserted resources degrade to loading directly, which the // sandbox still isolates from the publisher origin. - if (hasOpaqueOrigin()) return null; + if (hasOpaqueOrigin()) return FALLBACK; let absolute: string; try { absolute = new URL(raw, location.href).toString(); } catch { - return null; + return FALLBACK; } let endpoint = '/first-party/sign'; @@ -54,13 +61,13 @@ export async function signProxyUrl(raw: string): Promise { }); if (!resp.ok) { log.warn('tsjs-creative: sign HTTP error', resp.status); - return null; + return resp.status === 403 ? { outcome: 'blocked' } : FALLBACK; } const data = (await resp.json()) as { href?: string } | null; - const href = data && typeof data.href === 'string' ? data.href : null; - return href; + const href = data && typeof data.href === 'string' ? data.href : ''; + return href ? { outcome: 'signed', href } : FALLBACK; } catch (err) { log.warn('tsjs-creative: sign request failed', err); - return null; + return FALLBACK; } } diff --git a/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts index 48133fb72..f4b985532 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts @@ -38,6 +38,25 @@ describe('creative/iframe.ts', () => { }); }); + it('does not apply an iframe src when signing is blocked by policy', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + }); + global.fetch = fetchMock as unknown as typeof fetch; + + await importCreativeModule({ renderGuard: true }); + + const iframe = document.createElement('iframe'); + iframe.src = 'https://blocked.example.com/rejected.html'; + + await waitForExpect(() => { + expect(fetchMock).toHaveBeenCalled(); + expect(iframe.getAttribute('src')).toBeNull(); + expect(iframe.src).toBe(''); + }); + }); + it('falls back to raw iframe src when signing fails', async () => { const fetchMock = vi.fn().mockRejectedValue(new Error('network')); global.fetch = fetchMock as unknown as typeof fetch; diff --git a/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts index 44105571d..7459d0a11 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/image.test.ts @@ -38,6 +38,26 @@ describe('creative/image.ts', () => { }); }); + it('keeps the previous image src when signing is blocked by policy', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + }); + global.fetch = fetchMock as unknown as typeof fetch; + + await importCreativeModule({ renderGuard: true }); + + const img = new Image(); + img.src = '/existing.png'; + img.src = 'https://blocked.example.com/rejected.png'; + + await waitForExpect(() => { + expect(fetchMock).toHaveBeenCalled(); + expect(img.src).toBe(`${location.origin}/existing.png`); + expect(img.src).not.toContain('blocked.example.com'); + }); + }); + it('falls back to raw image src when signing fails', async () => { const fetchMock = vi.fn().mockRejectedValue(new Error('network')); global.fetch = fetchMock as unknown as typeof fetch; diff --git a/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts index 867c2b74e..3217b980d 100644 --- a/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts @@ -43,16 +43,50 @@ describe('creative/proxy_sign.ts', () => { credentials: 'same-origin', }) ); - expect(result).toBe(signed); + expect(result).toEqual({ outcome: 'signed', href: signed }); }); - it('returns null when fetch is unavailable', async () => { + it('returns fallback when fetch is unavailable', async () => { global.fetch = undefined as unknown as typeof fetch; const result = await signProxyUrl('https://cdn.example/asset.js'); - expect(result).toBeNull(); + expect(result).toEqual({ outcome: 'fallback' }); }); - it('skips the doomed POST in an opaque origin and returns null', async () => { + it('returns blocked for a signing policy rejection', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + }) as unknown as typeof fetch; + + const result = await signProxyUrl('https://blocked.example.com/asset.js'); + + expect(result).toEqual({ outcome: 'blocked' }); + }); + + it('returns fallback for a non-policy HTTP failure', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }) as unknown as typeof fetch; + + const result = await signProxyUrl('https://cdn.example/asset.js'); + + expect(result).toEqual({ outcome: 'fallback' }); + }); + + it('returns fallback when a successful response lacks an href', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}), + }) as unknown as typeof fetch; + + const result = await signProxyUrl('https://cdn.example/asset.js'); + + expect(result).toEqual({ outcome: 'fallback' }); + }); + + it('skips the doomed POST in an opaque origin and returns fallback', async () => { // A sandboxed srcdoc creative without `allow-same-origin` has origin // "null": the JSON POST would preflight and fail, so signing bails out // without issuing the request. @@ -63,7 +97,7 @@ describe('creative/proxy_sign.ts', () => { try { const result = await signProxyUrl('https://cdn.example/asset.js'); - expect(result).toBeNull(); + expect(result).toEqual({ outcome: 'fallback' }); expect(fetchMock).not.toHaveBeenCalled(); } finally { if (originDescriptor) { diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index b4cb64481..184915f0a 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -321,14 +321,14 @@ curl -I "https://edge.example.com/first-party/click?tsurl=https://advertiser.com ### GET/POST /first-party/sign -URL signing endpoint. Returns signed first-party proxy URL for a given target URL. +URL signing endpoint. Returns a signed first-party proxy URL for a valid HTTP or HTTPS target. When `proxy.allowed_domains` is non-empty, the endpoint checks the parsed target host before signing. An empty list permits every valid host. **Request Methods:** GET or POST **GET Request:** ```bash -curl "https://edge.example.com/first-party/sign?url=https://external.com/pixel.gif" +curl "https://edge.example.com/first-party/sign?url=https://cdn.example.com/pixel.gif" ``` **POST Request:** @@ -336,17 +336,24 @@ curl "https://edge.example.com/first-party/sign?url=https://external.com/pixel.g ```bash curl -X POST https://edge.example.com/first-party/sign \ -H "Content-Type: application/json" \ - -d '{"url":"https://external.com/pixel.gif"}' + -d '{"url":"https://cdn.example.com/pixel.gif"}' ``` **Response:** ```json { - "signed_url": "https://edge.example.com/first-party/proxy?tsurl=https://external.com/pixel.gif&tstoken=abc123..." + "href": "/first-party/proxy?tsurl=https%3A%2F%2Fcdn.example.com%2Fpixel.gif&tstoken=abc123...&tsexp=1234567890", + "base": "https://cdn.example.com/pixel.gif" } ``` +`href` is the signed proxy path. `base` is the normalized target without its query or fragment. + +**Error Responses:** + +- `403 Forbidden`: The target has a valid host that does not match a non-empty `proxy.allowed_domains` list + **Use Cases:** - TSJS creative runtime (image/iframe proxying) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a1f172429..958ffe8ef 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -809,20 +809,19 @@ Controls first-party proxy security settings and path-based asset routes. ### `[proxy]` -| Field | Type | Required | Description | -| ------------------- | ------------- | -------------------- | ------------------------------------------------------ | -| `allowed_domains` | Array[String] | No (default: `[]`) | Redirect destinations the proxy is permitted to follow | -| `certificate_check` | Boolean | No (default: `true`) | Verify TLS certificates when proxying HTTPS origins | -| `asset_routes` | Array[Table] | No (default: `[]`) | Path prefixes proxied directly to configured origins | +| Field | Type | Required | Description | +| ------------------- | ------------- | -------------------- | ----------------------------------------------------------- | +| `allowed_domains` | Array[String] | No (default: `[]`) | Hosts permitted for signing, initial fetches, and redirects | +| `certificate_check` | Boolean | No (default: `true`) | Verify TLS certificates when proxying HTTPS origins | +| `asset_routes` | Array[Table] | No (default: `[]`) | Path prefixes proxied directly to configured origins | **Example**: ```toml [proxy] allowed_domains = [ - "tracker.com", # Exact match - "*.adserver.com", # Wildcard: adserver.com and all subdomains - "*.trusted-cdn.net", + "assets.example.com", # Exact match + "*.cdn.example.com", # Wildcard: cdn.example.com and all subdomains ] ``` @@ -830,32 +829,32 @@ allowed_domains = [ ```bash # JSON array -TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS='["tracker.com","*.adserver.com"]' +TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS='["assets.example.com","*.cdn.example.com"]' # Indexed -TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS__0="tracker.com" -TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS__1="*.adserver.com" +TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS__0="assets.example.com" +TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS__1="*.cdn.example.com" # Comma-separated -TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS="tracker.com,*.adserver.com" +TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS="assets.example.com,*.cdn.example.com" ``` ### Field Details #### `allowed_domains` -**Purpose**: Allowlist of redirect destinations the proxy is permitted to follow. +**Purpose**: Allowlist of target hosts permitted for `/first-party/sign` and `/first-party/proxy`. -**Behavior**: When the proxy receives an HTTP redirect (301/302/303/307/308) during a request to `/first-party/proxy`, the redirect target host is checked against this list. A redirect whose host is not matched is blocked with a 403 error. +**Behavior**: Trusted Server checks the parsed host before signing a target, before fetching the initial proxy target, and before following each HTTP redirect (301/302/303/307/308). A host that does not match the list is blocked with a 403 error. -**Default — open mode**: When `allowed_domains` is absent or empty, every redirect destination is allowed. This default is intentional for zero-config development but should not be used in production. +**Default - open mode**: When `allowed_domains` is absent or empty, every valid host is allowed for signing, initial fetches, and redirects. This default supports zero-config development but should not be used in production. **Pattern Matching**: -| Pattern | Matches | Does not match | -| --------------- | --------------------------------------------------- | ------------------ | -| `tracker.com` | `tracker.com` | `sub.tracker.com` | -| `*.tracker.com` | `tracker.com`, `sub.tracker.com`, `a.b.tracker.com` | `evil-tracker.com` | +| Pattern | Matches | Does not match | +| -------------------- | ------------------------------------------------------------------ | ------------------------ | +| `assets.example.com` | `assets.example.com` | `sub.assets.example.com` | +| `*.cdn.example.com` | `cdn.example.com`, `static.cdn.example.com`, `a.b.cdn.example.com` | `evil-cdn.example.com` | - `"example.com"` — exact match only. - `"*.example.com"` — matches the base domain and any subdomain at any depth. @@ -864,13 +863,13 @@ TRUSTED_SERVER__PROXY__ALLOWED_DOMAINS="tracker.com,*.adserver.com" - The `*` wildcard requires a dot boundary: `*.example.com` does **not** match `evil-example.com`. ::: danger Production Recommendation -Always configure `allowed_domains` in production. Without an explicit allowlist, a signed proxy URL can be used to follow redirects to arbitrary hosts, creating an SSRF risk. +Always configure `allowed_domains` in production. Without an explicit allowlist, clients can sign and fetch valid URLs for arbitrary hosts, including redirect targets. ```toml [proxy] allowed_domains = [ - "*.your-ad-network.com", - "tracker.your-partner.com", + "assets.example.com", + "*.cdn.example.com", ] ``` diff --git a/docs/guide/first-party-proxy.md b/docs/guide/first-party-proxy.md index 43edd1220..67883e537 100644 --- a/docs/guide/first-party-proxy.md +++ b/docs/guide/first-party-proxy.md @@ -138,7 +138,7 @@ Use `/first-party/click` for navigational links (anchors) since it avoids downlo ### `/first-party/sign` - URL Signing -Generates signed proxy URLs for dynamic use cases. +Generates signed proxy URLs for dynamic use cases. Before signing, Trusted Server checks the parsed target host against `proxy.allowed_domains`. Protocol-relative targets inherit the signing request's scheme before this check. **Request (GET)**: @@ -170,6 +170,8 @@ POST /first-party/sign | `href` | Complete signed proxy URL ready to use | | `base` | Original base URL (without query parameters) | +A valid host outside a non-empty allowlist returns `403 Forbidden`. The creative runtime treats this response as a policy rejection and does not assign the attempted raw image or iframe URL. Network failures, malformed success responses, and non-403 errors keep the existing direct-load fallback. An empty allowlist permits every valid host. + **Expiration**: - Default: 30 seconds from signing @@ -456,45 +458,44 @@ Asset routes are intended for publisher-owned paths, not third-party creative UR ### Proxy Allowlist -Restrict which domains the proxy may redirect to via the `[proxy]` section: +Restrict target hosts for signing and proxy fetching via the `[proxy]` section: ```toml [proxy] allowed_domains = [ - "tracker.com", # Exact match - "*.adserver.com", # Wildcard: adserver.com and all subdomains - "*.trusted-cdn.net", + "assets.example.com", # Exact match + "*.cdn.example.com", # Wildcard: cdn.example.com and all subdomains ] ``` -**Semantics**: When a proxied request receives an HTTP redirect (301/302/303/307/308), the redirect target host is checked against `allowed_domains`. If the host does not match any pattern the redirect is blocked and a 403 error is returned. +**Semantics**: Trusted Server checks `allowed_domains` before `/first-party/sign` mints a token, before `/first-party/proxy` fetches the initial target, and before it follows each HTTP redirect (301/302/303/307/308). If the parsed target host does not match a pattern, Trusted Server blocks the operation with a 403 error. **Wildcard matching**: -| Pattern | Matches | Does not match | -| --------------- | --------------------------------------------------- | ------------------ | -| `tracker.com` | `tracker.com` | `sub.tracker.com` | -| `*.tracker.com` | `tracker.com`, `sub.tracker.com`, `a.b.tracker.com` | `evil-tracker.com` | +| Pattern | Matches | Does not match | +| -------------------- | ------------------------------------------------------------------ | ------------------------ | +| `assets.example.com` | `assets.example.com` | `sub.assets.example.com` | +| `*.cdn.example.com` | `cdn.example.com`, `static.cdn.example.com`, `a.b.cdn.example.com` | `evil-cdn.example.com` | - The `*` prefix matches the base domain and any subdomain at any depth. - Matching is case-insensitive; entries are normalized to lowercase on startup. -- The wildcard requires a dot boundary — `*.example.com` will **not** match `evil-example.com`. +- The wildcard requires a dot boundary. `*.example.com` does **not** match `evil-example.com`. - A bare `"*"` entry is **not** valid and will be removed at startup with a warning. Use an empty list for open mode. :::note Unicode / Internationalized Domain Names -Matching uses ASCII case-folding (`to_ascii_lowercase`). Internationalized domain names (IDNs) in Punycode form (e.g., `xn--nxasmq6b.com`) are matched literally — the Unicode label and its Punycode equivalent are treated as different strings. If your ad network uses IDN domains, add the Punycode form to `allowed_domains`. +Matching uses ASCII case-folding (`to_ascii_lowercase`). Internationalized domain names in Punycode form, such as `xn--bcher-kva.example.com`, are matched literally. The Unicode label and its Punycode equivalent are different strings, so add the Punycode form to `allowed_domains` when needed. ::: -**Default behavior**: When `allowed_domains` is omitted (or set to an empty list) every redirect destination is permitted. This default exists solely for development convenience and **must be overridden in production**. +**Default behavior**: When `allowed_domains` is omitted or empty, every valid host is permitted for signing, the initial fetch, and redirects. This default exists for development convenience and **must be overridden in production**. ::: danger Production Recommendation -Always set `allowed_domains` explicitly in production deployments. Without an allowlist, a signed proxy URL that follows redirects could be used to reach internal or unintended hosts (SSRF). +Always set `allowed_domains` explicitly in production deployments. Without an allowlist, clients can sign and fetch valid URLs for arbitrary hosts, including redirect targets. ```toml [proxy] allowed_domains = [ - "*.your-ad-network.com", - "tracker.your-partner.com", + "assets.example.com", + "*.cdn.example.com", ] ``` diff --git a/docs/superpowers/plans/2026-08-26-first-party-sign-allowlist-enforcement.md b/docs/superpowers/plans/2026-08-26-first-party-sign-allowlist-enforcement.md new file mode 100644 index 000000000..7c318ca43 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-first-party-sign-allowlist-enforcement.md @@ -0,0 +1,296 @@ +# First-party signing allowlist enforcement implementation plan + +> **Status:** Draft, awaiting approval +> +> **For implementers:** Follow `CLAUDE.md`. Keep the change within the approved spec, use target-matched Cargo aliases, and preserve a green workspace after each production edit. + +**Goal:** Make `/first-party/sign` reject valid HTTP or HTTPS targets outside a configured `proxy.allowed_domains` before minting a token, without letting the creative runtime bypass that rejection through its raw-URL fallback. + +**Issue:** [IABTechLab/trusted-server#1035](https://github.com/IABTechLab/trusted-server/issues/1035) + +**Spec:** `docs/superpowers/specs/2026-08-26-first-party-sign-allowlist-enforcement-design.md` + +## File map + +| File | Responsibility | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/proxy.rs` | Shared host policy, signing-time enforcement, warning log, and GET/POST handler matrix. | +| `crates/trusted-server-core/src/error.rs` | General allowlist-violation wording and existing `403` mapping. | +| `crates/trusted-server-core/src/settings.rs` | Accurate allowlist field documentation and open-mode log. | +| `crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts` | Distinct signed, fallback, and blocked signing outcomes. | +| `crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts` | Apply signed URLs, preserve recoverable fallback, and suppress policy-rejected assignments. | +| `crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts` | Signing outcome tests. | +| `crates/trusted-server-js/lib/test/integrations/creative/image.test.ts` | Image behavior after a signing-time policy rejection. | +| `crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts` | Iframe behavior after a signing-time policy rejection. | +| `docs/guide/api-reference.md` | Signing endpoint contract, response shape, and off-list response. | +| `docs/guide/configuration.md` | Complete `proxy.allowed_domains` behavior. | +| `docs/guide/first-party-proxy.md` | Signing, fetch, redirect, and browser rejection behavior. | +| `trusted-server.example.toml` | Accurate allowlist comment. | +| `CHANGELOG.md` | Unreleased security entry. | + +No adapter, dependency, route, or configuration-schema changes are expected. + +## Execution flow + +```mermaid +flowchart TD + A[Add GET and POST regression matrix] --> B[Confirm blocked cases fail] + B --> C[Enforce shared host policy before signing] + C --> D[Preserve fail-closed browser behavior for 403] + D --> E[Run focused Rust and JavaScript tests] + E --> F[Update source and operator documentation] + F --> G[Run repository gates] + G --> H[Review diff against issue scope] +``` + +## Task 1: Add the signing regression matrix + +**File:** `crates/trusted-server-core/src/proxy.rs` + +- [ ] Add a test-only request builder that accepts a method, signing request URI, and target URL. + - For GET, form-encode the target as the `url` query parameter. + - For POST, reuse the existing JSON request builder with `{ "url": target }`. + - Let the caller select an HTTP or HTTPS signing request so protocol-relative inheritance can be asserted. + - Keep the helper inside the existing `#[cfg(test)]` module. +- [ ] Add one table-driven test that runs each approved case through GET and POST: + - exact allowlist match; + - rejected host; + - wildcard match; + - protocol-relative target from an HTTP signing request; + - empty-list open mode; + - user information that resembles an allowed host but precedes a blocked destination host; and + - an allowed host with user information, a non-standard port, path, query, and fragment. +- [ ] Give every assertion a case label containing the method and scenario. +- [ ] Parse successful JSON responses and assert `200 OK` plus a signed `/first-party/proxy` `href`. +- [ ] For the protocol-relative case, assert that `base` or decoded `tsurl` uses the inherited `http://` scheme for both methods. +- [ ] For rejected cases, assert: + - `handle_first_party_proxy_sign` returns an error; + - the current context is `TrustedServerError::AllowlistViolation`; and + - its mapped status is `403 Forbidden`. +- [ ] Leave existing invalid-URL, denylist, protocol-inheritance, non-standard-port, oversized-body, and streaming-body tests intact. + +Run the focused test and confirm the rejected-host and user-information bypass cases fail because the current handler signs them: + +```bash +cargo test-fastly proxy_sign +``` + +**Acceptance:** the matrix exercises fourteen method/case combinations. It proves that matching uses the parsed host rather than the full authority and verifies scheme inheritance for GET and POST. + +## Task 2: Enforce the shared host policy before signing + +**Files:** + +- `crates/trusted-server-core/src/proxy.rs` +- `crates/trusted-server-core/src/error.rs` +- `crates/trusted-server-core/src/settings.rs` + +- [ ] Rename the private `redirect_is_permitted` helper to `is_host_permitted`. + - Update its documentation to describe host policy rather than redirects. + - Update the initial-target and redirect call sites. + - Rename or update existing helper tests without changing their assertions. +- [ ] Generalize `TrustedServerError::AllowlistViolation`: + - describe a proxy host blocked by `proxy.allowed_domains`; + - change the display text from redirect-specific wording to host-policy wording; and + - leave status mapping and generic client-facing body unchanged. +- [ ] In `handle_first_party_proxy_sign`, after URL parsing and HTTP/HTTPS validation: + - require `parsed.host_str()`; + - call `is_host_permitted(&settings.proxy.allowed_domains, host)`; + - when rejected, log one warning containing the host but not the complete URL; and + - return `TrustedServerError::AllowlistViolation { host }`. +- [ ] Keep the check before `SystemTime::now()`, `tsexp` construction, and `build_proxy_url_with_extras`, so rejected targets do not reach token work. +- [ ] Preserve the existing `rewrite.exclude_domains` check before parsing and allowlist enforcement. +- [ ] Update the signing handler's `# Errors` documentation to include missing hosts and allowlist rejection. +- [ ] Update `Proxy::allowed_domains` documentation so it covers signing, initial proxy targets, and redirects. +- [ ] Update the empty-list debug log so it describes open host policy rather than redirects only. + +Run focused tests: + +```bash +cargo test-fastly proxy_sign +cargo test-fastly is_host_permitted +cargo test-fastly allowlist +``` + +Then run the full Fastly target suite after the production edit: + +```bash +cargo test-fastly +``` + +**Acceptance:** all fourteen signing cases pass, fetch-time tests remain green, off-list signing returns `AllowlistViolation`, and no rejected target reaches signature construction. + +## Task 3: Preserve browser blocking for allowlist rejection + +**Files:** + +- `crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts` +- `crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts` +- `crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts` +- `crates/trusted-server-js/lib/test/integrations/creative/image.test.ts` +- `crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts` + +- [ ] Replace the overloaded `string | null` signing result with a discriminated outcome for: + - `signed`, carrying the proxy `href`; + - `fallback`, for failures that retain direct loading; and + - `blocked`, for policy rejection. +- [ ] Map signing responses as follows: + - a successful response with a valid `href` returns `signed`; + - `403 Forbidden` returns `blocked`; + - network errors, malformed success responses, and non-403 HTTP failures return `fallback`. +- [ ] Update the dynamic source guard: + - apply the proxy URL for `signed`; + - apply the raw URL for `fallback`; and + - for `blocked`, clear the pending assignment without invoking the native setter or applying the raw URL. +- [ ] Keep an element's previous resource unchanged when a later assignment is blocked. +- [ ] Preserve stale-request protection through the existing request ID check. +- [ ] Add `proxy_sign` tests for successful, blocked, and fallback outcomes. +- [ ] Add image and iframe tests proving a mocked `403` does not apply the raw external URL. +- [ ] Keep the existing network-failure tests proving direct fallback. + +Run focused JavaScript tests: + +```bash +( + cd crates/trusted-server-js/lib + npx vitest run \ + test/integrations/creative/proxy_sign.test.ts \ + test/integrations/creative/image.test.ts \ + test/integrations/creative/iframe.test.ts + npm run format + node build-all.mjs +) +``` + +**Acceptance:** a `403` leaves the attempted image or iframe URL unapplied, while successful signing and recoverable fallback retain their current behavior. + +## Task 4: Update maintained documentation + +**Files:** + +- `docs/guide/api-reference.md` +- `docs/guide/configuration.md` +- `docs/guide/first-party-proxy.md` +- `trusted-server.example.toml` +- `CHANGELOG.md` + +- [ ] Update the `/first-party/sign` API reference: + - explain that a non-empty `proxy.allowed_domains` is enforced before signing; + - state that a valid off-list host returns `403 Forbidden`; + - state that an empty list is open mode; and + - correct the response example to the actual `href` and `base` fields. +- [ ] Update the configuration guide's `[proxy]` field table and detailed behavior: + - signing targets, initial fetch targets, and redirect targets are covered; + - exact, wildcard, case-insensitive, and empty-list behavior stays unchanged. +- [ ] Update the first-party proxy guide: + - add the signing check beside `/first-party/sign`; + - describe the allowlist as applying at signing, initial fetch, and every redirect hop; and + - state that the creative runtime does not load a target directly after an allowlist `403`. +- [ ] Update the example configuration comment so it no longer describes `allowed_domains` as redirect-only. +- [ ] Add a concise Unreleased Security changelog entry covering early rejection and fail-closed runtime handling. +- [ ] Use fictional `example.com` hosts throughout. +- [ ] Leave historical `docs/superpowers/` artifacts and Prebid-specific guides unchanged. + +Run documentation checks: + +```bash +( + cd docs + npm run lint + npm run format + npm run build +) +``` + +**Acceptance:** maintained source and operator documentation agree on signing, initial fetch, redirects, matching, open mode, browser rejection, and `403` behavior. + +## Task 5: Run full verification + +- [ ] Check Rust formatting: + +```bash +cargo fmt --all -- --check +``` + +- [ ] Run all adapter-aligned test suites and parity tests: + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +``` + +- [ ] Run every target-specific Clippy gate: + +```bash +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +- [ ] Run the JavaScript gates: + +```bash +( + cd crates/trusted-server-js/lib + npx vitest run + npm run format + node build-all.mjs +) +``` + +- [ ] Repeat the documentation gates after any formatting or wording correction: + +```bash +( + cd docs + npm run lint + npm run format + npm run build +) +``` + +- [ ] Check the final diff and workspace: + +```bash +git diff --check +git status --short +git diff -- \ + crates/trusted-server-core/src/proxy.rs \ + crates/trusted-server-core/src/error.rs \ + crates/trusted-server-core/src/settings.rs \ + crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts \ + crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts \ + crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts \ + crates/trusted-server-js/lib/test/integrations/creative/image.test.ts \ + crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts \ + docs/guide/api-reference.md \ + docs/guide/configuration.md \ + docs/guide/first-party-proxy.md \ + trusted-server.example.toml \ + CHANGELOG.md +``` + +**Acceptance:** all required gates pass, or the final report names an exact external blocker without claiming the blocked check passed. + +## Final review checklist + +- [ ] GET and POST enforce the same policy after reaching the normalized parsed URL. +- [ ] User information and ports cannot confuse target-host extraction. +- [ ] Ports, paths, queries, fragments, and user information do not participate in matching. +- [ ] Protocol-relative targets inherit the request scheme, and both methods assert that scheme. +- [ ] Empty `proxy.allowed_domains` remains open mode. +- [ ] Exact and wildcard behavior remains case-insensitive and dot-boundary safe. +- [ ] Denylisted, malformed, unsupported-scheme, and missing-host errors retain their existing categories. +- [ ] Rejection logs contain the host and omit the complete target URL. +- [ ] Fetch-time initial-target and redirect enforcement is unchanged. +- [ ] Error status and generic client-facing response behavior are unchanged outside the new early rejection. +- [ ] A signing-time `403` cannot become a direct browser request. +- [ ] Network errors, malformed success responses, and non-403 signing failures retain direct fallback. +- [ ] Source docs, maintained guides, example configuration, and changelog describe the complete behavior. +- [ ] No auth, rate limiting, Origin, CORS, IP, DNS, token, expiry, adapter, route, dependency, or configuration-schema work entered the diff. diff --git a/docs/superpowers/specs/2026-08-26-first-party-sign-allowlist-enforcement-design.md b/docs/superpowers/specs/2026-08-26-first-party-sign-allowlist-enforcement-design.md new file mode 100644 index 000000000..c8673e406 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-first-party-sign-allowlist-enforcement-design.md @@ -0,0 +1,244 @@ +# First-party signing allowlist enforcement + +**Issue:** [IABTechLab/trusted-server#1035](https://github.com/IABTechLab/trusted-server/issues/1035) + +**Date:** 2026-08-26 + +**Status:** Proposed + +## Problem + +`GET` and `POST /first-party/sign` accept an absolute HTTP or HTTPS target and mint a short-lived `/first-party/proxy` URL for it. The signing handler currently checks `rewrite.exclude_domains`, but it does not check `proxy.allowed_domains`. + +The proxy checks `proxy.allowed_domains` later, before fetching the initial target and at each redirect hop. When an operator configures an allowlist, a client can therefore obtain a valid signature for an off-list host even though using that signature fails with `403 Forbidden`. + +This does not bypass fetch-time enforcement. It does make the signing endpoint inconsistent with proxy policy, and operators cannot distinguish off-policy signing attempts until a signed URL is used. + +## Goals + +- Enforce `proxy.allowed_domains` in `/first-party/sign` before minting a token. +- Apply the same host-matching rules used by the proxy fetch path. +- Cover GET query and POST JSON inputs. +- Cover protocol-relative targets after they inherit the request scheme. +- Preserve open mode when `proxy.allowed_domains` is empty. +- Log the rejected host without logging the complete target URL. +- Keep dynamic browser resources blocked when signing fails because of the allowlist. +- Keep source documentation, operator documentation, and example configuration accurate. + +## Non-goals + +- Authentication or authorization. +- Rate limiting. +- `Origin` validation or CORS changes. +- Private or reserved IP blocking. +- DNS resolution or rebinding protection. +- Token, signature, or expiry changes. +- JavaScript changes unrelated to distinguishing an allowlist rejection from other signing failures. +- Changing the existing direct-load fallback for network errors, malformed success responses, or non-403 signing failures. +- Adapter route changes. +- Changes for issue #982's opaque-origin signing broker or CORS-safe asset route. +- Changes to click URL signing or `/first-party/proxy-rebuild`. + +## Current behavior + +```mermaid +flowchart TD + A[GET or POST first-party sign] --> B[Normalize target] + B --> C[Check rewrite exclusions] + C --> D[Validate HTTP or HTTPS] + D --> E[Mint signed proxy URL] + E --> F[Client requests signed proxy URL] + F --> G[Check proxy allowed domains] + G -->|Matched or open mode| H[Fetch target] + G -->|Not matched| I[Return 403] +``` + +The allowlist check uses `url::Url::host_str()`. It does not include the port, path, query, fragment, or user information. Matching is case-insensitive: + +- `example.com` matches only `example.com`. +- `*.example.com` matches `example.com` and subdomains at any depth. +- `*.example.com` does not match `evil-example.com`. +- An empty list permits every valid host. + +## Approved behavior + +```mermaid +flowchart TD + A[GET or POST first-party sign] --> B[Normalize target] + B --> C[Check rewrite exclusions] + C --> D[Parse and validate HTTP or HTTPS] + D --> E[Extract host] + E --> F[Check proxy allowed domains] + F -->|Matched or open mode| G[Mint signed proxy URL] + F -->|Not matched| H[Log host and return 403] + G --> I[Proxy repeats the same check before fetching] +``` + +### Validation order + +The signing handler will retain this order: + +1. Read the target from `GET ?url=` or the POST JSON body. +2. Trim it and give a protocol-relative target the request's scheme. +3. Apply the existing `rewrite.exclude_domains` behavior. +4. Parse the target URL. +5. Require an HTTP or HTTPS scheme. +6. Require a host. +7. Check the host against `proxy.allowed_domains`. +8. Create `tsexp`, sign the target, and serialize the response. + +A URL rejected by `rewrite.exclude_domains` keeps its existing error behavior, even when it would also fail the allowlist. Malformed URLs, unsupported schemes, and missing hosts also keep their existing error categories. `403 Forbidden` is reserved for a valid host rejected by a non-empty allowlist. + +### Shared host policy + +Rename the private `redirect_is_permitted` helper to `is_host_permitted`. The helper already governs initial proxy targets as well as redirects, and the signing handler will become its third caller. + +The helper remains the single place that combines: + +- empty-list open mode; and +- exact or wildcard matching through `is_host_allowed`. + +Fetch-time enforcement keeps its current behavior. Only the private helper name and its callers change. + +### Rejection and logging + +A rejected signing request will emit one warning containing the host only: + +```text +sign request for `blocked.example.com` blocked: host not in proxy.allowed_domains +``` + +The handler will return `TrustedServerError::AllowlistViolation`, which already maps to `403 Forbidden`. Generalize the variant's documentation and display text from redirect-specific wording to host-policy wording so it remains accurate for initial fetches, redirects, and signing requests. + +The existing generic client-facing error body remains unchanged. + +### Browser behavior for policy rejection + +The current creative runtime treats every signing failure as permission to use the raw external URL. Today an off-list dynamic resource receives a signed URL and is later blocked by `/first-party/proxy`. Returning `403` earlier from `/first-party/sign` without changing the runtime would instead make that resource load directly in the browser. + +```mermaid +flowchart TD + A[Creative runtime requests signature] --> B{Signing result} + B -->|Signed href| C[Apply first-party proxy URL] + B -->|403 policy rejection| D[Leave attempted resource URL unapplied] + B -->|Other failure| E[Apply raw external URL] +``` + +Preserve the current effective policy for an allowlist rejection: + +- a `403` response from `/first-party/sign` is a policy rejection; +- the attempted resource URL is not applied to the image or iframe, so the browser does not request it directly; +- network errors, malformed success responses, and non-403 signing failures retain the existing direct-load fallback; and +- successful signing continues to apply the returned proxy `href`. + +Represent these outcomes explicitly in the creative runtime rather than overloading `null`. A discriminated result such as `signed`, `fallback`, or `blocked` lets the dynamic source guard distinguish a policy rejection from a recoverable signing failure. On `blocked`, the guard clears the pending assignment without invoking the native setter. If the element already had a resource, it remains unchanged. + +This is the only JavaScript behavior change in scope. + +### Input forms + +Both supported forms reach the same normalized `url::Url` before allowlist enforcement: + +```text +GET /first-party/sign?url=https%3A%2F%2Fcdn.example.com%2Fasset.js +``` + +```json +{ + "url": "https://cdn.example.com/asset.js" +} +``` + +For a target such as `//cdn.example.com/asset.js`, the handler retains the existing request-scheme inheritance before extracting and checking `cdn.example.com`. + +Methods other than GET and POST retain their current internal handler behavior. Adapter routing continues to expose only GET and POST. + +## Test contract + +### Core handler + +Add handler-level coverage for the following seven cases through both GET and POST, producing fourteen combinations: + +| Case | `proxy.allowed_domains` | Target | Expected result | +| ------------------------------ | ----------------------- | ------------------------------------------------------------ | ------------------------------------------------ | +| Exact match | `cdn.example.com` | `https://cdn.example.com/asset.js` | `200` with signed `href` | +| Rejected host | `allowed.example.com` | `https://blocked.example.com/asset.js` | `AllowlistViolation`, status `403` | +| Wildcard match | `*.example.com` | `https://static.cdn.example.com/asset.js` | `200` with signed `href` | +| Protocol-relative match | `cdn.example.com` | `//cdn.example.com/asset.js` from an HTTP signing request | `200`, signed `href`, and `base` using `http://` | +| Open mode | empty | `https://unlisted.example.com/asset.js` | `200` with signed `href` | +| User information cannot bypass | `allowed.example.com` | `https://allowed.example.com@blocked.example.com:9443/path` | `AllowlistViolation`, status `403` | +| Non-host URL parts are ignored | `allowed.example.com` | `https://user@allowed.example.com:9443/path?cache=1#section` | `200` with signed `href` | + +The test should use one table-driven matrix and identify the method and case in each assertion. Successful cases need not assert time-dependent token bytes. They must parse the response, prove that the handler returned `200`, and verify that `href` is a signed `/first-party/proxy` URL. The protocol-relative case must also verify the inherited scheme through `base` or the decoded `tsurl`. The rejected cases must prove both the error variant and its `403` mapping. + +The two authority cases verify that policy is applied to `Url::host_str()`, not the full authority or a substring of the input. Together with the existing host-matching tests, they lock down the rule that user information, port, path, query, and fragment do not participate in matching. + +Existing host-matching and fetch-time tests remain in place, with helper references renamed as needed. + +### Creative runtime + +Add JavaScript tests for all three signing outcomes: + +- a successful response applies the signed proxy `href`; +- a `403` response produces a blocked outcome and does not apply the raw external image or iframe URL; and +- network errors and non-403 failures retain the raw-URL fallback. + +No adapter-specific tests are required. Every adapter calls the shared core handler. + +## Documentation contract + +Update all maintained source and operator-facing descriptions of this setting: + +- `docs/guide/api-reference.md` + - State that `/first-party/sign` checks `proxy.allowed_domains` before signing. + - Document `403` for a valid off-list host. + - Correct the response example to the actual `{ href, base }` shape. +- `docs/guide/configuration.md` + - Describe signing, initial proxy targets, and redirect targets as covered by the allowlist. + - Preserve exact, wildcard, case-insensitive, and open-mode semantics. +- `docs/guide/first-party-proxy.md` + - Add signing-time enforcement to the signing endpoint and proxy allowlist sections. + - State that a runtime `403` policy rejection does not fall back to a direct browser request. +- `crates/trusted-server-core/src/settings.rs` + - Generalize the `Proxy::allowed_domains` documentation and open-mode log so they cover signing, initial targets, and redirects. +- `trusted-server.example.toml` + - Replace the redirect-only comment with wording that covers signed and fetched proxy targets. +- `CHANGELOG.md` + - Add an Unreleased Security entry describing early rejection at `/first-party/sign`. + +Prebid-specific documentation remains unchanged because its descriptions of bundle-host and redirect-host requirements are already accurate. + +## Expected files + +| File | Change | +| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/proxy.rs` | Rename and reuse the shared host-policy helper, enforce it before signing, log rejections, and add the GET/POST matrix. | +| `crates/trusted-server-core/src/error.rs` | Generalize `AllowlistViolation` documentation and display wording. | +| `crates/trusted-server-core/src/settings.rs` | Correct the allowlist field documentation and open-mode log. | +| `crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts` | Return distinct signed, fallback, and blocked outcomes. | +| `crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts` | Keep allowlist-rejected resource assignments from reaching the native setter. | +| `crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts` | Cover `403` policy rejection and non-policy fallback. | +| `crates/trusted-server-js/lib/test/integrations/creative/image.test.ts` | Prove an off-list image is not loaded directly. | +| `crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts` | Prove an off-list iframe is not loaded directly. | +| `docs/guide/api-reference.md` | Document signing-time allowlist behavior and `403`. | +| `docs/guide/configuration.md` | Describe the full allowlist policy. | +| `docs/guide/first-party-proxy.md` | Update signing, browser rejection, and proxy allowlist sections. | +| `trusted-server.example.toml` | Correct the allowlist comment. | +| `CHANGELOG.md` | Record the security fix. | + +No dependency, configuration schema, adapter, or routing changes are expected. + +## Completion criteria + +- A valid off-list GET or POST target returns `403` before token creation when `proxy.allowed_domains` is non-empty. +- Exact and wildcard matches continue to sign successfully. +- URL authority parsing cannot confuse user information or a port with the target host. +- Protocol-relative inputs are checked after scheme inheritance, and both GET and POST tests verify the inherited scheme. +- Empty-list open mode continues to sign valid targets. +- Signing and fetch paths use one host-policy helper. +- Fetch-time target and redirect enforcement is unchanged. +- A signing-time `403` does not cause the creative runtime to load the rejected URL directly. +- Non-policy signing failures retain their existing direct-load fallback. +- Logs identify rejected hosts without exposing paths or query strings. +- Source docs, current guides, example configuration, and changelog describe the behavior. +- All focused and repository-required checks pass. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 7c7e83635..a0e40b202 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -116,7 +116,7 @@ enabled = false [proxy] # certificate_check = true -# Required for integrations.prebid.external_bundle_url and first-party proxy redirects. +# Permits first-party proxy signing, initial fetch targets, and redirect targets. # allowed_domains = ["ads.example.com", "assets.example.com", "*.cdn.example.com"] # Static/rehosted asset cache policies are operator-controlled. Disabled rules