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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions crates/trusted-server-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
244 changes: 208 additions & 36 deletions crates/trusted-server-core/src/proxy.rs

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions crates/trusted-server-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Path-prefix-based asset proxy routes evaluated before publisher fallback.
Expand Down Expand Up @@ -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)"
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { log } from '../../core/log';
import { createMutationScheduler } from '../../shared/scheduler';

Check failure on line 2 in crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts

View workflow job for this annotation

GitHub Actions / format-typescript

There should be at least one empty line between import groups
import type { ProxySignOutcome } from './proxy_sign';

type ElementWithSrc = Element & { src: string };

Expand All @@ -23,7 +24,7 @@
resourceName: string;
logPrefix: string;
shouldProxy(raw: string, element: E): boolean;
signProxy(raw: string, element: E): Promise<string | null>;
signProxy(raw: string, element: E): Promise<ProxySignOutcome>;
}

export function createDynamicSrcProxy<E extends ElementWithSrc>(
Expand Down Expand Up @@ -84,12 +85,19 @@
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,15 @@ export function shouldProxyExternalUrl(raw: string): boolean {
}
}

export async function signProxyUrl(raw: string): Promise<string | null> {
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<ProxySignOutcome> {
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
Expand All @@ -30,12 +37,12 @@ export async function signProxyUrl(raw: string): Promise<string | null> {
// 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';
Expand All @@ -54,13 +61,13 @@ export async function signProxyUrl(raw: string): Promise<string | null> {
});
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand Down
15 changes: 11 additions & 4 deletions docs/guide/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,32 +321,39 @@ 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:**

```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)
Expand Down
43 changes: 21 additions & 22 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -809,53 +809,52 @@ 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
]
```

**Environment Override**:

```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.
Expand All @@ -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",
]
```

Expand Down
Loading
Loading