Skip to content

Commit a86b78a

Browse files
authored
Merge branch 'main' into hardening/constant-time-comparisons
2 parents 53846f8 + d462629 commit a86b78a

6 files changed

Lines changed: 191 additions & 162 deletions

File tree

crates/js/lib/package-lock.json

Lines changed: 118 additions & 118 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/js/lib/src/core/render.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ import NORMALIZE_CSS from './styles/normalize.css?inline';
77
import IFRAME_TEMPLATE from './templates/iframe.html?raw';
88

99
// Sandbox permissions granted to creative iframes.
10-
// Notably absent:
11-
// allow-scripts, allow-same-origin — prevent JS execution and same-origin
12-
// access, which are the primary attack vectors for malicious creatives.
13-
// allow-forms — server-side sanitization strips <form> elements, so form
14-
// submission from creatives is not a supported use case. Omitting this token
15-
// is consistent with that server-side policy and reduces the attack surface.
10+
// Ad creatives routinely contain scripts for tracking, click handling, and
11+
// viewability measurement, so allow-scripts and allow-same-origin are required
12+
// for creatives to render correctly. Server-side sanitization is the primary
13+
// defense against malicious markup; the sandbox provides defense-in-depth.
1614
const CREATIVE_SANDBOX_TOKENS = [
15+
'allow-forms',
1716
'allow-popups',
1817
'allow-popups-to-escape-sandbox',
18+
'allow-same-origin',
19+
'allow-scripts',
1920
'allow-top-navigation-by-user-activation',
2021
] as const;
2122

crates/js/lib/src/integrations/prebid/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,13 @@ export function installPrebidNpm(config?: Partial<PrebidNpmConfig>): typeof pbjs
269269
} else {
270270
unit.bids.push({ bidder: ADAPTER_CODE, params: tsParams });
271271
}
272+
273+
// Remove server-side bidder entries — they are now handled via the
274+
// trustedServer adapter. Only keep client-side bidders (which run via
275+
// their native Prebid.js adapters) and the trustedServer bid itself.
276+
unit.bids = unit.bids.filter(
277+
(b) => b.bidder === ADAPTER_CODE || clientSideBidders.has(b.bidder ?? '')
278+
);
272279
}
273280

274281
// Ensure the trustedServer adapter is allowed to return bids under any

crates/js/lib/test/core/render.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ describe('render', () => {
2727
expect(iframe.srcdoc).toContain('<span>ad</span>');
2828
expect(div.querySelector('iframe')).toBe(iframe);
2929
const sandbox = iframe.getAttribute('sandbox') ?? '';
30-
expect(sandbox).not.toContain('allow-forms');
30+
expect(sandbox).toContain('allow-forms');
3131
expect(sandbox).toContain('allow-popups');
3232
expect(sandbox).toContain('allow-popups-to-escape-sandbox');
3333
expect(sandbox).toContain('allow-top-navigation-by-user-activation');
34-
expect(sandbox).not.toContain('allow-same-origin');
35-
expect(sandbox).not.toContain('allow-scripts');
34+
expect(sandbox).toContain('allow-same-origin');
35+
expect(sandbox).toContain('allow-scripts');
3636
});
3737

