diff --git a/crates/common/src/backend.rs b/crates/common/src/backend.rs index e2d39b0..ba5a1c8 100644 --- a/crates/common/src/backend.rs +++ b/crates/common/src/backend.rs @@ -22,15 +22,18 @@ pub fn ensure_origin_backend( })); } - let host_with_port = match port { - Some(p) => format!("{}:{}", host, p), - None => host.to_string(), + let is_https = scheme.eq_ignore_ascii_case("https"); + let target_port = match (port, is_https) { + (Some(p), _) => p, + (None, true) => 443, + (None, false) => 80, }; - // Name: iframe__ (sanitize '.' and ':') - let mut name_base = format!("{}_{}", scheme, host_with_port); - name_base = name_base.replace(['.', ':'], "_"); - let backend_name = format!("backend_{}", name_base); + let host_with_port = format!("{}:{}", host, target_port); + + // Name: iframe___ (sanitize '.' and ':') + let name_base = format!("{}_{}_{}", scheme, host, target_port); + let backend_name = format!("backend_{}", name_base.replace(['.', ':'], "_")); // Target base is host[:port]; SSL is enabled only for https scheme let mut builder = Backend::builder(&backend_name, &host_with_port) @@ -39,7 +42,11 @@ pub fn ensure_origin_backend( .first_byte_timeout(Duration::from_secs(15)) .between_bytes_timeout(Duration::from_secs(10)); if scheme.eq_ignore_ascii_case("https") { - builder = builder.enable_ssl().sni_hostname(host); + builder = builder + .enable_ssl() + .sni_hostname(host) + .check_certificate(host); + log::info!("enable ssl for backend: {}", backend_name); } match builder.finish() { @@ -67,6 +74,7 @@ pub fn ensure_origin_backend( } } } + pub fn ensure_backend_from_url(origin_url: &str) -> Result> { let parsed_url = Url::parse(origin_url).change_context(TrustedServerError::Proxy { message: format!("Invalid origin_url: {}", origin_url), @@ -90,7 +98,7 @@ mod tests { #[test] fn returns_name_for_https_no_port() { let name = ensure_origin_backend("https", "origin.example.com", None).unwrap(); - assert_eq!(name, "backend_https_origin_example_com"); + assert_eq!(name, "backend_https_origin_example_com_443"); } #[test] @@ -101,6 +109,12 @@ mod tests { assert!(name.ends_with("_8080")); } + #[test] + fn returns_name_for_http_without_port_defaults_to_80() { + let name = ensure_origin_backend("http", "example.org", None).unwrap(); + assert_eq!(name, "backend_http_example_org_80"); + } + #[test] fn error_on_missing_host() { let err = ensure_origin_backend("https", "", None).err().unwrap(); diff --git a/crates/common/src/integrations/didomi.rs b/crates/common/src/integrations/didomi.rs new file mode 100644 index 0000000..6c1a2ed --- /dev/null +++ b/crates/common/src/integrations/didomi.rs @@ -0,0 +1,280 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use error_stack::{Report, ResultExt}; +use fastly::http::{header, Method}; +use fastly::{Request, Response}; +use serde::{Deserialize, Serialize}; +use url::Url; +use validator::Validate; + +use crate::backend::ensure_backend_from_url; +use crate::error::TrustedServerError; +use crate::integrations::{IntegrationEndpoint, IntegrationProxy, IntegrationRegistration}; +use crate::settings::{IntegrationConfig, Settings}; + +const DIDOMI_INTEGRATION_ID: &str = "didomi"; +const DIDOMI_PREFIX: &str = "/integrations/didomi/consent"; + +/// Configuration for the Didomi consent notice reverse proxy. +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +pub struct DidomiIntegrationConfig { + /// Whether the integration is enabled. + #[serde(default = "default_enabled")] + pub enabled: bool, + /// Base URL for the Didomi SDK origin. + #[serde(default = "default_sdk_origin")] + #[validate(url)] + pub sdk_origin: String, + /// Base URL for the Didomi API origin. + #[serde(default = "default_api_origin")] + #[validate(url)] + pub api_origin: String, +} + +impl IntegrationConfig for DidomiIntegrationConfig { + fn is_enabled(&self) -> bool { + self.enabled + } +} + +fn default_enabled() -> bool { + true +} + +fn default_sdk_origin() -> String { + "https://sdk.privacy-center.org".to_string() +} + +fn default_api_origin() -> String { + "https://api.privacy-center.org".to_string() +} + +enum DidomiBackend { + Sdk, + Api, +} + +struct DidomiIntegration { + config: Arc, +} + +impl DidomiIntegration { + fn new(config: Arc) -> Arc { + Arc::new(Self { config }) + } + + fn error(message: impl Into) -> TrustedServerError { + TrustedServerError::Integration { + integration: DIDOMI_INTEGRATION_ID.to_string(), + message: message.into(), + } + } + + fn backend_for_path(&self, consent_path: &str) -> DidomiBackend { + if consent_path.starts_with("/api/") { + DidomiBackend::Api + } else { + DidomiBackend::Sdk + } + } + + fn build_target_url( + &self, + base: &str, + consent_path: &str, + query: Option<&str>, + ) -> Result> { + let mut target = + Url::parse(base).change_context(Self::error("Invalid Didomi origin URL"))?; + let path = if consent_path.is_empty() { + "/" + } else { + consent_path + }; + target.set_path(path); + target.set_query(query); + Ok(target.to_string()) + } + + fn copy_headers( + &self, + backend: &DidomiBackend, + original_req: &Request, + proxy_req: &mut Request, + ) { + if let Some(client_ip) = original_req.get_client_ip_addr() { + proxy_req.set_header("X-Forwarded-For", client_ip.to_string()); + } + + for header_name in [ + header::ACCEPT, + header::ACCEPT_LANGUAGE, + header::ACCEPT_ENCODING, + header::USER_AGENT, + header::REFERER, + header::ORIGIN, + header::AUTHORIZATION, + ] { + if let Some(value) = original_req.get_header(&header_name) { + proxy_req.set_header(&header_name, value); + } + } + + if matches!(backend, DidomiBackend::Sdk) { + Self::copy_geo_headers(original_req, proxy_req); + } + } + + fn copy_geo_headers(original_req: &Request, proxy_req: &mut Request) { + let geo_headers = [ + ("X-Geo-Country", "FastlyGeo-CountryCode"), + ("X-Geo-Region", "FastlyGeo-Region"), + ("CloudFront-Viewer-Country", "FastlyGeo-CountryCode"), + ]; + + for (target, source) in geo_headers { + if let Some(value) = original_req.get_header(source) { + proxy_req.set_header(target, value); + } + } + } + + fn add_cors_headers(response: &mut Response) { + response.set_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + response.set_header( + header::ACCESS_CONTROL_ALLOW_HEADERS, + "Content-Type, Authorization, X-Requested-With", + ); + response.set_header( + header::ACCESS_CONTROL_ALLOW_METHODS, + "GET, POST, PUT, DELETE, OPTIONS", + ); + } +} + +fn build(settings: &Settings) -> Option> { + let config = match settings.integration_config::(DIDOMI_INTEGRATION_ID) + { + Ok(Some(config)) => Arc::new(config), + Ok(None) => return None, + Err(err) => { + log::error!("Failed to load Didomi integration config: {err:?}"); + return None; + } + }; + Some(DidomiIntegration::new(config)) +} + +/// Register the Didomi consent notice integration when enabled. +pub fn register(settings: &Settings) -> Option { + let integration = build(settings)?; + Some( + IntegrationRegistration::builder(DIDOMI_INTEGRATION_ID) + .with_proxy(integration) + .build(), + ) +} + +#[async_trait(?Send)] +impl IntegrationProxy for DidomiIntegration { + fn integration_name(&self) -> &'static str { + DIDOMI_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + vec![self.get("/consent/*"), self.post("/consent/*")] + } + + async fn handle( + &self, + _settings: &Settings, + req: Request, + ) -> Result> { + let path = req.get_path(); + let consent_path = path.strip_prefix(DIDOMI_PREFIX).unwrap_or(path); + let backend = self.backend_for_path(consent_path); + let base_origin = match backend { + DidomiBackend::Sdk => self.config.sdk_origin.as_str(), + DidomiBackend::Api => self.config.api_origin.as_str(), + }; + + let target_url = self + .build_target_url(base_origin, consent_path, req.get_query_str()) + .change_context(Self::error("Failed to build Didomi target URL"))?; + let backend_name = ensure_backend_from_url(base_origin) + .change_context(Self::error("Failed to configure Didomi backend"))?; + + let mut proxy_req = Request::new(req.get_method().clone(), &target_url); + self.copy_headers(&backend, &req, &mut proxy_req); + + if matches!(req.get_method(), &Method::POST | &Method::PUT) { + if let Some(content_type) = req.get_header(header::CONTENT_TYPE) { + proxy_req.set_header(header::CONTENT_TYPE, content_type); + } + proxy_req.set_body(req.into_body()); + } + + let mut response = proxy_req + .send(&backend_name) + .change_context(Self::error("Didomi upstream request failed"))?; + + if matches!(backend, DidomiBackend::Sdk) { + Self::add_cors_headers(&mut response); + } + + Ok(response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::integrations::IntegrationRegistry; + use crate::test_support::tests::create_test_settings; + use fastly::http::Method; + + fn config(enabled: bool) -> DidomiIntegrationConfig { + DidomiIntegrationConfig { + enabled, + sdk_origin: default_sdk_origin(), + api_origin: default_api_origin(), + } + } + + #[test] + fn selects_api_backend_for_api_paths() { + let integration = DidomiIntegration::new(Arc::new(config(true))); + assert!(matches!( + integration.backend_for_path("/api/events"), + DidomiBackend::Api + )); + assert!(matches!( + integration.backend_for_path("/24cd/loader.js"), + DidomiBackend::Sdk + )); + } + + #[test] + fn builds_target_url_with_query() { + let integration = DidomiIntegration::new(Arc::new(config(true))); + let url = integration + .build_target_url("https://sdk.privacy-center.org", "/loader.js", Some("v=1")) + .unwrap(); + assert_eq!(url, "https://sdk.privacy-center.org/loader.js?v=1"); + } + + #[test] + fn registers_prefix_routes() { + let mut settings = create_test_settings(); + settings + .integrations + .insert_config(DIDOMI_INTEGRATION_ID, &config(true)) + .expect("should insert config"); + + let registry = IntegrationRegistry::new(&settings); + assert!(registry.has_route(&Method::GET, "/integrations/didomi/consent/loader.js")); + assert!(registry.has_route(&Method::POST, "/integrations/didomi/consent/api/events")); + assert!(!registry.has_route(&Method::GET, "/other")); + } +} diff --git a/crates/common/src/integrations/mod.rs b/crates/common/src/integrations/mod.rs index 3bca27b..abf45d7 100644 --- a/crates/common/src/integrations/mod.rs +++ b/crates/common/src/integrations/mod.rs @@ -2,6 +2,7 @@ use crate::settings::Settings; +pub mod didomi; pub mod lockr; pub mod nextjs; pub mod permutive; @@ -25,5 +26,6 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] { nextjs::register, permutive::register, lockr::register, + didomi::register, ] } diff --git a/crates/js/lib/package-lock.json b/crates/js/lib/package-lock.json index 69f3c66..83411a3 100644 --- a/crates/js/lib/package-lock.json +++ b/crates/js/lib/package-lock.json @@ -166,6 +166,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -209,6 +210,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1407,6 +1409,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -1723,6 +1726,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2028,6 +2032,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -2715,6 +2720,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5523,6 +5529,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5739,6 +5746,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5844,6 +5852,7 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5937,6 +5946,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/crates/js/lib/src/integrations/didomi/index.ts b/crates/js/lib/src/integrations/didomi/index.ts new file mode 100644 index 0000000..20902b4 --- /dev/null +++ b/crates/js/lib/src/integrations/didomi/index.ts @@ -0,0 +1,45 @@ +import { log } from '../../core/log'; + +const DEFAULT_SDK_PATH = 'https://sdk.privacy-center.org/'; +const CONSENT_PROXY_PATH = '/integrations/didomi/consent/'; + +type DidomiConfig = { + sdkPath?: string; + [key: string]: unknown; +}; + +type DidomiWindow = Window & { didomiConfig?: DidomiConfig }; + +function buildProxySdkPath(win: DidomiWindow): string { + const base = win.location?.origin ?? win.location?.href; + if (!base) return CONSENT_PROXY_PATH; + const url = new URL(CONSENT_PROXY_PATH, base); + return `${url.origin}${url.pathname}`; +} + +export function installDidomiSdkProxy(): boolean { + if (typeof window === 'undefined') return false; + + const win = window as DidomiWindow; + const config = (win.didomiConfig ??= {}); + const previousSdkPath = + typeof config.sdkPath === 'string' && config.sdkPath.length > 0 + ? config.sdkPath + : DEFAULT_SDK_PATH; + + const proxiedSdkPath = buildProxySdkPath(win); + config.sdkPath = proxiedSdkPath; + + log.info('didomi sdkPath overridden for trusted server proxy', { + previousSdkPath, + sdkPath: proxiedSdkPath, + }); + + return true; +} + +if (typeof window !== 'undefined') { + installDidomiSdkProxy(); +} + +export default installDidomiSdkProxy; diff --git a/crates/js/lib/test/integrations/didomi/index.test.ts b/crates/js/lib/test/integrations/didomi/index.test.ts new file mode 100644 index 0000000..d699301 --- /dev/null +++ b/crates/js/lib/test/integrations/didomi/index.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { installDidomiSdkProxy } from '../../../src/integrations/didomi'; + +const ORIGINAL_WINDOW = global.window; + +function createWindow(url: string) { + return { + location: new URL(url) as unknown as Location, + } as Window & { didomiConfig?: any }; +} + +describe('integrations/didomi', () => { + let testWindow: ReturnType; + + beforeEach(() => { + testWindow = createWindow('https://example.com/page'); + Object.assign(globalThis as any, { window: testWindow }); + }); + + afterEach(() => { + Object.assign(globalThis as any, { window: ORIGINAL_WINDOW }); + }); + + it('initializes didomiConfig and forces sdkPath through trusted server proxy', () => { + installDidomiSdkProxy(); + + expect(testWindow.didomiConfig).toBeDefined(); + expect(testWindow.didomiConfig.sdkPath).toBe( + 'https://example.com/integrations/didomi/consent/' + ); + }); + + it('preserves existing config fields while overriding sdkPath', () => { + testWindow.didomiConfig = { apiKey: 'abc', sdkPath: 'https://sdk.privacy-center.org/' }; + + installDidomiSdkProxy(); + + expect(testWindow.didomiConfig.apiKey).toBe('abc'); + expect(testWindow.didomiConfig.sdkPath).toBe( + 'https://example.com/integrations/didomi/consent/' + ); + }); +}); diff --git a/trusted-server.toml b/trusted-server.toml index 4e2f08d..3e133c2 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -51,6 +51,11 @@ endpoint = "https://testlight.example/openrtb2/auction" timeout_ms = 1200 rewrite_scripts = true +[integrations.didomi] +enabled = false +sdk_origin = "https://sdk.privacy-center.org" +api_origin = "https://api.privacy-center.org" + [integrations.permutive] enabled = false organization_id = ""