Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions crates/js/lib/src/integrations/didomi/index.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}

Expand All @@ -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;
Expand Down
172 changes: 165 additions & 7 deletions crates/trusted-server-core/src/integrations/didomi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,30 @@ 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";
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)]
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)]
#[validate(custom(function = "validate_proxy_path"))]
pub proxy_path: Option<String>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Custom path also needs to reach the client-side URL builder

Thanks for adding the config knob here. One important gap: this field only changes the Rust route registration, but the Didomi browser module still builds sdkPath from the hard-coded /integrations/didomi/consent/ path in crates/js/lib/src/integrations/didomi/index.ts. With proxy_path set, this PR registers only the custom route and the new test asserts the old route no longer matches, so pages would still request the old prefix and miss the proxy.

Could we pass the resolved prefix to the JS runtime (for example via a small head-injected window.__tsjs_didomi.proxyPath config) and have the TypeScript fall back to the current default when it is absent?

@vasujain00 vasujain00 Jun 10, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed ✅ — DidomiIntegration now implements IntegrationHeadInjector and injects:

<script>window.__tsjs_didomi={proxyPath:"/custom-path/"};</script>

The TypeScript module reads window.__tsjs_didomi?.proxyPath at runtime, falling back to /integrations/didomi/consent/ when absent. Follows the same pattern as window.__tsjs_prebid.

/// Base URL for the Didomi SDK origin.
#[serde(default = "default_sdk_origin")]
#[validate(url)]
Expand All @@ -32,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
Expand Down Expand Up @@ -71,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
Expand Down Expand Up @@ -180,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(),
))
}
Expand All @@ -191,8 +235,12 @@ impl IntegrationProxy for DidomiIntegration {
DIDOMI_INTEGRATION_ID
}

fn proxy_prefix(&self) -> String {
self.resolved_prefix()
}

fn routes(&self) -> Vec<IntegrationEndpoint> {
vec![self.get("/consent/*"), self.post("/consent/*")]
vec![self.get("/*"), self.post("/*")]
}

async fn handle(
Expand All @@ -201,7 +249,8 @@ impl IntegrationProxy for DidomiIntegration {
req: Request,
) -> Result<Response, Report<TrustedServerError>> {
let path = req.get_path();
let consent_path = path.strip_prefix(DIDOMI_PREFIX).unwrap_or(path);
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 {
DidomiBackend::Sdk => self.config.sdk_origin.as_str(),
Expand Down Expand Up @@ -236,16 +285,32 @@ impl IntegrationProxy for DidomiIntegration {
}
}

impl IntegrationHeadInjector for DidomiIntegration {
fn integration_id(&self) -> &'static str {
DIDOMI_INTEGRATION_ID
}

fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec<String> {
let proxy_path = self.resolved_prefix();
// Escape `</` to prevent breaking out of the script tag.
let safe_path = proxy_path.replace("</", "<\\/");
vec![format!(
r#"<script>window.__tsjs_didomi={{proxyPath:"{safe_path}/"}};</script>"#
)]
}
}

#[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;

fn config(enabled: bool) -> DidomiIntegrationConfig {
DidomiIntegrationConfig {
enabled,
proxy_path: None,
sdk_origin: default_sdk_origin(),
api_origin: default_api_origin(),
}
Expand Down Expand Up @@ -286,4 +351,97 @@ 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"));
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#"<script>window.__tsjs_didomi={proxyPath:"/my-consent/"};</script>"#
);
}

#[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#"<script>window.__tsjs_didomi={proxyPath:"/integrations/didomi/consent/"};</script>"#
);
}
}
63 changes: 27 additions & 36 deletions crates/trusted-server-core/src/integrations/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IntegrationEndpoint>;

Expand All @@ -264,62 +280,37 @@ pub trait IntegrationProxy: Send + Sync {
) -> Result<Response, Report<TrustedServerError>>;

/// 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)
}
}
Expand Down
Loading