diff --git a/crates/js/lib/build-all.mjs b/crates/js/lib/build-all.mjs index 15fe8dba..b1f06439 100644 --- a/crates/js/lib/build-all.mjs +++ b/crates/js/lib/build-all.mjs @@ -8,245 +8,24 @@ * tsjs-core.js — core API (always included) * tsjs-.js — one per discovered integration * - * Environment variables: - * TSJS_PREBID_ADAPTERS — Comma-separated list of Prebid.js bid adapter - * names to include in the bundle (e.g. "rubicon,appnexus,openx"). - * Each name must have a corresponding {name}BidAdapter.js module in - * the prebid.js package. Default: "rubicon". - * - * TSJS_PREBID_USER_ID_MODULES — Ignored for production builds. User ID - * modules are selected from src/integrations/prebid/user_id_modules.json - * so attested bundles are deterministic. For local experiments only, use - * TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE. + * Prebid is intentionally excluded from this embedded build. Use + * build-prebid-external.mjs to generate publisher-specific Prebid bundles + * outside the Cargo build. */ -import crypto from 'node:crypto'; import fs from 'node:fs'; -import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { build } from 'vite'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); const srcDir = path.resolve(__dirname, 'src'); const distDir = path.resolve(__dirname, '..', 'dist'); const integrationsDir = path.join(srcDir, 'integrations'); -// --------------------------------------------------------------------------- -// Prebid adapter generation -// --------------------------------------------------------------------------- - -const DEFAULT_PREBID_ADAPTERS = 'rubicon'; -const ADAPTERS_FILE = path.join(integrationsDir, 'prebid', '_adapters.generated.ts'); -const USER_IDS_FILE = path.join(integrationsDir, 'prebid', '_user_ids.generated.ts'); - -const USER_ID_REGISTRY_FILE = path.join(integrationsDir, 'prebid', 'user_id_modules.json'); -const USER_IDS_MANIFEST_FILE = path.join(distDir, 'prebid-user-id-modules.json'); -const LIVE_INTENT_SHIM_ALIAS = 'prebid.js/modules/liveIntentIdSystem.js'; -const PREBID_PACKAGE_DIR = path.join(__dirname, 'node_modules', 'prebid.js'); -const PREBID_LIVE_INTENT_STANDARD = path.join( - PREBID_PACKAGE_DIR, - 'dist', - 'src', - 'libraries', - 'liveIntentId', - 'idSystem.js' -); -const PREBID_GLOBAL_MODULE = path.join(PREBID_PACKAGE_DIR, 'dist', 'src', 'src', 'prebidGlobal.js'); -const LIVE_INTENT_SHIM = path.join( - integrationsDir, - 'prebid', - 'prebid_modules', - 'liveIntentIdSystem.ts' -); - -/** - * Generate `_adapters.generated.ts` with import statements for each adapter - * listed in the TSJS_PREBID_ADAPTERS environment variable. - * - * Invalid adapter names (those without a matching module in prebid.js) are - * logged and skipped. - */ -function generatePrebidAdapters() { - const raw = process.env.TSJS_PREBID_ADAPTERS || DEFAULT_PREBID_ADAPTERS; - const names = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - - if (names.length === 0) { - console.warn( - '[build-all] TSJS_PREBID_ADAPTERS is empty, falling back to default:', - DEFAULT_PREBID_ADAPTERS - ); - names.push(DEFAULT_PREBID_ADAPTERS); - } - - const modulesDir = path.join(__dirname, 'node_modules', 'prebid.js', 'modules'); - - // Validate each adapter and build import lines - const imports = []; - for (const name of names) { - const moduleFile = `${name}BidAdapter.js`; - const modulePath = path.join(modulesDir, moduleFile); - if (!fs.existsSync(modulePath)) { - console.error( - `[build-all] WARNING: Prebid adapter "${name}" not found (expected ${moduleFile}), skipping` - ); - continue; - } - imports.push(`import 'prebid.js/modules/${moduleFile}';`); - } - - if (imports.length === 0) { - console.error( - '[build-all] WARNING: No valid Prebid adapters found, bundle will have no client-side adapters' - ); - } - - const content = [ - '// Auto-generated by build-all.mjs — manual edits will be overwritten at build time.', - '//', - '// Controls which Prebid.js bid adapters are included in the bundle.', - '// Set the TSJS_PREBID_ADAPTERS environment variable to a comma-separated list', - '// of adapter names (e.g. "rubicon,appnexus,openx") before building.', - `// Default: "${DEFAULT_PREBID_ADAPTERS}"`, - '', - ...imports, - '', - ].join('\n'); - - fs.writeFileSync(ADAPTERS_FILE, content); - - const adapterNames = names.filter((name) => - fs.existsSync(path.join(modulesDir, `${name}BidAdapter.js`)) - ); - console.log('[build-all] Prebid adapters:', adapterNames); -} - -function readUserIdRegistry() { - return JSON.parse(fs.readFileSync(USER_ID_REGISTRY_FILE, 'utf8')); -} - -function requireExistingFile(filePath, description) { - if (!fs.existsSync(filePath)) { - throw new Error(`[build-all] Missing ${description}: ${filePath}`); - } -} - -function prebidPackageVersion() { - const packageJsonPath = path.join(PREBID_PACKAGE_DIR, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - return packageJson.version; -} - -function sourceToModuleMap(entries) { - const map = {}; - for (const entry of entries) { - for (const source of entry.eidSources ?? []) { - map[source] = entry.moduleName; - } - } - return map; -} - -function validateUserIdImport(entry) { - requireExistingFile(LIVE_INTENT_SHIM, 'LiveIntent ESM shim'); - requireExistingFile(PREBID_LIVE_INTENT_STANDARD, 'Prebid LiveIntent standard ESM module'); - requireExistingFile(PREBID_GLOBAL_MODULE, 'Prebid global module'); - - if (entry.moduleName === 'liveIntentIdSystem') { - return; - } - - try { - require.resolve(entry.importPath, { paths: [__dirname] }); - } catch (error) { - throw new Error( - `[build-all] Required Prebid user ID module "${entry.moduleName}" could not be resolved from ${entry.importPath}: ${error.message}` - ); - } -} - -/** - * Generate `_user_ids.generated.ts` with deterministic User ID imports. - * - * Production builds intentionally ignore TSJS_PREBID_USER_ID_MODULES so the - * attested JS artifact does not vary per publisher. A dev-only override exists - * for local experiments and should not be used for trusted deployments. - */ -function generatePrebidUserIdModules() { - const registry = readUserIdRegistry(); - const entriesByModule = new Map(registry.modules.map((entry) => [entry.moduleName, entry])); - const override = process.env.TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE; - const moduleNames = override - ? override - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - : registry.defaultPreset; - - if (process.env.TSJS_PREBID_USER_ID_MODULES && !override) { - console.warn( - '[build-all] TSJS_PREBID_USER_ID_MODULES is ignored for deterministic attested builds. ' + - 'Use TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE only for local experiments.' - ); - } - - if (override) { - console.warn( - '[build-all] WARNING: using TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE. ' + - 'This changes the Prebid bundle and breaks production attestation assumptions.' - ); - } - - const selectedEntries = moduleNames.map((moduleName) => { - const entry = entriesByModule.get(moduleName); - if (!entry) { - throw new Error(`[build-all] Unknown Prebid user ID module in preset: ${moduleName}`); - } - validateUserIdImport(entry); - return entry; - }); - - const imports = selectedEntries.map((entry) => `import '${entry.importPath}';`); - - const content = [ - '// Auto-generated by build-all.mjs — manual edits will be overwritten at build time.', - '//', - '// Deterministic Prebid.js user ID module preset for attested builds.', - '// TSJS_PREBID_USER_ID_MODULES is intentionally ignored in production builds.', - '// Use TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE only for local experiments.', - `// Modules: ${moduleNames.join(', ')}`, - '', - ...imports, - '', - ].join('\n'); - - fs.writeFileSync(USER_IDS_FILE, content); - - const manifest = { - prebidVersion: prebidPackageVersion(), - deterministic: !override, - modules: moduleNames, - sourceToModule: sourceToModuleMap(registry.modules), - generatedFileHash: crypto.createHash('sha256').update(content).digest('hex'), - }; - - console.log('[build-all] Prebid user ID modules:', moduleNames); - return manifest; -} - -generatePrebidAdapters(); -const prebidUserIdManifest = generatePrebidUserIdModules(); - -// --------------------------------------------------------------------------- - // Clean dist directory fs.rmSync(distDir, { recursive: true, force: true }); fs.mkdirSync(distDir, { recursive: true }); -fs.writeFileSync(USER_IDS_MANIFEST_FILE, `${JSON.stringify(prebidUserIdManifest, null, 2)}\n`); // Discover integration modules: directories in src/integrations/ with index.ts const integrationModules = fs.existsSync(integrationsDir) @@ -255,7 +34,9 @@ const integrationModules = fs.existsSync(integrationsDir) .filter((name) => { const fullPath = path.join(integrationsDir, name); return ( - fs.statSync(fullPath).isDirectory() && fs.existsSync(path.join(fullPath, 'index.ts')) + name !== 'prebid' && + fs.statSync(fullPath).isDirectory() && + fs.existsSync(path.join(fullPath, 'index.ts')) ); }) .sort() @@ -271,20 +52,6 @@ async function buildModule(name, entryPath) { await build({ configFile: false, root: __dirname, - resolve: { - alias: { - [LIVE_INTENT_SHIM_ALIAS]: LIVE_INTENT_SHIM, - 'prebid.js/modules/liveIntentIdSystem': LIVE_INTENT_SHIM, - 'tsjs-prebid/liveIntentIdSystemStandard': PREBID_LIVE_INTENT_STANDARD, - 'tsjs-prebid/prebidGlobal': PREBID_GLOBAL_MODULE, - // prebid.js doesn't expose src/adapterManager.js via its package - // "exports" map, but we need it for client-side bidder validation. - 'prebid.js/src/adapterManager.js': path.resolve( - __dirname, - 'node_modules/prebid.js/dist/src/src/adapterManager.js' - ), - }, - }, build: { emptyOutDir: false, outDir: distDir, diff --git a/crates/js/lib/build-prebid-external.mjs b/crates/js/lib/build-prebid-external.mjs new file mode 100644 index 00000000..8cb7efd0 --- /dev/null +++ b/crates/js/lib/build-prebid-external.mjs @@ -0,0 +1,266 @@ +/** + * Build a publisher-specific external Prebid bundle. + * + * Unlike build-all.mjs, this script is intended to run outside the Cargo build. + * It produces an immutable bundle and manifest that can be hosted on an asset + * CDN, then referenced by integrations.prebid.external_bundle_url. + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'vite'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const srcDir = path.resolve(__dirname, 'src'); +const integrationsDir = path.join(srcDir, 'integrations'); +const prebidDir = path.join(integrationsDir, 'prebid'); + +const DEFAULT_PREBID_ADAPTERS = 'rubicon'; +const ADAPTERS_FILE = path.join(prebidDir, '_adapters.generated.ts'); +const USER_IDS_FILE = path.join(prebidDir, '_user_ids.generated.ts'); +const USER_ID_REGISTRY_FILE = path.join(prebidDir, 'user_id_modules.json'); +const LIVE_INTENT_SHIM_ALIAS = 'prebid.js/modules/liveIntentIdSystem.js'; +const PREBID_PACKAGE_DIR = path.join(__dirname, 'node_modules', 'prebid.js'); +const PREBID_LIVE_INTENT_STANDARD = path.join( + PREBID_PACKAGE_DIR, + 'dist', + 'src', + 'libraries', + 'liveIntentId', + 'idSystem.js' +); +const PREBID_GLOBAL_MODULE = path.join(PREBID_PACKAGE_DIR, 'dist', 'src', 'src', 'prebidGlobal.js'); +const LIVE_INTENT_SHIM = path.join(prebidDir, 'prebid_modules', 'liveIntentIdSystem.ts'); + +function parseArgs(argv) { + const options = new Map(); + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith('--')) { + throw new Error(`[build-prebid-external] Unexpected positional argument: ${arg}`); + } + + const equalsIndex = arg.indexOf('='); + const rawKey = equalsIndex === -1 ? arg.slice(2) : arg.slice(2, equalsIndex); + const inlineValue = equalsIndex === -1 ? undefined : arg.slice(equalsIndex + 1); + const value = inlineValue ?? argv[i + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`[build-prebid-external] Missing value for --${rawKey}`); + } + if (inlineValue === undefined) { + i += 1; + } + options.set(rawKey, value); + } + + return { + adapters: parseList(options.get('adapters') ?? DEFAULT_PREBID_ADAPTERS), + userIdModules: options.has('user-id-modules') + ? parseList(options.get('user-id-modules')) + : null, + outDir: path.resolve(__dirname, options.get('out') ?? path.join('..', 'dist', 'prebid')), + }; +} + +function parseList(raw) { + return raw + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +} + +function readIfExists(filePath) { + return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null; +} + +function restoreFile(filePath, content) { + if (content === null) { + fs.rmSync(filePath, { force: true }); + } else { + fs.writeFileSync(filePath, content); + } +} + +function requireExistingFile(filePath, description) { + if (!fs.existsSync(filePath)) { + throw new Error(`[build-prebid-external] Missing ${description}: ${filePath}`); + } +} + +function prebidPackageVersion() { + const packageJsonPath = path.join(PREBID_PACKAGE_DIR, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return packageJson.version; +} + +function readUserIdRegistry() { + return JSON.parse(fs.readFileSync(USER_ID_REGISTRY_FILE, 'utf8')); +} + +function validateUserIdImport(entry) { + requireExistingFile(LIVE_INTENT_SHIM, 'LiveIntent ESM shim'); + requireExistingFile(PREBID_LIVE_INTENT_STANDARD, 'Prebid LiveIntent standard ESM module'); + requireExistingFile(PREBID_GLOBAL_MODULE, 'Prebid global module'); + + if (entry.moduleName === 'liveIntentIdSystem') { + return; + } + + try { + require.resolve(entry.importPath, { paths: [__dirname] }); + } catch (error) { + throw new Error( + `[build-prebid-external] Required Prebid user ID module "${entry.moduleName}" ` + + `could not be resolved from ${entry.importPath}: ${error.message}` + ); + } +} + +function generateAdapterImports(adapterNames) { + const modulesDir = path.join(PREBID_PACKAGE_DIR, 'modules'); + const imports = []; + const validAdapters = []; + + for (const name of adapterNames) { + const moduleFile = `${name}BidAdapter.js`; + const modulePath = path.join(modulesDir, moduleFile); + if (!fs.existsSync(modulePath)) { + throw new Error( + `[build-prebid-external] Prebid adapter "${name}" not found (expected ${moduleFile})` + ); + } + imports.push(`import 'prebid.js/modules/${moduleFile}';`); + validAdapters.push(name); + } + + const content = [ + '// Auto-generated by build-prebid-external.mjs — manual edits will be overwritten.', + '//', + '// External Prebid bundle adapter imports.', + `// Modules: ${validAdapters.join(', ')}`, + '', + ...imports, + '', + ].join('\n'); + + fs.writeFileSync(ADAPTERS_FILE, content); + return validAdapters; +} + +function generateUserIdImports(requestedModules) { + const registry = readUserIdRegistry(); + const entriesByModule = new Map(registry.modules.map((entry) => [entry.moduleName, entry])); + const moduleNames = requestedModules ?? registry.defaultPreset; + const selectedEntries = moduleNames.map((moduleName) => { + const entry = entriesByModule.get(moduleName); + if (!entry) { + throw new Error(`[build-prebid-external] Unknown Prebid user ID module: ${moduleName}`); + } + validateUserIdImport(entry); + return entry; + }); + + const imports = selectedEntries.map((entry) => `import '${entry.importPath}';`); + const content = [ + '// Auto-generated by build-prebid-external.mjs — manual edits will be overwritten.', + '//', + '// External Prebid bundle User ID module imports.', + `// Modules: ${moduleNames.join(', ')}`, + '', + ...imports, + '', + ].join('\n'); + + fs.writeFileSync(USER_IDS_FILE, content); + return moduleNames; +} + +async function buildExternalBundle(outDir) { + fs.mkdirSync(outDir, { recursive: true }); + + const temporaryFile = 'trusted-prebid.tmp.js'; + const temporaryPath = path.join(outDir, temporaryFile); + fs.rmSync(temporaryPath, { force: true }); + + await build({ + configFile: false, + root: __dirname, + resolve: { + alias: { + [LIVE_INTENT_SHIM_ALIAS]: LIVE_INTENT_SHIM, + 'prebid.js/modules/liveIntentIdSystem': LIVE_INTENT_SHIM, + 'tsjs-prebid/liveIntentIdSystemStandard': PREBID_LIVE_INTENT_STANDARD, + 'tsjs-prebid/prebidGlobal': PREBID_GLOBAL_MODULE, + 'prebid.js/src/adapterManager.js': path.resolve( + __dirname, + 'node_modules/prebid.js/dist/src/src/adapterManager.js' + ), + }, + }, + build: { + emptyOutDir: false, + outDir, + assetsDir: '.', + sourcemap: false, + minify: 'esbuild', + rollupOptions: { + input: path.join(prebidDir, 'index.ts'), + output: { + format: 'iife', + dir: outDir, + entryFileNames: temporaryFile, + inlineDynamicImports: true, + extend: false, + name: 'tsjs_prebid_external', + }, + }, + }, + logLevel: 'warn', + }); + + const bundleBytes = fs.readFileSync(temporaryPath); + const sha256 = crypto.createHash('sha256').update(bundleBytes).digest('hex'); + const sri = `sha384-${crypto.createHash('sha384').update(bundleBytes).digest('base64')}`; + const filename = `trusted-prebid-${sha256}.js`; + const finalPath = path.join(outDir, filename); + + fs.rmSync(finalPath, { force: true }); + fs.renameSync(temporaryPath, finalPath); + + return { filename, sha256, sri }; +} + +const args = parseArgs(process.argv.slice(2)); +const originalAdapters = readIfExists(ADAPTERS_FILE); +const originalUserIds = readIfExists(USER_IDS_FILE); + +try { + const adapters = generateAdapterImports(args.adapters); + const userIdModules = generateUserIdImports(args.userIdModules); + const bundle = await buildExternalBundle(args.outDir); + const manifest = { + prebidVersion: prebidPackageVersion(), + adapters, + userIdModules, + sha256: bundle.sha256, + sri: bundle.sri, + filename: bundle.filename, + }; + + fs.writeFileSync( + path.join(args.outDir, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); + + console.log('[build-prebid-external] Built external Prebid bundle:', bundle.filename); + console.log('[build-prebid-external] SHA-256:', bundle.sha256); + console.log('[build-prebid-external] SRI:', bundle.sri); + console.log('[build-prebid-external] Manifest:', path.join(args.outDir, 'manifest.json')); +} finally { + restoreFile(ADAPTERS_FILE, originalAdapters); + restoreFile(USER_IDS_FILE, originalUserIds); +} diff --git a/crates/js/lib/package.json b/crates/js/lib/package.json index 9beca211..2ffed57e 100644 --- a/crates/js/lib/package.json +++ b/crates/js/lib/package.json @@ -6,6 +6,7 @@ "description": "Trusted Server tsjs TypeScript library with queue and simple banner rendering.", "scripts": { "build": "node build-all.mjs", + "build:prebid-external": "node build-prebid-external.mjs", "dev": "vite build --watch", "test": "vitest run", "test:watch": "vitest", diff --git a/crates/js/lib/src/integrations/prebid/_adapters.generated.ts b/crates/js/lib/src/integrations/prebid/_adapters.generated.ts index baf65ce9..b48904b9 100644 --- a/crates/js/lib/src/integrations/prebid/_adapters.generated.ts +++ b/crates/js/lib/src/integrations/prebid/_adapters.generated.ts @@ -1,8 +1,6 @@ -// Auto-generated by build-all.mjs — manual edits will be overwritten at build time. +// Auto-generated by build-prebid-external.mjs — manual edits will be overwritten. // -// Controls which Prebid.js bid adapters are included in the bundle. -// Set the TSJS_PREBID_ADAPTERS environment variable to a comma-separated list -// of adapter names (e.g. "rubicon,appnexus,openx") before building. -// Default: "rubicon" +// External Prebid bundle adapter imports. +// Modules: rubicon import 'prebid.js/modules/rubiconBidAdapter.js'; diff --git a/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts b/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts index 24e80285..8911dbd2 100644 --- a/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts +++ b/crates/js/lib/src/integrations/prebid/_user_ids.generated.ts @@ -1,8 +1,6 @@ -// Auto-generated by build-all.mjs — manual edits will be overwritten at build time. +// Auto-generated by build-prebid-external.mjs — manual edits will be overwritten. // -// Deterministic Prebid.js user ID module preset for attested builds. -// TSJS_PREBID_USER_ID_MODULES is intentionally ignored in production builds. -// Use TSJS_PREBID_USER_ID_MODULES_DEV_OVERRIDE only for local experiments. +// External Prebid bundle User ID module imports. // Modules: connectIdSystem, criteoIdSystem, id5IdSystem, identityLinkIdSystem, liveIntentIdSystem, pubProvidedIdSystem, sharedIdSystem, uid2IdSystem, unifiedIdSystem import 'prebid.js/modules/connectIdSystem.js'; diff --git a/crates/js/lib/src/integrations/prebid/index.ts b/crates/js/lib/src/integrations/prebid/index.ts index 6038f42a..156efbbe 100644 --- a/crates/js/lib/src/integrations/prebid/index.ts +++ b/crates/js/lib/src/integrations/prebid/index.ts @@ -19,10 +19,8 @@ import 'prebid.js/modules/consentManagementUsp.js'; import 'prebid.js/modules/userId.js'; // Client-side bid adapters — self-register with prebid.js on import. -// The set of adapters is controlled by the TSJS_PREBID_ADAPTERS env var at -// build time. See _adapters.generated.ts (written by build-all.mjs). -// User ID submodules come from the deterministic attested preset in -// user_id_modules.json. See _user_ids.generated.ts. +// The external bundle generator writes _adapters.generated.ts and +// _user_ids.generated.ts from its --adapters and --user-id-modules options. // When a bidder is listed in `client_side_bidders` in trusted-server.toml, // the requestBids shim leaves its bids untouched and the corresponding // adapter handles them natively in the browser. @@ -156,7 +154,9 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { } for (const name of missingConfiguredUserIdNames) { - log.warn(`[tsjs-prebid] configured User ID module "${name}" is not included in TSJS`); + log.warn( + `[tsjs-prebid] configured User ID module "${name}" is not included in the external bundle` + ); } return diagnostics; @@ -462,21 +462,21 @@ export function installPrebidNpm(config?: Partial): typeof pbjs // Validate that every client-side bidder has its adapter registered. // Adapters self-register on import, so a missing adapter means the bidder - // was listed in client_side_bidders but not in TSJS_PREBID_ADAPTERS at - // build time. Without the adapter the bidder is silently dropped from both - // server-side and client-side auctions. + // was listed in client_side_bidders but not included in the generated + // external Prebid bundle. Without the adapter the bidder is silently dropped + // from both server-side and client-side auctions. for (const bidder of clientSideBidders) { try { if (!adapterManager.getBidAdapter(bidder)) { log.error( `[tsjs-prebid] client-side bidder "${bidder}" has no adapter loaded. ` + - `Add it to TSJS_PREBID_ADAPTERS at build time.` + `Add it to build-prebid-external.mjs --adapters.` ); } } catch { log.error( `[tsjs-prebid] client-side bidder "${bidder}" has no adapter loaded. ` + - `Add it to TSJS_PREBID_ADAPTERS at build time.` + `Add it to build-prebid-external.mjs --adapters.` ); } } diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 9aa4c54a..4dd19d58 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -143,6 +143,7 @@ fn create_test_settings() -> Settings { [integrations.prebid] enabled = true server_url = "https://test-prebid.com/openrtb2/auction" + external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" [auction] enabled = true diff --git a/crates/trusted-server-core/README.md b/crates/trusted-server-core/README.md index 6f323456..3049a111 100644 --- a/crates/trusted-server-core/README.md +++ b/crates/trusted-server-core/README.md @@ -41,7 +41,7 @@ Helpers: JS bundles (served by publisher module): - Dynamic endpoint: `/static/tsjs=tsjs-unified.min.js?v=` - - At build time, each integration is compiled as a separate IIFE (`tsjs-core.js`, `tsjs-prebid.js`, `tsjs-creative.js`, etc.) + - At build time, embedded integrations are compiled as separate IIFEs (`tsjs-core.js`, `tsjs-creative.js`, etc.); Prebid is generated externally and served through `/integrations/prebid/bundle.js`. - At runtime, the server concatenates `tsjs-core.js` + enabled integration modules based on `IntegrationRegistry` config - The URL filename is fixed for backward compatibility; the `?v=` hash changes when modules change diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 418b5b1a..1026d146 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -3,12 +3,18 @@ use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; +use base64::{ + engine::general_purpose::{ + STANDARD as BASE64_STANDARD, STANDARD_NO_PAD as BASE64_STANDARD_NO_PAD, + }, + Engine as _, +}; use error_stack::{Report, ResultExt}; use fastly::http::{header, Method, StatusCode, Url}; use fastly::{Request, Response}; use serde::{Deserialize, Serialize}; use serde_json::Value as Json; -use validator::Validate; +use validator::{Validate, ValidationError}; use crate::auction::provider::AuctionProvider; use crate::auction::types::{ @@ -29,10 +35,17 @@ use crate::openrtb::{ OpenRtbRequest, PrebidExt, PrebidImpExt, Publisher, Regs, RegsExt, RequestExt, Site, ToExt, TrustedServerExt, User, UserExt, }; +use crate::proxy::{is_host_allowed, proxy_request, ProxyRequestConfig}; use crate::request_signing::{RequestSigner, SigningParams, SIGNING_VERSION}; use crate::settings::{IntegrationConfig, Settings}; const PREBID_INTEGRATION_ID: &str = "prebid"; +const PREBID_BUNDLE_ROUTE: &str = "/integrations/prebid/bundle.js"; +const PREBID_BUNDLE_CONTENT_TYPE: &str = "application/javascript; charset=utf-8"; +const PREBID_BUNDLE_IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable"; +const PREBID_BUNDLE_REVALIDATION_CACHE_CONTROL: &str = + "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400"; +const PREBID_BUNDLE_ERROR_CACHE_CONTROL: &str = "no-store"; const TRUSTED_SERVER_BIDDER: &str = "trustedServer"; const BIDDER_PARAMS_KEY: &str = "bidderParams"; const ZONE_KEY: &str = "zone"; @@ -85,6 +98,18 @@ pub struct PrebidIntegrationConfig { deserialize_with = "crate::settings::vec_from_seq_or_map" )] pub script_patterns: Vec, + /// Absolute HTTPS URL of the generated external Prebid bundle. + #[serde(default)] + #[validate(custom(function = "validate_external_bundle_url"))] + pub external_bundle_url: Option, + /// Optional hex SHA-256 of the exact external bundle bytes. + #[serde(default)] + #[validate(custom(function = "validate_external_bundle_sha256"))] + pub external_bundle_sha256: Option, + /// Optional browser Subresource Integrity value for the first-party script. + #[serde(default)] + #[validate(custom(function = "validate_external_bundle_sri"))] + pub external_bundle_sri: Option, /// Bidders that should run client-side in the browser via native Prebid.js /// adapters instead of being routed through the server-side auction. /// @@ -226,6 +251,163 @@ fn default_script_patterns() -> Vec { .collect() } +fn validate_external_bundle_url(value: &str) -> Result<(), ValidationError> { + let url = Url::parse(value).map_err(|_| { + let mut err = ValidationError::new("invalid_external_bundle_url"); + err.message = Some("external_bundle_url must be a valid absolute URL".into()); + err + })?; + + if url.scheme() != "https" { + let mut err = ValidationError::new("invalid_external_bundle_scheme"); + err.message = Some("external_bundle_url must use https".into()); + return Err(err); + } + + if url.host_str().is_none() { + let mut err = ValidationError::new("missing_external_bundle_host"); + err.message = Some("external_bundle_url must include a host".into()); + return Err(err); + } + + Ok(()) +} + +fn validate_external_bundle_sha256(value: &str) -> Result<(), ValidationError> { + if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Ok(()); + } + + let mut err = ValidationError::new("invalid_external_bundle_sha256"); + err.message = Some("external_bundle_sha256 must be a 64-character hex SHA-256".into()); + Err(err) +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum ExternalBundleSriAlgorithm { + Sha256, + Sha384, + Sha512, +} + +impl ExternalBundleSriAlgorithm { + fn parse(value: &str) -> Option { + match value { + "sha256" => Some(Self::Sha256), + "sha384" => Some(Self::Sha384), + "sha512" => Some(Self::Sha512), + _ => None, + } + } + + fn expected_digest_len(self) -> usize { + match self { + Self::Sha256 => 32, + Self::Sha384 => 48, + Self::Sha512 => 64, + } + } +} + +fn external_bundle_sri_validation_error(message: &'static str) -> ValidationError { + let mut err = ValidationError::new("invalid_external_bundle_sri"); + err.message = Some(message.into()); + err +} + +fn parse_external_bundle_sri(value: &str) -> Result<(), ValidationError> { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed != value { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri must be non-empty with no surrounding whitespace", + )); + } + + for token in trimmed.split_ascii_whitespace() { + let Some((algorithm_raw, digest_raw)) = token.split_once('-') else { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri entries must use algorithm-digest format", + )); + }; + + let Some(algorithm) = ExternalBundleSriAlgorithm::parse(algorithm_raw) else { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri must use sha256, sha384, or sha512", + )); + }; + + if digest_raw.is_empty() { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri digest must be non-empty", + )); + } + + let digest = BASE64_STANDARD + .decode(digest_raw) + .or_else(|_| BASE64_STANDARD_NO_PAD.decode(digest_raw)) + .map_err(|_| { + external_bundle_sri_validation_error("external_bundle_sri digest must be base64") + })?; + + if digest.len() != algorithm.expected_digest_len() { + return Err(external_bundle_sri_validation_error( + "external_bundle_sri digest length does not match its algorithm", + )); + } + } + + Ok(()) +} + +fn validate_external_bundle_sri(value: &str) -> Result<(), ValidationError> { + parse_external_bundle_sri(value) +} + +fn validate_external_bundle_config( + config: &PrebidIntegrationConfig, + allowed_domains: &[String], +) -> Result<(), Report> { + let url = config.external_bundle_url.as_deref().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_url is required when prebid is enabled" + .to_string(), + }) + })?; + + let parsed = Url::parse(url).map_err(|_| { + Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_url must be a valid absolute URL" + .to_string(), + }) + })?; + + if parsed.scheme() != "https" { + return Err(Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_url must use https".to_string(), + })); + } + + let host = parsed.host_str().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_url must include a host".to_string(), + }) + })?; + + if !allowed_domains.is_empty() + && !allowed_domains + .iter() + .any(|pattern| is_host_allowed(host, pattern)) + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "integrations.prebid.external_bundle_url host `{host}` is not permitted by proxy.allowed_domains" + ), + })); + } + + Ok(()) +} + pub struct PrebidIntegration { config: PrebidIntegrationConfig, engine: Arc, @@ -319,13 +501,150 @@ impl PrebidIntegration { let body = "// Script overridden by Trusted Server\n"; Ok(Response::from_status(StatusCode::OK) - .with_header( - header::CONTENT_TYPE, - "application/javascript; charset=utf-8", - ) + .with_header(header::CONTENT_TYPE, PREBID_BUNDLE_CONTENT_TYPE) .with_header(header::CACHE_CONTROL, "public, max-age=31536000") .with_body(body)) } + + fn external_bundle_script_src(&self) -> String { + match self.config.external_bundle_sha256.as_deref() { + Some(sha256) => format!("{PREBID_BUNDLE_ROUTE}?v={sha256}"), + None => PREBID_BUNDLE_ROUTE.to_string(), + } + } + + fn external_bundle_script_tag(&self) -> String { + let src = self.external_bundle_script_src(); + let integrity = self + .config + .external_bundle_sri + .as_deref() + .map(|value| format!(" integrity=\"{}\"", escape_html_attr(value))) + .unwrap_or_default(); + + format!("") + } + + fn external_bundle_request_cache_mode( + &self, + req: &Request, + ) -> Result, Report> { + let parsed = Url::parse(req.get_url_str()).map_err(|_| { + Report::new(TrustedServerError::BadRequest { + message: "invalid request URL".to_string(), + }) + })?; + let versions = parsed + .query_pairs() + .filter(|(key, _)| key == "v") + .map(|(_, value)| value.into_owned()) + .collect::>(); + + if versions.len() > 1 { + return Ok(None); + } + + let requested_version = versions.first().map(String::as_str); + match ( + self.config.external_bundle_sha256.as_deref(), + requested_version, + ) { + (None, Some(_)) => Ok(None), + (Some(expected), Some(actual)) if expected != actual => Ok(None), + (Some(_), Some(_)) => Ok(Some(ExternalBundleCacheMode::Immutable)), + _ => Ok(Some(ExternalBundleCacheMode::Revalidate)), + } + } + + fn apply_external_bundle_headers( + &self, + response: &mut Response, + mode: ExternalBundleCacheMode, + ) { + response.set_header(header::CONTENT_TYPE, PREBID_BUNDLE_CONTENT_TYPE); + match mode { + ExternalBundleCacheMode::Immutable => { + response.set_header(header::CACHE_CONTROL, PREBID_BUNDLE_IMMUTABLE_CACHE_CONTROL); + if let Some(sha256) = self.config.external_bundle_sha256.as_deref() { + response.set_header(header::ETAG, format!("\"sha256:{sha256}\"")); + } + } + ExternalBundleCacheMode::Revalidate => { + response.set_header( + header::CACHE_CONTROL, + PREBID_BUNDLE_REVALIDATION_CACHE_CONTROL, + ); + if let Some(sha256) = self.config.external_bundle_sha256.as_deref() { + response.set_header(header::ETAG, format!("\"sha256:{sha256}\"")); + } + } + } + } + + fn sanitize_external_bundle_response( + &self, + mut response: Response, + mode: ExternalBundleCacheMode, + ) -> Response { + let status = response.get_status(); + let content_encoding = response.get_header(header::CONTENT_ENCODING).cloned(); + let body = response.take_body(); + + let mut sanitized = Response::from_status(status).with_body(body); + if let Some(content_encoding) = content_encoding { + sanitized.set_header(header::CONTENT_ENCODING, content_encoding); + } + + if status == StatusCode::OK { + self.apply_external_bundle_headers(&mut sanitized, mode); + } else { + sanitized.set_header(header::CACHE_CONTROL, PREBID_BUNDLE_ERROR_CACHE_CONTROL); + } + + sanitized + } + + async fn handle_external_bundle( + &self, + settings: &Settings, + req: Request, + ) -> Result> { + let Some(cache_mode) = self.external_bundle_request_cache_mode(&req)? else { + return Ok(Response::from_status(StatusCode::NOT_FOUND).with_body("Not Found")); + }; + + let target_url = self.config.external_bundle_url.as_deref().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: + "integrations.prebid.external_bundle_url is required when prebid is enabled" + .to_string(), + }) + })?; + + let proxy_config = ProxyRequestConfig::new(target_url) + .without_ec_id() + .without_forward_headers() + .with_streaming() + .with_allowed_domains(&settings.proxy.allowed_domains) + .with_https_only(); + + let response = proxy_request(settings, req, proxy_config).await?; + Ok(self.sanitize_external_bundle_response(response, cache_mode)) + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum ExternalBundleCacheMode { + Immutable, + Revalidate, +} + +fn escape_html_attr(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") } fn build( @@ -337,6 +656,8 @@ fn build( return Ok(None); }; + validate_external_bundle_config(&config, &settings.proxy.allowed_domains)?; + // Warn about bidders that appear in both lists — this is likely a config // mistake. A bidder should be in either `bidders` (server-side) or // `client_side_bidders` (browser-side), not both. @@ -371,7 +692,7 @@ pub fn register( .with_proxy(integration.clone()) .with_attribute_rewriter(integration.clone()) .with_head_injector(integration) - .with_deferred_js() + .without_js() .build(), )) } @@ -385,6 +706,8 @@ impl IntegrationProxy for PrebidIntegration { fn routes(&self) -> Vec { let mut routes = vec![]; + routes.push(self.get("/bundle.js")); + // Register routes for script removal patterns // Patterns can be exact paths (e.g., "/prebid.min.js") or use matchit wildcards // (e.g., "/static/prebid/{*rest}") @@ -400,13 +723,16 @@ impl IntegrationProxy for PrebidIntegration { async fn handle( &self, - _settings: &Settings, + settings: &Settings, req: Request, ) -> Result> { let path = req.get_path().to_string(); let method = req.get_method().clone(); match method { + Method::GET if path == PREBID_BUNDLE_ROUTE => { + self.handle_external_bundle(settings, req).await + } // Serve empty JS for matching script patterns Method::GET if self.matches_script_pattern(&path) => self.handle_script_handler(), _ => Ok(Response::from_status(StatusCode::NOT_FOUND).with_body("Not Found")), @@ -470,9 +796,13 @@ impl IntegrationHeadInjector for PrebidIntegration { }) .replace("window.pbjs=window.pbjs||{{}};window.pbjs.que=window.pbjs.que||[];window.pbjs.cmd=window.pbjs.cmd||[];window.__tsjs_prebid={config_json};"# - )] + )]; + + inserts.push(self.external_bundle_script_tag()); + + inserts } } @@ -1611,6 +1941,7 @@ mod tests { use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; use crate::test_support::tests::crate_test_settings_str; + use base64::engine::general_purpose::STANDARD as TEST_BASE64_STANDARD; use fastly::http::Method; use fastly::Request; use serde_json::json; @@ -1632,6 +1963,11 @@ mod tests { test_mode: false, debug_query_params: None, script_patterns: default_script_patterns(), + external_bundle_url: Some( + "https://assets.example/prebid/trusted-prebid.js".to_string(), + ), + external_bundle_sha256: None, + external_bundle_sri: None, client_side_bidders: Vec::new(), bid_param_zone_overrides: HashMap::default(), bid_param_overrides: HashMap::default(), @@ -1640,6 +1976,10 @@ mod tests { } } + fn test_sri(algorithm: &str, digest: &[u8]) -> String { + format!("{algorithm}-{}", TEST_BASE64_STANDARD.encode(digest)) + } + fn create_test_auction_request() -> AuctionRequest { AuctionRequest { id: "auction-123".to_string(), @@ -1787,6 +2127,7 @@ passphrase = "test-secret-key-32-bytes-minimum" &json!({ "enabled": true, "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, "bidders": ["mocktioneer"], "script_patterns": [], @@ -1837,6 +2178,7 @@ passphrase = "test-secret-key-32-bytes-minimum" &json!({ "enabled": true, "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, "bidders": ["mocktioneer"], "script_patterns": ["/prebid.js", "/prebid.min.js"], @@ -1871,8 +2213,12 @@ passphrase = "test-secret-key-32-bytes-minimum" "Prebid preload should be removed when auto-config is enabled" ); assert!( - processed.contains("tsjs-prebid.min.js"), - "Deferred prebid bundle should be injected" + processed.contains(PREBID_BUNDLE_ROUTE), + "External prebid bundle route should be injected" + ); + assert!( + !processed.contains("tsjs-prebid.min.js"), + "Embedded deferred prebid bundle should not be injected" ); } @@ -1919,6 +2265,204 @@ server_url = "https://prebid.example" .contains(&"/prebid.min.js".to_string())); } + #[test] + fn external_bundle_config_parses_with_optional_hash_metadata() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +"#, + ); + + assert_eq!( + config.external_bundle_url.as_deref(), + Some("https://assets.example/prebid/trusted-prebid.js"), + "should preserve configured external bundle URL" + ); + assert!( + config.external_bundle_sha256.is_none(), + "SHA-256 should be optional" + ); + } + + #[test] + fn external_bundle_config_rejects_malformed_hash_metadata() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +external_bundle_sha256 = "not-a-sha" +"#, + ) + .expect_err("should reject malformed SHA-256"); + + assert!( + err.to_string().contains("external_bundle_sha256"), + "error should mention malformed SHA-256: {err:?}" + ); + } + + #[test] + fn external_bundle_config_rejects_non_https_bundle_url() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +external_bundle_url = "http://assets.example/prebid/trusted-prebid.js" +"#, + ) + .expect_err("should reject non-HTTPS external bundle URL"); + + assert!( + err.to_string().contains("external_bundle_url"), + "error should mention external bundle URL: {err:?}" + ); + } + + #[test] + fn external_bundle_config_rejects_invalid_sri_base64() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +external_bundle_sri = "sha384-not-valid!!!" +"#, + ) + .expect_err("should reject invalid SRI base64"); + + assert!( + err.to_string().contains("external_bundle_sri"), + "error should mention external bundle SRI: {err:?}" + ); + } + + #[test] + fn external_bundle_config_rejects_sri_with_wrong_digest_length() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +external_bundle_sri = "sha384-AAAA" +"#, + ) + .expect_err("should reject SRI with wrong digest length"); + + assert!( + err.to_string().contains("external_bundle_sri"), + "error should mention external bundle SRI: {err:?}" + ); + } + + #[test] + fn external_bundle_registration_allows_sha256_without_sri() { + let mut settings = make_settings(); + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", + "external_bundle_sha256": "0".repeat(64) + }), + ) + .expect("should update prebid config"); + + let registry = IntegrationRegistry::new(&settings) + .expect("should create registry with valid SHA-256 and no SRI"); + + assert!( + registry.has_route(&Method::GET, PREBID_BUNDLE_ROUTE), + "should register external bundle route" + ); + } + + #[test] + fn external_bundle_registration_allows_sha256_with_valid_sha384_sri() { + let mut settings = make_settings(); + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", + "external_bundle_sha256": "0".repeat(64), + "external_bundle_sri": test_sri("sha384", &[0; 48]) + }), + ) + .expect("should update prebid config"); + + let registry = IntegrationRegistry::new(&settings) + .expect("should create registry with valid SHA-256 and SHA-384 SRI"); + + assert!( + registry.has_route(&Method::GET, PREBID_BUNDLE_ROUTE), + "should register external bundle route" + ); + } + + #[test] + fn external_bundle_registration_requires_bundle_url() { + let mut settings = make_settings(); + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction" + }), + ) + .expect("should update prebid config"); + + let err = match IntegrationRegistry::new(&settings) { + Ok(_) => panic!("should reject missing URL"), + Err(err) => err, + }; + assert!( + err.to_string().contains("external_bundle_url"), + "error should mention missing external bundle URL: {err:?}" + ); + } + + #[test] + fn external_bundle_registration_uses_proxy_allowed_domains() { + let mut settings = make_settings(); + settings.proxy.allowed_domains = vec!["allowed.example".to_string()]; + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction", + "external_bundle_url": "https://blocked.example/prebid/trusted-prebid.js" + }), + ) + .expect("should update prebid config"); + + let err = match IntegrationRegistry::new(&settings) { + Ok(_) => panic!("should reject bundle host outside proxy.allowed_domains"), + Err(err) => err, + }; + assert!( + err.to_string().contains("proxy.allowed_domains"), + "error should mention proxy.allowed_domains: {err:?}" + ); + } + #[test] fn script_handler_returns_empty_js() { let integration = PrebidIntegration::new(base_config()); @@ -1943,6 +2487,215 @@ server_url = "https://prebid.example" assert!(body.contains("// Script overridden by Trusted Server")); } + #[test] + fn external_bundle_request_cache_mode_validates_version_query() { + let sha256 = "a".repeat(64); + let mut config = base_config(); + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + config.external_bundle_sha256 = Some(sha256.clone()); + let integration = PrebidIntegration::new(config); + + let versioned_req = Request::get(format!( + "https://pub.example{PREBID_BUNDLE_ROUTE}?v={sha256}" + )); + let missing_version_req = Request::get(format!("https://pub.example{PREBID_BUNDLE_ROUTE}")); + let mismatched_req = Request::get(format!( + "https://pub.example{PREBID_BUNDLE_ROUTE}?v={}", + "b".repeat(64) + )); + + assert_eq!( + integration + .external_bundle_request_cache_mode(&versioned_req) + .expect("should parse versioned request"), + Some(ExternalBundleCacheMode::Immutable), + "matching v query should use immutable cache mode" + ); + assert_eq!( + integration + .external_bundle_request_cache_mode(&missing_version_req) + .expect("should parse unversioned request"), + Some(ExternalBundleCacheMode::Revalidate), + "missing v query should use revalidation cache mode" + ); + assert_eq!( + integration + .external_bundle_request_cache_mode(&mismatched_req) + .expect("should parse mismatched request"), + None, + "mismatched v query should 404" + ); + } + + #[test] + fn external_bundle_request_cache_mode_rejects_version_when_hash_is_absent() { + let mut config = base_config(); + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + let integration = PrebidIntegration::new(config); + + let versioned_req = Request::get(format!( + "https://pub.example{PREBID_BUNDLE_ROUTE}?v={}", + "a".repeat(64) + )); + let unversioned_req = Request::get(format!("https://pub.example{PREBID_BUNDLE_ROUTE}")); + + assert_eq!( + integration + .external_bundle_request_cache_mode(&versioned_req) + .expect("should parse versioned request"), + None, + "v query should 404 when SHA-256 is omitted" + ); + assert_eq!( + integration + .external_bundle_request_cache_mode(&unversioned_req) + .expect("should parse unversioned request"), + Some(ExternalBundleCacheMode::Revalidate), + "unversioned request should be served with revalidation cache mode" + ); + } + + #[test] + fn external_bundle_headers_use_cache_policy_for_mode() { + let sha256 = "a".repeat(64); + let mut config = base_config(); + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + config.external_bundle_sha256 = Some(sha256.clone()); + config.external_bundle_sri = Some(test_sri("sha384", &[0; 48])); + let integration = PrebidIntegration::new(config); + + let mut immutable = Response::from_status(StatusCode::OK); + integration + .apply_external_bundle_headers(&mut immutable, ExternalBundleCacheMode::Immutable); + assert_eq!( + immutable.get_header_str(header::CONTENT_TYPE), + Some(PREBID_BUNDLE_CONTENT_TYPE), + "should normalize JS content type" + ); + assert_eq!( + immutable.get_header_str(header::CACHE_CONTROL), + Some(PREBID_BUNDLE_IMMUTABLE_CACHE_CONTROL), + "versioned responses should be immutable" + ); + assert_eq!( + immutable.get_header_str(header::ETAG), + Some(format!("\"sha256:{sha256}\"").as_str()), + "should emit configured hash ETag" + ); + + let mut revalidate = Response::from_status(StatusCode::OK); + integration + .apply_external_bundle_headers(&mut revalidate, ExternalBundleCacheMode::Revalidate); + assert_eq!( + revalidate.get_header_str(header::CACHE_CONTROL), + Some(PREBID_BUNDLE_REVALIDATION_CACHE_CONTROL), + "unversioned responses should use short-lived revalidation" + ); + } + + #[test] + fn external_bundle_response_sanitization_uses_header_whitelist_for_ok_response() { + let sha256 = "a".repeat(64); + let mut config = base_config(); + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + config.external_bundle_sha256 = Some(sha256.clone()); + config.external_bundle_sri = Some(test_sri("sha384", &[0; 48])); + let integration = PrebidIntegration::new(config); + + let mut upstream = Response::from_status(StatusCode::OK).with_body("console.log('ok');"); + upstream.set_header(header::CONTENT_TYPE, "text/html"); + upstream.set_header(header::CACHE_CONTROL, "private, max-age=0"); + upstream.set_header(header::SET_COOKIE, "bad=1; Path=/"); + upstream.set_header(header::CONTENT_ENCODING, "gzip"); + upstream.set_header(header::CONTENT_LENGTH, "16"); + upstream.set_header("X-Upstream", "leak"); + + let mut sanitized = integration + .sanitize_external_bundle_response(upstream, ExternalBundleCacheMode::Immutable); + + assert_eq!( + sanitized.get_header_str(header::CONTENT_TYPE), + Some(PREBID_BUNDLE_CONTENT_TYPE), + "should normalize JS content type" + ); + assert_eq!( + sanitized.get_header_str(header::CACHE_CONTROL), + Some(PREBID_BUNDLE_IMMUTABLE_CACHE_CONTROL), + "should apply trusted cache policy" + ); + assert_eq!( + sanitized.get_header_str(header::ETAG), + Some(format!("\"sha256:{sha256}\"").as_str()), + "should emit trusted ETag" + ); + assert_eq!( + sanitized.get_header_str(header::CONTENT_ENCODING), + Some("gzip"), + "should preserve body encoding metadata" + ); + assert!( + sanitized.get_header(header::CONTENT_LENGTH).is_none(), + "should strip upstream content length so the platform can derive it from the body" + ); + assert!( + sanitized.get_header(header::SET_COOKIE).is_none(), + "should strip upstream Set-Cookie" + ); + assert!( + sanitized.get_header("X-Upstream").is_none(), + "should strip arbitrary upstream headers" + ); + assert_eq!( + sanitized.take_body_str(), + "console.log('ok');", + "should preserve body bytes" + ); + } + + #[test] + fn external_bundle_response_sanitization_strips_headers_for_error_response() { + let mut config = base_config(); + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + let integration = PrebidIntegration::new(config); + + let mut upstream = Response::from_status(StatusCode::NOT_FOUND).with_body("missing"); + upstream.set_header(header::CONTENT_TYPE, "text/html"); + upstream.set_header(header::CACHE_CONTROL, "public, max-age=31536000"); + upstream.set_header(header::SET_COOKIE, "bad=1; Path=/"); + upstream.set_header("X-Upstream", "leak"); + + let sanitized = integration + .sanitize_external_bundle_response(upstream, ExternalBundleCacheMode::Revalidate); + + assert_eq!( + sanitized.get_status(), + StatusCode::NOT_FOUND, + "should preserve upstream status" + ); + assert_eq!( + sanitized.get_header_str(header::CACHE_CONTROL), + Some(PREBID_BUNDLE_ERROR_CACHE_CONTROL), + "should prevent caching upstream error responses" + ); + assert!( + sanitized.get_header(header::CONTENT_TYPE).is_none(), + "should strip upstream content type on error responses" + ); + assert!( + sanitized.get_header(header::SET_COOKIE).is_none(), + "should strip upstream Set-Cookie on error responses" + ); + assert!( + sanitized.get_header("X-Upstream").is_none(), + "should strip arbitrary upstream headers on error responses" + ); + } + #[test] fn routes_include_script_patterns() { let integration = PrebidIntegration::new(base_config()); @@ -1964,6 +2717,12 @@ server_url = "https://prebid.example" has_prebid_min_js_route, "should register /prebid.min.js route" ); + assert!( + routes + .iter() + .any(|r| r.path == PREBID_BUNDLE_ROUTE && r.method == Method::GET), + "should register the bundle route" + ); } #[test] @@ -1978,7 +2737,7 @@ server_url = "https://prebid.example" }; let inserts = integration.head_inserts(&ctx); - assert_eq!(inserts.len(), 1, "should produce exactly one head insert"); + assert_eq!(inserts.len(), 2, "should produce config and bundle inserts"); let script = &inserts[0]; assert!( @@ -2029,6 +2788,72 @@ server_url = "https://prebid.example" ); } + #[test] + fn head_injector_emits_external_bundle_script_with_hash_and_integrity() { + let sha256 = "a".repeat(64); + let mut config = base_config(); + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + config.external_bundle_sha256 = Some(sha256.clone()); + config.external_bundle_sri = Some(test_sri("sha384", &[0; 48])); + let integration = PrebidIntegration::new(config); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&ctx); + + assert_eq!(inserts.len(), 2, "should emit config and bundle scripts"); + assert!( + inserts[1].contains(&format!("src=\"{PREBID_BUNDLE_ROUTE}?v={sha256}\"")), + "bundle script should use content-addressed first-party URL: {}", + inserts[1] + ); + assert!( + inserts[1].contains("integrity=\"sha384-"), + "bundle script should include configured SRI: {}", + inserts[1] + ); + assert!( + !inserts[1].contains("crossorigin"), + "same-origin bundle script should not include crossorigin: {}", + inserts[1] + ); + } + + #[test] + fn head_injector_emits_external_bundle_script_without_hash_query_when_unhashed() { + let mut config = base_config(); + config.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + let integration = PrebidIntegration::new(config); + let document_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &document_state, + }; + + let inserts = integration.head_inserts(&ctx); + + assert_eq!(inserts.len(), 2, "should emit config and bundle scripts"); + assert!( + inserts[1].contains(&format!("src=\"{PREBID_BUNDLE_ROUTE}\"")), + "bundle script should use first-party route without hash query: {}", + inserts[1] + ); + assert!( + !inserts[1].contains("?v="), + "unhashed bundle script should not include version query: {}", + inserts[1] + ); + } + #[test] fn head_injector_escapes_closing_script_tags_in_values() { let mut config = base_config(); @@ -3185,8 +4010,17 @@ server_url = "https://prebid.example" let routes = integration.routes(); - // Should have 0 routes when no script patterns configured - assert_eq!(routes.len(), 0); + assert_eq!( + routes.len(), + 1, + "should keep bundle route when no script patterns configured" + ); + assert!( + routes + .iter() + .any(|route| route.path == PREBID_BUNDLE_ROUTE && route.method == Method::GET), + "should register the bundle route" + ); } /// Verifies body-preview truncation keeps a UTF-8 char boundary. diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 389dd50d..bd50b061 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -393,6 +393,7 @@ pub trait IntegrationHeadInjector: Send + Sync { pub struct IntegrationRegistration { pub integration_id: &'static str, pub js_deferred: bool, + pub js_disabled: bool, pub proxies: Vec>, pub attribute_rewriters: Vec>, pub script_rewriters: Vec>, @@ -417,6 +418,7 @@ impl IntegrationRegistrationBuilder { registration: IntegrationRegistration { integration_id, js_deferred: false, + js_disabled: false, proxies: Vec::new(), attribute_rewriters: Vec::new(), script_rewriters: Vec::new(), @@ -470,6 +472,14 @@ impl IntegrationRegistrationBuilder { self } + /// Disable TSJS module inclusion for an integration that is handled by other assets. + #[must_use] + pub fn without_js(mut self) -> Self { + self.registration.js_disabled = true; + self.registration.js_deferred = false; + self + } + #[must_use] pub fn build(self) -> IntegrationRegistration { self.registration @@ -491,6 +501,7 @@ struct IntegrationRegistryInner { // Metadata for introspection routes: Vec<(IntegrationEndpoint, &'static str)>, deferred_js_ids: Vec<&'static str>, + disabled_js_ids: Vec<&'static str>, html_rewriters: Vec>, script_rewriters: Vec>, html_post_processors: Vec>, @@ -508,11 +519,12 @@ impl Default for IntegrationRegistryInner { head_router: Router::new(), options_router: Router::new(), routes: Vec::new(), + deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), html_rewriters: Vec::new(), script_rewriters: Vec::new(), html_post_processors: Vec::new(), head_injectors: Vec::new(), - deferred_js_ids: Vec::new(), } } } @@ -634,7 +646,9 @@ impl IntegrationRegistry { inner .head_injectors .extend(registration.head_injectors.into_iter()); - if registration.js_deferred { + if registration.js_disabled { + inner.disabled_js_ids.push(registration.integration_id); + } else if registration.js_deferred { inner.deferred_js_ids.push(registration.integration_id); } } @@ -829,7 +843,10 @@ impl IntegrationRegistry { let mut ids: Vec<&'static str> = JS_ALWAYS.to_vec(); for meta in self.registered_integrations() { - if !JS_EXCLUDED.contains(&meta.id) && !ids.contains(&meta.id) { + if !JS_EXCLUDED.contains(&meta.id) + && !self.inner.disabled_js_ids.contains(&meta.id) + && !ids.contains(&meta.id) + { ids.push(meta.id); } } @@ -882,6 +899,7 @@ impl IntegrationRegistry { html_post_processors: Vec::new(), head_injectors: Vec::new(), deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), }), } } @@ -908,6 +926,7 @@ impl IntegrationRegistry { html_post_processors: Vec::new(), head_injectors, deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), }), } } @@ -970,6 +989,7 @@ impl IntegrationRegistry { html_post_processors: Vec::new(), head_injectors: Vec::new(), deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), }), } } @@ -1448,7 +1468,7 @@ mod tests { } #[test] - fn js_module_ids_immediate_excludes_prebid_and_includes_js_only_modules() { + fn js_module_ids_exclude_prebid_and_include_js_only_modules() { let settings = crate::test_support::tests::create_test_settings(); let mut settings_with_prebid = settings; settings_with_prebid @@ -1458,6 +1478,7 @@ mod tests { &serde_json::json!({ "enabled": true, "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, "bidders": ["mocktioneer"], "debug": false @@ -1473,8 +1494,8 @@ mod tests { let deferred = registry.js_module_ids_deferred(); assert!( - all.contains(&"prebid"), - "should include prebid in full list" + !all.contains(&"prebid"), + "should not include prebid in embedded TSJS module IDs" ); assert!( immediate.contains(&"creative"), @@ -1489,8 +1510,8 @@ mod tests { "should not include prebid in immediate IDs" ); assert!( - deferred.contains(&"prebid"), - "should include prebid in deferred IDs" + !deferred.contains(&"prebid"), + "should not include prebid in deferred IDs" ); } @@ -1503,7 +1524,8 @@ mod tests { "prebid", &serde_json::json!({ "enabled": false, - "server_url": "https://test-prebid.com/openrtb2/auction" + "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", }), ) .expect("should update prebid config"); @@ -1517,6 +1539,41 @@ mod tests { ); } + #[test] + fn js_module_ids_exclude_prebid_when_external_bundle_is_configured() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings + .integrations + .insert_config( + "prebid", + &serde_json::json!({ + "enabled": true, + "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js" + }), + ) + .expect("should update prebid config"); + + let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + + assert!( + !registry.js_module_ids().contains(&"prebid"), + "external bundle mode should not include prebid in embedded TSJS modules" + ); + assert!( + !registry.js_module_ids_immediate().contains(&"prebid"), + "external bundle mode should not include prebid in immediate TSJS modules" + ); + assert!( + !registry.js_module_ids_deferred().contains(&"prebid"), + "external bundle mode should not include prebid in deferred TSJS modules" + ); + assert!( + registry.has_route(&Method::GET, "/integrations/prebid/bundle.js"), + "external bundle mode should register the first-party bundle route" + ); + } + #[test] fn js_module_ids_split_is_exhaustive() { let settings = crate::test_support::tests::create_test_settings(); @@ -1528,6 +1585,7 @@ mod tests { &serde_json::json!({ "enabled": true, "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", "timeout_ms": 1000, "bidders": ["mocktioneer"], "debug": false diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 1f160b28..04e5effe 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -52,6 +52,8 @@ pub struct ProxyRequestConfig<'a> { /// Integration proxies should pass `&[]`; first-party proxy passes /// `&settings.proxy.allowed_domains`. pub allowed_domains: &'a [String], + /// Require the initial target and every followed redirect hop to use HTTPS. + pub require_https: bool, } impl<'a> ProxyRequestConfig<'a> { @@ -70,6 +72,7 @@ impl<'a> ProxyRequestConfig<'a> { copy_request_headers: true, stream_passthrough: false, allowed_domains: &[], + require_https: false, } } @@ -100,6 +103,27 @@ impl<'a> ProxyRequestConfig<'a> { self.stream_passthrough = true; self } + + /// Disable EC ID query-param forwarding to the target URL. + #[must_use] + pub fn without_ec_id(mut self) -> Self { + self.forward_ec_id = false; + self + } + + /// Enforce a domain allowlist on the target URL and followed redirects. + #[must_use] + pub fn with_allowed_domains(mut self, allowed_domains: &'a [String]) -> Self { + self.allowed_domains = allowed_domains; + self + } + + /// Require HTTPS for the target URL and followed redirects. + #[must_use] + pub fn with_https_only(mut self) -> Self { + self.require_https = true; + self + } } /// Encodings we support decompressing in `finalize_proxied_response`. @@ -401,6 +425,7 @@ struct ProxyRequestHeaders<'a> { additional_headers: &'a [(header::HeaderName, HeaderValue)], copy_request_headers: bool, allowed_domains: &'a [String], + require_https: bool, } /// Proxy a request to a clear target URL while reusing creative rewrite logic. @@ -427,6 +452,7 @@ pub async fn proxy_request( copy_request_headers, stream_passthrough, allowed_domains, + require_https, } = config; let mut target_url_parsed = url::Url::parse(target_url).map_err(|_| { @@ -449,6 +475,7 @@ pub async fn proxy_request( additional_headers: &headers, copy_request_headers, allowed_domains, + require_https, }, stream_passthrough, ) @@ -511,7 +538,7 @@ fn redirect_is_permitted>(allowed_domains: &[S], host: &str) -> bo /// /// Comparison is case-insensitive. The wildcard check requires a dot boundary, /// so `"*.example.com"` does **not** match `"evil-example.com"`. -fn is_host_allowed(host: &str, pattern: &str) -> bool { +pub(crate) fn is_host_allowed(host: &str, pattern: &str) -> bool { let host = host.to_ascii_lowercase(); let pattern = pattern.to_ascii_lowercase(); @@ -552,6 +579,12 @@ async fn proxy_with_redirects( message: "unsupported scheme".to_string(), })); } + if request_headers.require_https && scheme != "https" { + log::warn!("request to `{}` blocked: HTTPS is required", current_url); + return Err(Report::new(TrustedServerError::Forbidden { + message: "non-HTTPS proxy target blocked".to_string(), + })); + } let host = parsed_url.host_str().unwrap_or(""); if host.is_empty() { @@ -637,8 +670,20 @@ async fn proxy_with_redirects( let next_scheme = next_url.scheme().to_ascii_lowercase(); if next_scheme != "http" && next_scheme != "https" { + if request_headers.require_https { + log::warn!("redirect to `{}` blocked: HTTPS is required", next_url); + return Err(Report::new(TrustedServerError::Forbidden { + message: "non-HTTPS redirect blocked".to_string(), + })); + } return finalize_response(settings, req, ¤t_url, beresp, stream_passthrough); } + if request_headers.require_https && next_scheme != "https" { + log::warn!("redirect to `{}` blocked: HTTPS is required", next_url); + return Err(Report::new(TrustedServerError::Forbidden { + message: "non-HTTPS redirect blocked".to_string(), + })); + } let next_host = match next_url.host_str() { Some(h) if !h.is_empty() => h, @@ -712,6 +757,7 @@ pub async fn handle_first_party_proxy( copy_request_headers: true, stream_passthrough: false, allowed_domains: &settings.proxy.allowed_domains, + require_https: false, }, ) .await @@ -1167,8 +1213,8 @@ mod tests { use super::{ copy_proxy_forward_headers, handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, is_host_allowed, - rebuild_response_with_body, reconstruct_and_validate_signed_target, redirect_is_permitted, - ProxyRequestConfig, SUPPORTED_ENCODINGS, + proxy_request, rebuild_response_with_body, reconstruct_and_validate_signed_target, + redirect_is_permitted, ProxyRequestConfig, SUPPORTED_ENCODINGS, }; use crate::error::{IntoHttpResponse, TrustedServerError}; use crate::test_support::tests::create_test_settings; @@ -2172,6 +2218,34 @@ mod tests { // `redirect_is_permitted` and `ip_literal_blocked_by_domain_allowlist` // cover the blocking logic used at every hop. + #[tokio::test] + async fn proxy_request_blocks_non_https_target_when_https_only() { + let settings = create_test_settings(); + let req = Request::new( + Method::GET, + "https://edge.example/integrations/prebid/bundle.js", + ); + let config = ProxyRequestConfig::new("http://assets.example/prebid/trusted-prebid.js") + .without_ec_id() + .without_forward_headers() + .with_streaming() + .with_https_only(); + + let err = proxy_request(&settings, req, config) + .await + .expect_err("should block non-HTTPS target before proxying"); + + assert_eq!( + err.current_context().status_code(), + StatusCode::FORBIDDEN, + "HTTPS-only proxy requests should reject http targets" + ); + assert!( + matches!(err.current_context(), TrustedServerError::Forbidden { .. }), + "should return a forbidden error" + ); + } + #[tokio::test] async fn proxy_initial_target_blocked_by_allowlist() { use crate::http_util::compute_encrypted_sha256_token; diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index ebd977b0..c4dae5b2 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -161,7 +161,7 @@ pub fn handle_tsjs_dynamic( Ok(Response::from_status(StatusCode::NOT_FOUND).with_body("Not Found")) } -/// Extract a module ID from a deferred-module filename like `tsjs-prebid.min.js`. +/// Extract a module ID from a deferred-module filename like `tsjs-sourcepoint.min.js`. /// /// Returns `Some(&'static str)` if the filename matches a known JS module ID, /// `None` otherwise. The caller must additionally verify that the module is @@ -1223,14 +1223,14 @@ mod tests { #[test] fn parse_deferred_module_filename_extracts_known_id() { assert_eq!( - parse_deferred_module_filename("tsjs-prebid.min.js"), - Some("prebid"), - "should extract prebid from minified filename" + parse_deferred_module_filename("tsjs-sourcepoint.min.js"), + Some("sourcepoint"), + "should extract sourcepoint from minified filename" ); assert_eq!( - parse_deferred_module_filename("tsjs-prebid.js"), - Some("prebid"), - "should extract prebid from unminified filename" + parse_deferred_module_filename("tsjs-sourcepoint.js"), + Some("sourcepoint"), + "should extract sourcepoint from unminified filename" ); } @@ -1252,14 +1252,14 @@ mod tests { "should reject without tsjs- prefix" ); assert_eq!( - parse_deferred_module_filename("tsjs-prebid.txt"), + parse_deferred_module_filename("tsjs-sourcepoint.txt"), None, "should reject non-js extension" ); } #[test] - fn tsjs_dynamic_serves_deferred_prebid_when_enabled() { + fn tsjs_dynamic_does_not_serve_embedded_prebid() { let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -1271,8 +1271,8 @@ mod tests { let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); assert_eq!( response.get_status(), - StatusCode::OK, - "should serve deferred prebid module when enabled" + StatusCode::NOT_FOUND, + "should not serve embedded prebid module" ); } @@ -1285,7 +1285,8 @@ mod tests { "prebid", &serde_json::json!({ "enabled": false, - "server_url": "https://test-prebid.com/openrtb2/auction" + "server_url": "https://test-prebid.com/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", }), ) .expect("should update prebid config"); diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 25c918b2..a8e74f7d 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -24,7 +24,8 @@ pub mod tests { [integrations.prebid] enabled = true - server_url = "https://test-prebid.com/openrtb2/auction" + server_url = "https://test-prebid.com/openrtb2/auction" + external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" [integrations.nextjs] enabled = false diff --git a/docs/guide/creative-processing.md b/docs/guide/creative-processing.md index 7a8977a9..cad9a5cf 100644 --- a/docs/guide/creative-processing.md +++ b/docs/guide/creative-processing.md @@ -748,7 +748,7 @@ Each integration is built as a separate IIFE at compile time (`crates/js/lib/dis - `tsjs-core.js` — Core API (always included) - `tsjs-creative.js` — Creative click-guard and tracking -- `tsjs-prebid.js` — Prebid.js NPM bundle with trustedServer adapter +- Prebid is built externally with `build-prebid-external.mjs` and served through `/integrations/prebid/bundle.js` - `tsjs-lockr.js`, `tsjs-permutive.js`, `tsjs-didomi.js`, `tsjs-datadome.js`, `tsjs-testlight.js` — Other integrations At runtime, the server concatenates `tsjs-core.js` + the modules for enabled integrations. The URL stays `/static/tsjs=tsjs-unified.min.js?v=` for backward compatibility. diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 79576b8b..e3b6a34c 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -212,7 +212,7 @@ impl IntegrationScriptRewriter for MyIntegration { `html_processor.rs` calls these hooks after applying the standard origin→first-party rewrite, so you can simply swap URLs, append query parameters, or mutate inline JSON. Use this to point ` +``` + +If `external_bundle_sha256` is omitted, the injected script tag should omit the +content hash query value and Trusted Server must not serve the response as an +immutable asset. + +### Why Not Use `/first-party/proxy` Directly? + +The generic first-party proxy is designed for creative assets. It may forward EC +IDs, follow creative-oriented response processing paths, and uses signed target +URLs. The Prebid bundle is a static application asset and should have a narrower +route with asset-specific behavior. + +The new route can still reuse the lower-level proxy helper, but it should call it +with asset-safe options: + +- `forward_ec_id = false` +- `copy_request_headers = false` or a minimal static-asset header set +- `stream_passthrough = true` +- redirects allowed only when every hop remains `https://` and the redirect + target host is permitted by `proxy.allowed_domains` +- no HTML/CSS rewriting + +## Runtime Request Flow + +```mermaid +sequenceDiagram + autonumber + participant B as Browser + participant TS as Trusted Server + participant CDN as External Bundle Host + participant PBS as Prebid Server + + B->>TS: GET publisher page + TS->>TS: remove configured publisher Prebid script tags + TS-->>B: HTML with window.__tsjs_prebid and first-party Prebid bundle script + B->>TS: GET /integrations/prebid/bundle.js[?v=sha256] + TS->>CDN: GET external_bundle_url + CDN-->>TS: generated Prebid bundle bytes + TS-->>B: application/javascript with cache headers for configured mode + B->>B: Prebid installs trustedServer adapter and processes pbjs queue + B->>TS: POST /auction + TS->>PBS: POST OpenRTB request + PBS-->>TS: OpenRTB response + TS-->>B: auction response +``` + +## HTML Injection Behavior + +When Prebid is enabled, Prebid head injection should emit: + +1. the existing `window.pbjs` queue stub +2. `window.__tsjs_prebid` config +3. a first-party script tag for `/integrations/prebid/bundle.js`, with `?v=` when a SHA-256 hash is configured + +The script tag should be injected at the same early head insertion point used by +current TSJS injection. + +The generated external Prebid bundle should be `defer`-safe. It must install all +modules and the `trustedServer` adapter before calling `pbjs.processQueue()`. + +## Script Interception Behavior + +In Phase 1, Trusted Server always owns Prebid loading through the generated +external bundle. Therefore existing publisher Prebid script tags should continue +to be removed when they match `script_patterns`. + +Requests for intercepted publisher Prebid script URLs may continue returning the +existing empty JS response. This prevents duplicate Prebid instances when the +publisher page references its original Prebid asset. + +Publisher-existing Prebid mode is explicitly out of scope for Phase 1. + +## External Bundle Generation + +Add a generation path outside the Cargo build, for example: + +```bash +node crates/js/lib/build-prebid-external.mjs \ + --adapters exampleBidder,anotherExampleBidder \ + --user-id-modules sharedIdSystem,uid2IdSystem \ + --out dist/prebid/ +``` + +The generated bundle should include: + +- Prebid.js core +- selected bidder adapters +- consent modules required by the integration +- selected User ID modules +- the existing Trusted Server Prebid adapter/shim logic + +The generator should emit a manifest: + +```json +{ + "prebidVersion": "10.26.0", + "adapters": ["exampleBidder", "anotherExampleBidder"], + "userIdModules": ["sharedIdSystem", "uid2IdSystem"], + "sha256": "abc123...", + "sri": "sha384-...", + "filename": "trusted-prebid-abc123.js" +} +``` + +Trusted Server config should reference the generated asset URL. When the +manifest includes hash values, config should also reference those values to +enable content-addressed delivery, immutable caching, and browser SRI when +configured. + +## Required Code Changes + +### JS Build + +- Stop including `src/integrations/prebid/index.ts` in the default `build-all.mjs` + embedded TSJS discovery path, or move the Prebid external entrypoint outside + `src/integrations`. +- Move reusable Trusted Server Prebid adapter/shim code into a module that can be + used by the external bundle generator. +- Keep Prebid-related generated adapter/User ID imports in the external bundle + generator, not the embedded Trusted Server build. + +### Rust Integration + +- Add `external_bundle_url`, `external_bundle_sha256`, and + `external_bundle_sri` fields to `PrebidIntegrationConfig`. +- Do not register Prebid with `.with_deferred_js()`. +- Register a Prebid integration GET route for `/integrations/prebid/bundle.js`. +- Implement the route as a first-party proxy to `external_bundle_url` with static + asset behavior. +- Inject the first-party script tag from the Prebid head injector. +- Preserve current script-pattern removal/empty-script behavior. + +### Publisher Static Serving + +- `/static/tsjs=tsjs-prebid.min.js` should no longer be a Prebid loading path. +- Existing deferred-module serving can remain for other integrations. + +## Response Headers + +For successful first-party bundle responses, Trusted Server should always set or +normalize: + +```text +Content-Type: application/javascript; charset=utf-8 +``` + +Caching depends on whether the browser-visible URL is content-addressed. + +When `external_bundle_sha256` is configured, the injected URL should include +`?v=` and Trusted Server should serve the response as an +immutable asset: + +```text +Cache-Control: public, max-age=31536000, immutable +ETag: "sha256:" +``` + +If the route query `v` is present, it must match `external_bundle_sha256`. If no +SHA-256 is configured, any `v` query value should return `404 Not Found`. This +avoids ambiguous cache entries. + +When `external_bundle_sha256` is omitted, the injected URL should not include a +content hash query value and Trusted Server must not use `immutable`. It should +use a short-lived revalidation-oriented policy, for example: + +```text +Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400 +``` + +In this mode, validators may be omitted. Phase 1 preserves streaming passthrough, +so Trusted Server should not buffer the bundle solely to compute an `ETag`. + +## Integrity and Attestation + +This design separates two attestable artifacts: + +1. Trusted Server WASM binary +2. generated external Prebid bundle + +The Trusted Server binary hash should no longer vary with Prebid module choices. +The Prebid bundle should be audited through its own manifest containing: + +- Prebid version +- module list +- bundle hash +- SRI value +- generator version or source revision when available + +When configured, browser SRI validates the first-party proxied response. SRI is +recommended with `external_bundle_sha256`, but it is not required; deployments may +use the hash metadata only for versioned first-party URLs, cache policy, and +manifest auditing. + +Phase 1 does not perform mandatory edge-side SHA-256 byte validation. Doing so +would require buffering the proxied bundle and would break the streaming +passthrough behavior for this route. If `external_bundle_sha256` is omitted, +Trusted Server cannot treat the route as content-addressed. That mode trades +stronger attestation and long-lived caching for easier operations and must use +non-immutable cache headers. + +## Migration Plan + +1. Add required external bundle URL config and optional hash/SRI metadata. +2. Add first-party bundle proxy route and injection. +3. Add external bundle generation tooling and manifest output. +4. Remove Prebid from the embedded TSJS build and never register it as deferred JS. +5. Update docs and examples to point publishers at the generated external bundle. + +## Test Plan + +### Rust Tests + +- Config validation accepts valid external bundle settings. +- Config validation rejects missing required external bundle settings and malformed optional hash or SRI metadata. +- Registry does not include `prebid` in embedded or deferred JS IDs. +- Head injection emits the first-party bundle URL with the configured hash when present and without a hash query value when absent. +- Script interception still removes matching publisher Prebid scripts. +- Bundle route proxies to `external_bundle_url` without forwarding EC ID. +- Bundle route rejects mismatched `v` query values when SHA-256 is configured and rejects any `v` query value when SHA-256 is omitted. +- Bundle route blocks redirects to non-HTTPS URLs. +- Bundle route blocks redirects whose target hosts are not permitted by + `proxy.allowed_domains`. +- Bundle route emits JavaScript content type and immutable cache headers in content-addressed mode. +- Bundle route emits JavaScript content type and non-immutable short-lived cache headers when SHA-256 is omitted. + +### JS Tests + +- External generated bundle registers the `trustedServer` adapter. +- External generated bundle shims `requestBids()` as the previous embedded bundle + did. +- External generated bundle calls `pbjs.processQueue()` after module/adapter + registration. +- Client-side bidder adapter selection is reflected in the generated manifest. + +### Browser/Integration Tests + +- Publisher page loads no `/static/tsjs=tsjs-prebid.min.js`. +- Browser loads `/integrations/prebid/bundle.js?v=` from first-party + origin when SHA-256 is configured, or `/integrations/prebid/bundle.js` when it + is omitted. +- Original publisher Prebid script tag is removed or neutralized. +- A Prebid auction still posts to `/auction`. +- No duplicate Prebid instances are created. + +## Final Phase 1 Decisions + +- Edge-side SHA-256 byte validation is not mandatory in Phase 1 because the route + preserves streaming passthrough. +- Redirects are allowed only when every hop remains `https://` and each redirect + target host is permitted by `proxy.allowed_domains`. +- The injected script tag omits `crossorigin` because the browser-visible bundle + URL is same-origin. diff --git a/trusted-server.toml b/trusted-server.toml index 5ab1a750..ef3bd39f 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -65,6 +65,10 @@ debug = false # test_mode = false # debug_query_params = "" # script_patterns = ["/prebid.js"] +# Generated external Prebid bundle served through /integrations/prebid/bundle.js. +external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +# external_bundle_sha256 = "..." +# external_bundle_sri = "sha384-..." # Bidders that run client-side via native Prebid.js adapters instead of # being routed through the server-side auction. Their adapter modules must