Skip to content
Merged
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
15 changes: 14 additions & 1 deletion crates/js/lib/test/integrations/didomi/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import { installDidomiSdkProxy } from '../../../src/integrations/didomi';

const ORIGINAL_WINDOW = global.window;

type TestDidomiWindow = Window & {
didomiConfig?: any;
__tsjs_didomi?: { proxyPath?: string };
};

function createWindow(url: string) {
return {
location: new URL(url) as unknown as Location,
} as Window & { didomiConfig?: any };
} as TestDidomiWindow;
}

describe('integrations/didomi', () => {
Expand Down Expand Up @@ -41,4 +46,12 @@ describe('integrations/didomi', () => {
'https://example.com/integrations/didomi/consent/'
);
});

it('uses the server-injected custom proxy path', () => {
testWindow.__tsjs_didomi = { proxyPath: '/my-custom-consent/' };

installDidomiSdkProxy();

expect(testWindow.didomiConfig.sdkPath).toBe('https://example.com/my-custom-consent/');
});
});
205 changes: 198 additions & 7 deletions crates/trusted-server-core/src/integrations/didomi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,31 @@ 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::platform::RuntimeServices;
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>,
Comment thread
ChristianPavilonis marked this conversation as resolved.
/// Base URL for the Didomi SDK origin.
#[serde(default = "default_sdk_origin")]
#[validate(url)]
Expand All @@ -33,6 +41,41 @@ pub struct DidomiIntegrationConfig {
pub api_origin: String,
}

/// Validates the optional `proxy_path` value.
/// Rejects empty, root-only, trailing-slash, dot-segment, 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"));
}

if trimmed
.split('/')
.any(|segment| matches!(segment, "." | ".."))
{
return Err(ValidationError::new("proxy_path_dot_segment"));
}

if !trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~' | '/'))
{
return Err(ValidationError::new("proxy_path_forbidden_chars"));
}

Ok(())
}

impl IntegrationConfig for DidomiIntegrationConfig {
fn is_enabled(&self) -> bool {
self.enabled
Expand Down Expand Up @@ -72,6 +115,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 @@ -181,7 +232,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 @@ -192,8 +244,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 @@ -203,7 +259,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 @@ -238,16 +295,47 @@ 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> {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct InjectedDidomiClientConfig {
proxy_path: String,
}

let payload = InjectedDidomiClientConfig {
proxy_path: format!("{}/", self.resolved_prefix()),
};

// Escape `</` to prevent breaking out of the script tag.
let config_json = serde_json::to_string(&payload)
.unwrap_or_else(|e| {
log::warn!("Didomi: failed to serialize client config: {e}");
"{}".to_string()
})
.replace("</", "<\\/");

vec![format!(
r#"<script>window.__tsjs_didomi={config_json};</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 @@ -288,4 +376,107 @@ 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());
assert!(validate_proxy_path("has\"quote").is_err());
assert!(validate_proxy_path("has\\backslash").is_err());
assert!(validate_proxy_path("has\nnewline").is_err());
assert!(validate_proxy_path("encoded%2e%2e/path").is_err());
}

#[test]
fn validates_proxy_path_rejects_double_slash() {
assert!(validate_proxy_path("my//path").is_err());
}

#[test]
fn validates_proxy_path_rejects_dot_segments() {
assert!(validate_proxy_path("my/./path").is_err());
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>"#
);
}
}
Loading
Loading