Skip to content

Commit d516a9e

Browse files
Reject disallowed first-party proxy targets before signing (#1077)
1 parent b59567b commit d516a9e

15 files changed

Lines changed: 918 additions & 104 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323

2424
### Security
2525

26+
- `/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.
2627
- 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.
2728
- 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
2829

crates/trusted-server-core/src/error.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ pub enum TrustedServerError {
7676
#[display("Forbidden: {message}")]
7777
Forbidden { message: String },
7878

79-
/// A redirect destination was blocked by the proxy allowlist.
80-
#[display("Redirect to `{host}` blocked: host not in proxy allowed_domains")]
79+
/// A proxy host was blocked by `proxy.allowed_domains`.
80+
#[display("Proxy host `{host}` blocked: host not in proxy.allowed_domains")]
8181
AllowlistViolation { host: String },
8282

8383
/// Settings parsing or validation failed.

crates/trusted-server-core/src/proxy.rs

Lines changed: 208 additions & 36 deletions
Large diffs are not rendered by default.

crates/trusted-server-core/src/settings.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1654,15 +1654,17 @@ pub struct Proxy {
16541654
/// Set to false for local development with self-signed certificates.
16551655
#[serde(default = "default_certificate_check")]
16561656
pub certificate_check: bool,
1657-
/// Permitted redirect target domains for the first-party proxy.
1657+
/// Permitted signing, initial fetch, and redirect target domains for the
1658+
/// first-party proxy.
16581659
///
16591660
/// Supports exact hostname match (`"example.com"`) and subdomain wildcard
16601661
/// prefix (`"*.example.com"`, which also matches the apex `example.com`).
16611662
/// Matching is case-insensitive.
16621663
///
1663-
/// When empty (the default), redirect destinations are not restricted.
1664-
/// Configure this in production to prevent SSRF via redirect chains
1665-
/// initiated by signed first-party proxy URLs.
1664+
/// When empty (the default), proxy hosts are not restricted. Configure this
1665+
/// in production to constrain signed and fetched first-party proxy targets.
1666+
/// When `integrations.prebid.external_bundle_url` is configured, this list
1667+
/// must include its host and any HTTPS redirect targets.
16661668
#[serde(default, deserialize_with = "vec_from_seq_or_map")]
16671669
pub allowed_domains: Vec<String>,
16681670
/// Path-prefix-based asset proxy routes evaluated before publisher fallback.
@@ -1719,7 +1721,7 @@ impl Proxy {
17191721

17201722
if self.allowed_domains.is_empty() {
17211723
log::debug!(
1722-
"proxy.allowed_domains is empty: all redirect destinations are permitted (open mode)"
1724+
"proxy.allowed_domains is empty: all signing, initial fetch, and redirect hosts are permitted (open mode)"
17231725
);
17241726
}
17251727

crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { log } from '../../core/log';
22
import { createMutationScheduler } from '../../shared/scheduler';
33

4+
import type { ProxySignOutcome } from './proxy_sign';
5+
46
type ElementWithSrc = Element & { src: string };
57

68
type ElementCtor<E extends ElementWithSrc> = {
@@ -23,7 +25,7 @@ export interface DynamicSrcProxyOptions<E extends ElementWithSrc> {
2325
resourceName: string;
2426
logPrefix: string;
2527
shouldProxy(raw: string, element: E): boolean;
26-
signProxy(raw: string, element: E): Promise<string | null>;
28+
signProxy(raw: string, element: E): Promise<ProxySignOutcome>;
2729
}
2830

2931
export function createDynamicSrcProxy<E extends ElementWithSrc>(
@@ -84,12 +86,19 @@ export function createDynamicSrcProxy<E extends ElementWithSrc>(
8486
log.info(`${options.logPrefix}: signing ${options.resourceName} ${attr}`, { raw });
8587
void options
8688
.signProxy(raw, element)
87-
.then((signed) => {
89+
.then((result) => {
8890
const current = assignments.get(element);
8991
if (!current || current.requestId !== requestId) return;
9092
assignments.delete(element);
91-
const finalUrl = signed || raw;
92-
if (signed) {
93+
if (result.outcome === 'blocked') {
94+
log.warn(`${options.logPrefix}: blocked dynamic ${options.resourceName} ${attr}`, {
95+
raw,
96+
});
97+
return;
98+
}
99+
100+
const finalUrl = result.outcome === 'signed' ? result.href : raw;
101+
if (result.outcome === 'signed') {
93102
log.info(`${options.logPrefix}: proxied dynamic ${options.resourceName}`, {
94103
base: raw,
95104
finalUrl,

crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,15 @@ export function shouldProxyExternalUrl(raw: string): boolean {
2020
}
2121
}
2222

23-
export async function signProxyUrl(raw: string): Promise<string | null> {
24-
if (typeof fetch !== 'function') return null;
23+
export type ProxySignOutcome =
24+
| { outcome: 'signed'; href: string }
25+
| { outcome: 'fallback' }
26+
| { outcome: 'blocked' };
27+
28+
const FALLBACK: ProxySignOutcome = { outcome: 'fallback' };
29+
30+
export async function signProxyUrl(raw: string): Promise<ProxySignOutcome> {
31+
if (typeof fetch !== 'function') return FALLBACK;
2532
// A sandboxed srcdoc creative without `allow-same-origin` has an opaque
2633
// origin: this JSON POST would preflight with `Origin: null` and fail, so
2734
// skip the doomed request and leave the resource URL unsigned. Dynamic
@@ -30,12 +37,12 @@ export async function signProxyUrl(raw: string): Promise<string | null> {
3037
// https://github.com/IABTechLab/trusted-server/issues/982. Until then,
3138
// dynamically inserted resources degrade to loading directly, which the
3239
// sandbox still isolates from the publisher origin.
33-
if (hasOpaqueOrigin()) return null;
40+
if (hasOpaqueOrigin()) return FALLBACK;
3441
let absolute: string;
3542
try {
3643
absolute = new URL(raw, location.href).toString();
3744
} catch {
38-
return null;
45+
return FALLBACK;
3946
}
4047

4148
let endpoint = '/first-party/sign';
@@ -54,13 +61,13 @@ export async function signProxyUrl(raw: string): Promise<string | null> {
5461
});
5562
if (!resp.ok) {
5663
log.warn('tsjs-creative: sign HTTP error', resp.status);
57-
return null;
64+
return resp.status === 403 ? { outcome: 'blocked' } : FALLBACK;
5865
}
5966
const data = (await resp.json()) as { href?: string } | null;
60-
const href = data && typeof data.href === 'string' ? data.href : null;
61-
return href;
67+
const href = data && typeof data.href === 'string' ? data.href : '';
68+
return href ? { outcome: 'signed', href } : FALLBACK;
6269
} catch (err) {
6370
log.warn('tsjs-creative: sign request failed', err);
64-
return null;
71+
return FALLBACK;
6572
}
6673
}

crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,25 @@ describe('creative/iframe.ts', () => {
3838
});
3939
});
4040

41+
it('does not apply an iframe src when signing is blocked by policy', async () => {
42+
const fetchMock = vi.fn().mockResolvedValue({
43+
ok: false,
44+
status: 403,
45+
});
46+
global.fetch = fetchMock as unknown as typeof fetch;
47+
48+
await importCreativeModule({ renderGuard: true });
49+
50+
const iframe = document.createElement('iframe');
51+
iframe.src = 'https://blocked.example.com/rejected.html';
52+
53+
await waitForExpect(() => {
54+
expect(fetchMock).toHaveBeenCalled();
55+
expect(iframe.getAttribute('src')).toBeNull();
56+
expect(iframe.src).toBe('');
57+
});
58+
});
59+
4160
it('falls back to raw iframe src when signing fails', async () => {
4261
const fetchMock = vi.fn().mockRejectedValue(new Error('network'));
4362
global.fetch = fetchMock as unknown as typeof fetch;

crates/trusted-server-js/lib/test/integrations/creative/image.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,26 @@ describe('creative/image.ts', () => {
3838
});
3939
});
4040

41+
it('keeps the previous image src when signing is blocked by policy', async () => {
42+
const fetchMock = vi.fn().mockResolvedValue({
43+
ok: false,
44+
status: 403,
45+
});
46+
global.fetch = fetchMock as unknown as typeof fetch;
47+
48+
await importCreativeModule({ renderGuard: true });
49+
50+
const img = new Image();
51+
img.src = '/existing.png';
52+
img.src = 'https://blocked.example.com/rejected.png';
53+
54+
await waitForExpect(() => {
55+
expect(fetchMock).toHaveBeenCalled();
56+
expect(img.src).toBe(`${location.origin}/existing.png`);
57+
expect(img.src).not.toContain('blocked.example.com');
58+
});
59+
});
60+
4161
it('falls back to raw image src when signing fails', async () => {
4262
const fetchMock = vi.fn().mockRejectedValue(new Error('network'));
4363
global.fetch = fetchMock as unknown as typeof fetch;

crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,50 @@ describe('creative/proxy_sign.ts', () => {
4343
credentials: 'same-origin',
4444
})
4545
);
46-
expect(result).toBe(signed);
46+
expect(result).toEqual({ outcome: 'signed', href: signed });
4747
});
4848

49-
it('returns null when fetch is unavailable', async () => {
49+
it('returns fallback when fetch is unavailable', async () => {
5050
global.fetch = undefined as unknown as typeof fetch;
5151
const result = await signProxyUrl('https://cdn.example/asset.js');
52-
expect(result).toBeNull();
52+
expect(result).toEqual({ outcome: 'fallback' });
5353
});
5454

55-
it('skips the doomed POST in an opaque origin and returns null', async () => {
55+
it('returns blocked for a signing policy rejection', async () => {
56+
global.fetch = vi.fn().mockResolvedValue({
57+
ok: false,
58+
status: 403,
59+
}) as unknown as typeof fetch;
60+
61+
const result = await signProxyUrl('https://blocked.example.com/asset.js');
62+
63+
expect(result).toEqual({ outcome: 'blocked' });
64+
});
65+
66+
it('returns fallback for a non-policy HTTP failure', async () => {
67+
global.fetch = vi.fn().mockResolvedValue({
68+
ok: false,
69+
status: 500,
70+
}) as unknown as typeof fetch;
71+
72+
const result = await signProxyUrl('https://cdn.example/asset.js');
73+
74+
expect(result).toEqual({ outcome: 'fallback' });
75+
});
76+
77+
it('returns fallback when a successful response lacks an href', async () => {
78+
global.fetch = vi.fn().mockResolvedValue({
79+
ok: true,
80+
status: 200,
81+
json: async () => ({}),
82+
}) as unknown as typeof fetch;
83+
84+
const result = await signProxyUrl('https://cdn.example/asset.js');
85+
86+
expect(result).toEqual({ outcome: 'fallback' });
87+
});
88+
89+
it('skips the doomed POST in an opaque origin and returns fallback', async () => {
5690
// A sandboxed srcdoc creative without `allow-same-origin` has origin
5791
// "null": the JSON POST would preflight and fail, so signing bails out
5892
// without issuing the request.
@@ -63,7 +97,7 @@ describe('creative/proxy_sign.ts', () => {
6397

6498
try {
6599
const result = await signProxyUrl('https://cdn.example/asset.js');
66-
expect(result).toBeNull();
100+
expect(result).toEqual({ outcome: 'fallback' });
67101
expect(fetchMock).not.toHaveBeenCalled();
68102
} finally {
69103
if (originDescriptor) {

docs/guide/api-reference.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -321,32 +321,39 @@ curl -I "https://edge.example.com/first-party/click?tsurl=https://advertiser.com
321321

322322
### GET/POST /first-party/sign
323323

324-
URL signing endpoint. Returns signed first-party proxy URL for a given target URL.
324+
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.
325325

326326
**Request Methods:** GET or POST
327327

328328
**GET Request:**
329329

330330
```bash
331-
curl "https://edge.example.com/first-party/sign?url=https://external.com/pixel.gif"
331+
curl "https://edge.example.com/first-party/sign?url=https://cdn.example.com/pixel.gif"
332332
```
333333

334334
**POST Request:**
335335

336336
```bash
337337
curl -X POST https://edge.example.com/first-party/sign \
338338
-H "Content-Type: application/json" \
339-
-d '{"url":"https://external.com/pixel.gif"}'
339+
-d '{"url":"https://cdn.example.com/pixel.gif"}'
340340
```
341341

342342
**Response:**
343343

344344
```json
345345
{
346-
"signed_url": "https://edge.example.com/first-party/proxy?tsurl=https://external.com/pixel.gif&tstoken=abc123..."
346+
"href": "/first-party/proxy?tsurl=https%3A%2F%2Fcdn.example.com%2Fpixel.gif&tstoken=abc123...&tsexp=1234567890",
347+
"base": "https://cdn.example.com/pixel.gif"
347348
}
348349
```
349350

351+
`href` is the signed proxy path. `base` is the normalized target without its query or fragment.
352+
353+
**Error Responses:**
354+
355+
- `403 Forbidden`: The target has a valid host that does not match a non-empty `proxy.allowed_domains` list
356+
350357
**Use Cases:**
351358

352359
- TSJS creative runtime (image/iframe proxying)

0 commit comments

Comments
 (0)