From 370ec075e02f2b681d4df929735624f1be328359 Mon Sep 17 00:00:00 2001 From: Vasu Jain Date: Mon, 8 Jun 2026 12:07:50 -0700 Subject: [PATCH 1/2] feat: make integration proxy paths configurable per customer Fixes #741 Adds a `proxy_prefix()` method to the `IntegrationProxy` trait with a default implementation that preserves existing behavior (`/integrations/{name}`). Integrations can override this to use a customer-configured path that is harder for ad blockers to target. Implements the feature for the Didomi integration as the first example: [integrations.didomi] proxy_path = "my-custom-consent-path" When `proxy_path` is set, routes are registered under `//*` instead of the default `/integrations/didomi/consent/*`. The same pattern can be adopted by other integrations. This is a backwards-compatible change -- if `proxy_path` is not set, the default prefix is used and existing deployments are unaffected. --- .../src/integrations/didomi.rs | 40 +++++++++++- .../src/integrations/registry.rs | 63 ++++++++----------- 2 files changed, 64 insertions(+), 39 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index 7042af70..25e9a89d 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -14,7 +14,7 @@ use crate::integrations::{IntegrationEndpoint, IntegrationProxy, IntegrationRegi use crate::settings::{IntegrationConfig, Settings}; const DIDOMI_INTEGRATION_ID: &str = "didomi"; -const DIDOMI_PREFIX: &str = "/integrations/didomi/consent"; +const DIDOMI_DEFAULT_PREFIX: &str = "/integrations/didomi/consent"; /// Configuration for the Didomi consent notice reverse proxy. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] @@ -22,6 +22,10 @@ pub struct DidomiIntegrationConfig { /// Whether the integration is enabled. #[serde(default = "default_enabled")] pub enabled: bool, + /// Custom proxy path prefix to avoid ad-blocker detection. + /// Defaults to "integrations/didomi/consent" if not set. + #[serde(default)] + pub proxy_path: Option, /// Base URL for the Didomi SDK origin. #[serde(default = "default_sdk_origin")] #[validate(url)] @@ -191,8 +195,15 @@ impl IntegrationProxy for DidomiIntegration { DIDOMI_INTEGRATION_ID } + fn proxy_prefix(&self) -> String { + match &self.config.proxy_path { + Some(custom) => format!("/{}", custom.trim_start_matches('/')), + None => DIDOMI_DEFAULT_PREFIX.to_string(), + } + } + fn routes(&self) -> Vec { - vec![self.get("/consent/*"), self.post("/consent/*")] + vec![self.get("/*"), self.post("/*")] } async fn handle( @@ -201,7 +212,8 @@ impl IntegrationProxy for DidomiIntegration { req: Request, ) -> Result> { let path = req.get_path(); - let consent_path = path.strip_prefix(DIDOMI_PREFIX).unwrap_or(path); + let prefix = self.proxy_prefix(); + let consent_path = path.strip_prefix(&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(), @@ -246,6 +258,7 @@ mod tests { fn config(enabled: bool) -> DidomiIntegrationConfig { DidomiIntegrationConfig { enabled, + proxy_path: None, sdk_origin: default_sdk_origin(), api_origin: default_api_origin(), } @@ -286,4 +299,25 @@ mod tests { assert!(registry.has_route(&Method::POST, "/integrations/didomi/consent/api/events")); assert!(!registry.has_route(&Method::GET, "/other")); } + + #[test] + fn registers_custom_proxy_path() { + let mut settings = create_test_settings(); + let custom_config = DidomiIntegrationConfig { + enabled: true, + proxy_path: Some("my-custom-consent".to_string()), + sdk_origin: default_sdk_origin(), + api_origin: default_api_origin(), + }; + settings + .integrations + .insert_config(DIDOMI_INTEGRATION_ID, &custom_config) + .expect("should insert config"); + + let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + assert!(registry.has_route(&Method::GET, "/my-custom-consent/loader.js")); + assert!(registry.has_route(&Method::POST, "/my-custom-consent/api/events")); + // Original path should NOT match + assert!(!registry.has_route(&Method::GET, "/integrations/didomi/consent/loader.js")); + } } diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 389dd50d..aaa58818 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -251,8 +251,24 @@ pub trait IntegrationProxy: Send + Sync { /// Use this with the `namespaced_*` helper methods to automatically prefix routes. fn integration_name(&self) -> &'static str; + /// Returns the URL path prefix for this integration's proxy routes. + /// + /// Override this to provide a custom, customer-specific proxy path that is + /// harder for ad blockers to target. When not overridden, defaults to + /// `/integrations/{integration_name()}`. + /// + /// # Example + /// ```ignore + /// fn proxy_prefix(&self) -> String { + /// "/my-custom-path".to_string() // instead of /integrations/didomi + /// } + /// ``` + fn proxy_prefix(&self) -> String { + format!("/integrations/{}", self.integration_name()) + } + /// Routes handled by this integration. - /// to automatically namespace routes under `/integrations/{integration_name()}/`, + /// to automatically namespace routes under the proxy prefix, /// or define routes manually for backwards compatibility. fn routes(&self) -> Vec; @@ -264,62 +280,37 @@ pub trait IntegrationProxy: Send + Sync { ) -> Result>; /// Helper to create a namespaced GET endpoint. - /// Automatically prefixes the path with `/integrations/{integration_name()}`. - /// - /// # Example - /// ```ignore - /// self.namespaced_get("/auction") // becomes /integrations/my_integration/auction - /// ``` + /// Automatically prefixes the path with the integration's `proxy_prefix()`. fn get(&self, path: &str) -> IntegrationEndpoint { - let full_path = format!("/integrations/{}{}", self.integration_name(), path); + let full_path = format!("{}{}", self.proxy_prefix(), path); IntegrationEndpoint::get(full_path) } /// Helper to create a namespaced POST endpoint. - /// Automatically prefixes the path with `/integrations/{integration_name()}`. - /// - /// # Example - /// ```ignore - /// self.post("/auction") // becomes /integrations/my_integration/auction - /// ``` + /// Automatically prefixes the path with the integration's `proxy_prefix()`. fn post(&self, path: &str) -> IntegrationEndpoint { - let full_path = format!("/integrations/{}{}", self.integration_name(), path); + let full_path = format!("{}{}", self.proxy_prefix(), path); IntegrationEndpoint::post(full_path) } /// Helper to create a namespaced PUT endpoint. - /// Automatically prefixes the path with `/integrations/{integration_name()}`. - /// - /// # Example - /// ```ignore - /// self.put("/users") // becomes /integrations/my_integration/users - /// ``` + /// Automatically prefixes the path with the integration's `proxy_prefix()`. fn put(&self, path: &str) -> IntegrationEndpoint { - let full_path = format!("/integrations/{}{}", self.integration_name(), path); + let full_path = format!("{}{}", self.proxy_prefix(), path); IntegrationEndpoint::put(full_path) } /// Helper to create a namespaced DELETE endpoint. - /// Automatically prefixes the path with `/integrations/{integration_name()}`. - /// - /// # Example - /// ```ignore - /// self.delete("/users/123") // becomes /integrations/my_integration/users/123 - /// ``` + /// Automatically prefixes the path with the integration's `proxy_prefix()`. fn delete(&self, path: &str) -> IntegrationEndpoint { - let full_path = format!("/integrations/{}{}", self.integration_name(), path); + let full_path = format!("{}{}", self.proxy_prefix(), path); IntegrationEndpoint::delete(full_path) } /// Helper to create a namespaced PATCH endpoint. - /// Automatically prefixes the path with `/integrations/{integration_name()}`. - /// - /// # Example - /// ```ignore - /// self.patch("/settings") // becomes /integrations/my_integration/settings - /// ``` + /// Automatically prefixes the path with the integration's `proxy_prefix()`. fn patch(&self, path: &str) -> IntegrationEndpoint { - let full_path = format!("/integrations/{}{}", self.integration_name(), path); + let full_path = format!("{}{}", self.proxy_prefix(), path); IntegrationEndpoint::patch(full_path) } } From 6c287b3f10315bf73558aa5a6aa410fb7b62d576 Mon Sep 17 00:00:00 2001 From: Vasu Jain Date: Wed, 10 Jun 2026 09:31:53 -0700 Subject: [PATCH 2/2] fix: address review comments on configurable proxy paths 1. Validate proxy_path at config load time: - Reject empty, trailing slash, double slash, and matchit metacharacters ({, }, *, ?, #, space) - Uses validator crate custom function (runs automatically via Settings::integration_config) - Added 5 validation unit tests 2. Pass custom proxy path to client-side JS: - Implement IntegrationHeadInjector for DidomiIntegration - Injects window.__tsjs_didomi={proxyPath:"/..."} - TypeScript reads from window.__tsjs_didomi?.proxyPath, falls back to default /integrations/didomi/consent/ - Existing Didomi JS tests still pass (318 total) 3. Document proxy_path in docs/guide/integrations/didomi.md: - Added to Configuration Options table - New 'Custom Proxy Path' section with format rules, examples - Added environment variable equivalent --- .../js/lib/src/integrations/didomi/index.ts | 20 ++- .../src/integrations/didomi.rs | 144 ++++++++++++++++-- docs/guide/integrations/didomi.md | 40 ++++- 3 files changed, 183 insertions(+), 21 deletions(-) diff --git a/crates/js/lib/src/integrations/didomi/index.ts b/crates/js/lib/src/integrations/didomi/index.ts index 20902b40..3595b2f9 100644 --- a/crates/js/lib/src/integrations/didomi/index.ts +++ b/crates/js/lib/src/integrations/didomi/index.ts @@ -1,19 +1,27 @@ import { log } from '../../core/log'; -const DEFAULT_SDK_PATH = 'https://sdk.privacy-center.org/'; -const CONSENT_PROXY_PATH = '/integrations/didomi/consent/'; +const DEFAULT_CONSENT_PROXY_PATH = '/integrations/didomi/consent/'; type DidomiConfig = { sdkPath?: string; [key: string]: unknown; }; -type DidomiWindow = Window & { didomiConfig?: DidomiConfig }; +type DidomiWindow = Window & { + didomiConfig?: DidomiConfig; + __tsjs_didomi?: { proxyPath?: string }; +}; + +/** Read the server-injected proxy path, falling back to the default. */ +function getConsentProxyPath(win: DidomiWindow): string { + return win.__tsjs_didomi?.proxyPath ?? DEFAULT_CONSENT_PROXY_PATH; +} function buildProxySdkPath(win: DidomiWindow): string { + const proxyPath = getConsentProxyPath(win); const base = win.location?.origin ?? win.location?.href; - if (!base) return CONSENT_PROXY_PATH; - const url = new URL(CONSENT_PROXY_PATH, base); + if (!base) return proxyPath; + const url = new URL(proxyPath, base); return `${url.origin}${url.pathname}`; } @@ -25,7 +33,7 @@ export function installDidomiSdkProxy(): boolean { const previousSdkPath = typeof config.sdkPath === 'string' && config.sdkPath.length > 0 ? config.sdkPath - : DEFAULT_SDK_PATH; + : 'https://sdk.privacy-center.org/'; const proxiedSdkPath = buildProxySdkPath(win); config.sdkPath = proxiedSdkPath; diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index 25e9a89d..0079d67f 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -6,11 +6,14 @@ use fastly::http::{header, Method}; use fastly::{Request, Response}; use serde::{Deserialize, Serialize}; use url::Url; -use validator::Validate; +use validator::{Validate, ValidationError}; use crate::backend::BackendConfig; use crate::error::TrustedServerError; -use crate::integrations::{IntegrationEndpoint, IntegrationProxy, IntegrationRegistration}; +use crate::integrations::{ + IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy, + IntegrationRegistration, +}; use crate::settings::{IntegrationConfig, Settings}; const DIDOMI_INTEGRATION_ID: &str = "didomi"; @@ -25,6 +28,7 @@ pub struct DidomiIntegrationConfig { /// Custom proxy path prefix to avoid ad-blocker detection. /// Defaults to "integrations/didomi/consent" if not set. #[serde(default)] + #[validate(custom(function = "validate_proxy_path"))] pub proxy_path: Option, /// Base URL for the Didomi SDK origin. #[serde(default = "default_sdk_origin")] @@ -36,6 +40,33 @@ pub struct DidomiIntegrationConfig { pub api_origin: String, } +/// Validates the optional `proxy_path` value. +/// Rejects empty, root-only, trailing-slash, and values containing +/// characters that are unsafe for URL path routing. +fn validate_proxy_path(value: &str) -> Result<(), ValidationError> { + let trimmed = value.trim_start_matches('/'); + + if trimmed.is_empty() { + return Err(ValidationError::new("proxy_path_empty")); + } + + if trimmed.ends_with('/') { + return Err(ValidationError::new("proxy_path_trailing_slash")); + } + + if trimmed.contains("//") { + return Err(ValidationError::new("proxy_path_double_slash")); + } + + // Reject characters unsafe for matchit routes or URL paths + const FORBIDDEN: &[char] = &['?', '#', '{', '}', '*', ' ']; + if trimmed.contains(FORBIDDEN) { + return Err(ValidationError::new("proxy_path_forbidden_chars")); + } + + Ok(()) +} + impl IntegrationConfig for DidomiIntegrationConfig { fn is_enabled(&self) -> bool { self.enabled @@ -75,6 +106,14 @@ impl DidomiIntegration { } } + /// Returns the canonicalized proxy prefix: always starts with `/`, no trailing slash. + fn resolved_prefix(&self) -> String { + match &self.config.proxy_path { + Some(custom) => format!("/{}", custom.trim_start_matches('/')), + None => DIDOMI_DEFAULT_PREFIX.to_string(), + } + } + fn backend_for_path(&self, consent_path: &str) -> DidomiBackend { if consent_path.starts_with("/api/") { DidomiBackend::Api @@ -184,7 +223,8 @@ pub fn register( Ok(Some( IntegrationRegistration::builder(DIDOMI_INTEGRATION_ID) - .with_proxy(integration) + .with_proxy(integration.clone()) + .with_head_injector(integration) .build(), )) } @@ -196,10 +236,7 @@ impl IntegrationProxy for DidomiIntegration { } fn proxy_prefix(&self) -> String { - match &self.config.proxy_path { - Some(custom) => format!("/{}", custom.trim_start_matches('/')), - None => DIDOMI_DEFAULT_PREFIX.to_string(), - } + self.resolved_prefix() } fn routes(&self) -> Vec { @@ -212,7 +249,7 @@ impl IntegrationProxy for DidomiIntegration { req: Request, ) -> Result> { let path = req.get_path(); - let prefix = self.proxy_prefix(); + let prefix = self.resolved_prefix(); let consent_path = path.strip_prefix(&prefix).unwrap_or(path); let backend = self.backend_for_path(consent_path); let base_origin = match backend { @@ -248,10 +285,25 @@ impl IntegrationProxy for DidomiIntegration { } } +impl IntegrationHeadInjector for DidomiIntegration { + fn integration_id(&self) -> &'static str { + DIDOMI_INTEGRATION_ID + } + + fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + let proxy_path = self.resolved_prefix(); + // Escape `window.__tsjs_didomi={{proxyPath:"{safe_path}/"}};"# + )] + } +} + #[cfg(test)] mod tests { use super::*; - use crate::integrations::IntegrationRegistry; + use crate::integrations::{IntegrationDocumentState, IntegrationRegistry}; use crate::test_support::tests::create_test_settings; use fastly::http::Method; @@ -317,7 +369,79 @@ mod tests { let registry = IntegrationRegistry::new(&settings).expect("should create registry"); assert!(registry.has_route(&Method::GET, "/my-custom-consent/loader.js")); assert!(registry.has_route(&Method::POST, "/my-custom-consent/api/events")); - // Original path should NOT match assert!(!registry.has_route(&Method::GET, "/integrations/didomi/consent/loader.js")); } + + #[test] + fn validates_proxy_path_rejects_empty() { + assert!(validate_proxy_path("").is_err()); + assert!(validate_proxy_path("/").is_err()); + } + + #[test] + fn validates_proxy_path_rejects_trailing_slash() { + assert!(validate_proxy_path("my-path/").is_err()); + } + + #[test] + fn validates_proxy_path_rejects_forbidden_chars() { + assert!(validate_proxy_path("path?query").is_err()); + assert!(validate_proxy_path("path#frag").is_err()); + assert!(validate_proxy_path("{param}").is_err()); + assert!(validate_proxy_path("wild*card").is_err()); + assert!(validate_proxy_path("has space").is_err()); + } + + #[test] + fn validates_proxy_path_rejects_double_slash() { + assert!(validate_proxy_path("my//path").is_err()); + } + + #[test] + fn validates_proxy_path_accepts_valid() { + assert!(validate_proxy_path("my-custom-path").is_ok()); + assert!(validate_proxy_path("nested/path/here").is_ok()); + assert!(validate_proxy_path("/leading-slash-ok").is_ok()); + } + + #[test] + fn head_injector_emits_proxy_path() { + let custom_config = DidomiIntegrationConfig { + enabled: true, + proxy_path: Some("my-consent".to_string()), + sdk_origin: default_sdk_origin(), + api_origin: default_api_origin(), + }; + let integration = DidomiIntegration::new(Arc::new(custom_config)); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + let inserts = integration.head_inserts(&ctx); + assert_eq!(inserts.len(), 1); + assert_eq!( + inserts[0], + r#""# + ); + } + + #[test] + fn head_injector_default_path() { + let integration = DidomiIntegration::new(Arc::new(config(true))); + let doc_state = IntegrationDocumentState::default(); + let ctx = IntegrationHtmlContext { + request_host: "example.com", + request_scheme: "https", + origin_host: "example.com", + document_state: &doc_state, + }; + let inserts = integration.head_inserts(&ctx); + assert_eq!( + inserts[0], + r#""# + ); + } } diff --git a/docs/guide/integrations/didomi.md b/docs/guide/integrations/didomi.md index ada7057f..438dee99 100644 --- a/docs/guide/integrations/didomi.md +++ b/docs/guide/integrations/didomi.md @@ -56,20 +56,50 @@ api_origin = "https://api.privacy-center.org" ### Configuration Options -| Field | Type | Required | Default | Description | -| ------------ | ------- | -------- | -------------------------------- | -------------------------- | -| `enabled` | boolean | No | `false` | Enable/disable integration | -| `sdk_origin` | string | Yes | `https://sdk.privacy-center.org` | Didomi SDK backend URL | -| `api_origin` | string | Yes | `https://api.privacy-center.org` | Didomi API backend URL | +| Field | Type | Required | Default | Description | +| ------------ | ------- | -------- | -------------------------------- | ---------------------------------------- | +| `enabled` | boolean | No | `false` | Enable/disable integration | +| `proxy_path` | string | No | `integrations/didomi/consent` | Custom proxy URL path prefix (see below) | +| `sdk_origin` | string | Yes | `https://sdk.privacy-center.org` | Didomi SDK backend URL | +| `api_origin` | string | Yes | `https://api.privacy-center.org` | Didomi API backend URL | ### Environment Variables ```bash TRUSTED_SERVER__INTEGRATIONS__DIDOMI__ENABLED=true +TRUSTED_SERVER__INTEGRATIONS__DIDOMI__PROXY_PATH=my-custom-consent TRUSTED_SERVER__INTEGRATIONS__DIDOMI__SDK_ORIGIN=https://sdk.privacy-center.org TRUSTED_SERVER__INTEGRATIONS__DIDOMI__API_ORIGIN=https://api.privacy-center.org ``` +### Custom Proxy Path + +By default, Didomi requests are served at `/integrations/didomi/consent/*`. Since this path is predictable, ad blockers may add it to their block lists. Use `proxy_path` to set a customer-specific path that is harder to target: + +```toml +[integrations.didomi] +enabled = true +proxy_path = "my-custom-consent" +``` + +With this configuration, requests are served at `/my-custom-consent/*` instead of the default. + +**Format rules:** + +- Must not be empty or just `/` +- Must not end with a trailing slash +- Must not contain `?`, `#`, `{`, `}`, `*`, or spaces +- Must not contain consecutive slashes (`//`) +- Leading slash is optional (it is normalized internally) + +**Examples of valid values:** + +- `"consent-proxy"` → serves at `/consent-proxy/*` +- `"privacy/manage"` → serves at `/privacy/manage/*` +- `"/my-cmp-path"` → serves at `/my-cmp-path/*` + +The custom path is automatically passed to the client-side JavaScript bundle via `window.__tsjs_didomi.proxyPath`, so the Didomi SDK URL rewriting continues to work without additional frontend configuration. + ## Endpoints ### SDK Proxy