3838
it('preserves dollar sequences when building the creative document', async () => {

crates/js/lib/test/integrations/prebid/index.test.ts

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -411,14 +411,13 @@ describe('prebid/installPrebidNpm', () => {
411411
];
412412
pbjs.requestBids({ adUnits } as any);
413413

414-
// Each ad unit should have trustedServer added
414+
// Each ad unit should only have trustedServer — original bidders are absorbed
415415
for (const unit of adUnits) {
416-
const hasTsBidder = unit.bids.some((b: any) => b.bidder === 'trustedServer');
417-
expect(hasTsBidder).toBe(true);
416+
expect(unit.bids).toHaveLength(1);
417+
expect(unit.bids[0].bidder).toBe('trustedServer');
418418
}
419419

420-
const trustedServerBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer');
421-
expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: {} });
420+
expect(adUnits[0].bids[0].params.bidderParams).toEqual({ appnexus: {} });
422421

423422
// Should call through to original requestBids
424423
expect(mockRequestBids).toHaveBeenCalled();
@@ -434,7 +433,7 @@ describe('prebid/installPrebidNpm', () => {
434433
expect(tsCount).toBe(1);
435434
});
436435

437-
it('captures per-bidder params on trustedServer bid', () => {
436+
it('captures per-bidder params on trustedServer bid and removes originals', () => {
438437
const pbjs = installPrebidNpm();
439438

440439
const adUnits = [
@@ -447,8 +446,10 @@ describe('prebid/installPrebidNpm', () => {
447446
];
448447
pbjs.requestBids({ adUnits } as any);
449448

450-
const trustedServerBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer');
451-
expect(trustedServerBid).toBeDefined();
449+
// Only trustedServer should remain — original bidders are absorbed
450+
expect(adUnits[0].bids).toHaveLength(1);
451+
const trustedServerBid = adUnits[0].bids[0] as any;
452+
expect(trustedServerBid.bidder).toBe('trustedServer');
452453
expect(trustedServerBid.params.bidderParams).toEqual({
453454
appnexus: { placementId: 123 },
454455
rubicon: { accountId: 'abc' },
@@ -482,11 +483,12 @@ describe('prebid/installPrebidNpm', () => {
482483
];
483484
pbjs.requestBids({ adUnits } as any);
484485

485-
const tsBid0 = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
486-
expect(tsBid0.params.zone).toBe('header');
486+
// Original kargo bids should be removed, only trustedServer remains
487+
expect(adUnits[0].bids).toHaveLength(1);
488+
expect(adUnits[0].bids[0].params.zone).toBe('header');
487489

488-
const tsBid1 = adUnits[1].bids.find((b: any) => b.bidder === 'trustedServer') as any;
489-
expect(tsBid1.params.zone).toBe('fixed_bottom');
490+
expect(adUnits[1].bids).toHaveLength(1);
491+
expect(adUnits[1].bids[0].params.zone).toBe('fixed_bottom');
490492
});
491493

492494
it('omits zone when mediaTypes.banner.name is not set', () => {
@@ -501,8 +503,8 @@ describe('prebid/installPrebidNpm', () => {
501503
];
502504
pbjs.requestBids({ adUnits } as any);
503505

504-
const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
505-
expect(tsBid.params.zone).toBeUndefined();
506+
expect(adUnits[0].bids).toHaveLength(1);
507+
expect(adUnits[0].bids[0].params.zone).toBeUndefined();
506508
});
507509

508510
it('omits zone when ad unit has no mediaTypes', () => {
@@ -511,8 +513,8 @@ describe('prebid/installPrebidNpm', () => {
511513
const adUnits = [{ bids: [{ bidder: 'rubicon', params: {} }] }];
512514
pbjs.requestBids({ adUnits } as any);
513515

514-
const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
515-
expect(tsBid.params.zone).toBeUndefined();
516+
expect(adUnits[0].bids).toHaveLength(1);
517+
expect(adUnits[0].bids[0].params.zone).toBeUndefined();
516518
});
517519

518520
it('clears stale zone when existing trustedServer bid is reused', () => {
@@ -549,10 +551,10 @@ describe('prebid/installPrebidNpm', () => {
549551
mockPbjs.adUnits = [{ bids: [{ bidder: 'openx', params: {} }] }] as any[];
550552
pbjs.requestBids({} as any);
551553

552-
const hasTsBidder = (mockPbjs.adUnits[0] as any).bids.some(
553-
(b: any) => b.bidder === 'trustedServer'
554-
);
555-
expect(hasTsBidder).toBe(true);
554+
// Original openx bid should be removed, only trustedServer remains
555+
const unit = mockPbjs.adUnits[0] as any;
556+
expect(unit.bids).toHaveLength(1);
557+
expect(unit.bids[0].bidder).toBe('trustedServer');
556558
});
557559
});
558560
});
@@ -611,7 +613,7 @@ describe('prebid/client-side bidders', () => {
611613
delete (window as any).__tsjs_prebid;
612614
});
613615

614-
it('excludes client-side bidders from trustedServer bidderParams', () => {
616+
it('excludes client-side bidders from trustedServer bidderParams and removes server-side bids', () => {
615617
(window as any).__tsjs_prebid = { clientSideBidders: ['rubicon'] };
616618

617619
const pbjs = installPrebidNpm();
@@ -627,13 +629,18 @@ describe('prebid/client-side bidders', () => {
627629
];
628630
pbjs.requestBids({ adUnits } as any);
629631

632+
// Only rubicon (client-side) and trustedServer should remain
633+
expect(adUnits[0].bids).toHaveLength(2);
630634
const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
631635
expect(tsBid).toBeDefined();
632636
// rubicon should NOT be in bidderParams — it runs client-side
633637
expect(tsBid.params.bidderParams).toEqual({
634638
appnexus: { placementId: 123 },
635639
kargo: { placementId: 'k1' },
636640
});
641+
// appnexus and kargo should be removed (absorbed into trustedServer)
642+
expect(adUnits[0].bids.find((b: any) => b.bidder === 'appnexus')).toBeUndefined();
643+
expect(adUnits[0].bids.find((b: any) => b.bidder === 'kargo')).toBeUndefined();
637644
});
638645

639646
it('preserves client-side bidder bids as standalone entries', () => {
@@ -673,6 +680,8 @@ describe('prebid/client-side bidders', () => {
673680
];
674681
pbjs.requestBids({ adUnits } as any);
675682

683+
// 3 bids: rubicon (client-side), openx (client-side), trustedServer
684+
expect(adUnits[0].bids).toHaveLength(3);
676685
const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
677686
// Only appnexus should be in bidderParams
678687
expect(tsBid.params.bidderParams).toEqual({
@@ -682,6 +691,9 @@ describe('prebid/client-side bidders', () => {
682691
// Both client-side bidders should remain
683692
expect(adUnits[0].bids.find((b: any) => b.bidder === 'rubicon')).toBeDefined();
684693
expect(adUnits[0].bids.find((b: any) => b.bidder === 'openx')).toBeDefined();
694+
695+
// Server-side bidder should be removed
696+
expect(adUnits[0].bids.find((b: any) => b.bidder === 'appnexus')).toBeUndefined();
685697
});
686698

687699
it('behaves normally when no client-side bidders are configured', () => {
@@ -698,7 +710,10 @@ describe('prebid/client-side bidders', () => {
698710
];
699711
pbjs.requestBids({ adUnits } as any);
700712

701-
const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
713+
// All original bidders should be removed, only trustedServer remains
714+
expect(adUnits[0].bids).toHaveLength(1);
715+
const tsBid = adUnits[0].bids[0] as any;
716+
expect(tsBid.bidder).toBe('trustedServer');
702717
expect(tsBid.params.bidderParams).toEqual({
703718
appnexus: { placementId: 123 },
704719
rubicon: { accountId: 'abc' },
@@ -720,7 +735,10 @@ describe('prebid/client-side bidders', () => {
720735
];
721736
pbjs.requestBids({ adUnits } as any);
722737

723-
const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
738+
// All original bidders should be removed, only trustedServer remains
739+
expect(adUnits[0].bids).toHaveLength(1);
740+
const tsBid = adUnits[0].bids[0] as any;
741+
expect(tsBid.bidder).toBe('trustedServer');
724742
expect(tsBid.params.bidderParams).toEqual({
725743
appnexus: { placementId: 123 },
726744
rubicon: { accountId: 'abc' },
@@ -742,7 +760,8 @@ describe('prebid/client-side bidders', () => {
742760
];
743761
pbjs.requestBids({ adUnits } as any);
744762

745-
// trustedServer should still be present (even with empty bidderParams)
763+
// All 3 should be present: rubicon, appnexus (both client-side), and trustedServer
764+
expect(adUnits[0].bids).toHaveLength(3);
746765
const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
747766
expect(tsBid).toBeDefined();
748767
expect(tsBid.params.bidderParams).toEqual({});

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

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ fn is_safe_data_uri(lower: &str) -> bool {
329329

330330
/// Strip dangerous elements and attributes from ad creative HTML.
331331
///
332-
/// Removes elements that can execute code or exfiltrate data (`script`, `iframe`,
332+
/// Removes elements that can execute code or exfiltrate data (`script`,
333333
/// `object`, `embed`, `base`, `meta`, `form`, `link`, `style`, `noscript`) and strips `on*` event-handler
334334
/// attributes and dangerous URI schemes from all remaining elements:
335335
/// - `javascript:`, `vbscript:`
@@ -361,18 +361,17 @@ pub fn sanitize_creative_html(markup: &str) -> String {
361361
HtmlSettings {
362362
element_content_handlers: vec![
363363
// Remove executable/dangerous elements along with their inner content.
364-
// - <script>, <iframe>, <object>, <embed>: direct execution vectors.
364+
// - <script>, <object>, <embed>: direct execution vectors.
365365
// - <base>: rewrites all relative URLs, undermining the proxy rewriter.
366366
// - <meta>: can trigger redirects (http-equiv=refresh) or inject CSP.
367-
// - <form>: action/formaction can exfiltrate data; stripped to match the
368-
// iframe sandbox which omits allow-forms.
367+
// - <form>: action/formaction can exfiltrate data.
369368
// - <link>: external stylesheet/resource loading.
370369
// - <style>: CSS expressions, @import, and url() data exfiltration.
371370
// - <noscript>: rendered when scripts are disabled (always the case
372371
// inside a sandbox without allow-scripts); strip to prevent parser
373372
// differential attacks.
374373
element!(
375-
"script, iframe, object, embed, base, meta, form, link, style, noscript",
374+
"script, object, embed, base, meta, form, link, style, noscript",
376375
|el| {
377376
el.remove();
378377
Ok(())
@@ -454,10 +453,9 @@ pub fn sanitize_creative_html(markup: &str) -> String {
454453
// NOTE: This uses simple substring matching on the lowercased value,
455454
// which does not handle CSS escape sequences (e.g. `\65xpression(`)
456455
// or comments (e.g. `expr/**/ession(`). That is acceptable: CSS
457-
// expression() is IE6-8 only and the iframe sandbox's absence of
458-
// allow-scripts prevents execution even if an obfuscated value were
459-
// to slip through. The sandbox is the primary mitigation; this check
460-
// is defense-in-depth for obvious patterns.
456+
// expression() is IE6-8 only, and downstream rendering still happens
457+
// inside a sandboxed iframe. This check is defense-in-depth for
458+
// obvious patterns.
461459
if let Some(style) = el.get_attribute("style") {
462460
let lower = style.to_ascii_lowercase();
463461
if lower.contains("expression(")
@@ -1504,10 +1502,14 @@ mod tests {
15041502
}
15051503

15061504
#[test]
1507-
fn sanitize_removes_iframe_element() {
1505+
fn sanitize_preserves_iframe_element_and_src() {
15081506
let html = r#"<div>ad</div><iframe src="https://evil.example/"></iframe>"#;
15091507
let out = sanitize_creative_html(html);
1510-
assert!(!out.contains("<iframe"), "should remove iframe element");
1508+
assert!(out.contains("<iframe"), "should preserve iframe element");
1509+
assert!(
1510+
out.contains("https://evil.example/"),
1511+
"should preserve safe iframe src"
1512+
);
15111513
assert!(out.contains("ad"), "should preserve safe content");
15121514
}
15131515

0 commit comments

Comments
 (0)