@@ -6,23 +6,31 @@ use fastly::http::{header, Method};
66use fastly:: { Request , Response } ;
77use serde:: { Deserialize , Serialize } ;
88use url:: Url ;
9- use validator:: Validate ;
9+ use validator:: { Validate , ValidationError } ;
1010
1111use crate :: backend:: BackendConfig ;
1212use crate :: error:: TrustedServerError ;
13- use crate :: integrations:: { IntegrationEndpoint , IntegrationProxy , IntegrationRegistration } ;
13+ use crate :: integrations:: {
14+ IntegrationEndpoint , IntegrationHeadInjector , IntegrationHtmlContext , IntegrationProxy ,
15+ IntegrationRegistration ,
16+ } ;
1417use crate :: platform:: RuntimeServices ;
1518use crate :: settings:: { IntegrationConfig , Settings } ;
1619
1720const DIDOMI_INTEGRATION_ID : & str = "didomi" ;
18- const DIDOMI_PREFIX : & str = "/integrations/didomi/consent" ;
21+ const DIDOMI_DEFAULT_PREFIX : & str = "/integrations/didomi/consent" ;
1922
2023/// Configuration for the Didomi consent notice reverse proxy.
2124#[ derive( Debug , Clone , Deserialize , Serialize , Validate ) ]
2225pub struct DidomiIntegrationConfig {
2326 /// Whether the integration is enabled.
2427 #[ serde( default = "default_enabled" ) ]
2528 pub enabled : bool ,
29+ /// Custom proxy path prefix to avoid ad-blocker detection.
30+ /// Defaults to "integrations/didomi/consent" if not set.
31+ #[ serde( default ) ]
32+ #[ validate( custom( function = "validate_proxy_path" ) ) ]
33+ pub proxy_path : Option < String > ,
2634 /// Base URL for the Didomi SDK origin.
2735 #[ serde( default = "default_sdk_origin" ) ]
2836 #[ validate( url) ]
@@ -33,6 +41,41 @@ pub struct DidomiIntegrationConfig {
3341 pub api_origin : String ,
3442}
3543
44+ /// Validates the optional `proxy_path` value.
45+ /// Rejects empty, root-only, trailing-slash, dot-segment, and values
46+ /// containing characters that are unsafe for URL path routing.
47+ fn validate_proxy_path ( value : & str ) -> Result < ( ) , ValidationError > {
48+ let trimmed = value. trim_start_matches ( '/' ) ;
49+
50+ if trimmed. is_empty ( ) {
51+ return Err ( ValidationError :: new ( "proxy_path_empty" ) ) ;
52+ }
53+
54+ if trimmed. ends_with ( '/' ) {
55+ return Err ( ValidationError :: new ( "proxy_path_trailing_slash" ) ) ;
56+ }
57+
58+ if trimmed. contains ( "//" ) {
59+ return Err ( ValidationError :: new ( "proxy_path_double_slash" ) ) ;
60+ }
61+
62+ if trimmed
63+ . split ( '/' )
64+ . any ( |segment| matches ! ( segment, "." | ".." ) )
65+ {
66+ return Err ( ValidationError :: new ( "proxy_path_dot_segment" ) ) ;
67+ }
68+
69+ if !trimmed
70+ . chars ( )
71+ . all ( |ch| ch. is_ascii_alphanumeric ( ) || matches ! ( ch, '-' | '_' | '.' | '~' | '/' ) )
72+ {
73+ return Err ( ValidationError :: new ( "proxy_path_forbidden_chars" ) ) ;
74+ }
75+
76+ Ok ( ( ) )
77+ }
78+
3679impl IntegrationConfig for DidomiIntegrationConfig {
3780 fn is_enabled ( & self ) -> bool {
3881 self . enabled
@@ -72,6 +115,14 @@ impl DidomiIntegration {
72115 }
73116 }
74117
118+ /// Returns the canonicalized proxy prefix: always starts with `/`, no trailing slash.
119+ fn resolved_prefix ( & self ) -> String {
120+ match & self . config . proxy_path {
121+ Some ( custom) => format ! ( "/{}" , custom. trim_start_matches( '/' ) ) ,
122+ None => DIDOMI_DEFAULT_PREFIX . to_string ( ) ,
123+ }
124+ }
125+
75126 fn backend_for_path ( & self , consent_path : & str ) -> DidomiBackend {
76127 if consent_path. starts_with ( "/api/" ) {
77128 DidomiBackend :: Api
@@ -181,7 +232,8 @@ pub fn register(
181232
182233 Ok ( Some (
183234 IntegrationRegistration :: builder ( DIDOMI_INTEGRATION_ID )
184- . with_proxy ( integration)
235+ . with_proxy ( integration. clone ( ) )
236+ . with_head_injector ( integration)
185237 . build ( ) ,
186238 ) )
187239}
@@ -192,8 +244,12 @@ impl IntegrationProxy for DidomiIntegration {
192244 DIDOMI_INTEGRATION_ID
193245 }
194246
247+ fn proxy_prefix ( & self ) -> String {
248+ self . resolved_prefix ( )
249+ }
250+
195251 fn routes ( & self ) -> Vec < IntegrationEndpoint > {
196- vec ! [ self . get( "/consent/ *" ) , self . post( "/consent /*" ) ]
252+ vec ! [ self . get( "/*" ) , self . post( "/*" ) ]
197253 }
198254
199255 async fn handle (
@@ -203,7 +259,8 @@ impl IntegrationProxy for DidomiIntegration {
203259 req : Request ,
204260 ) -> Result < Response , Report < TrustedServerError > > {
205261 let path = req. get_path ( ) ;
206- let consent_path = path. strip_prefix ( DIDOMI_PREFIX ) . unwrap_or ( path) ;
262+ let prefix = self . resolved_prefix ( ) ;
263+ let consent_path = path. strip_prefix ( & prefix) . unwrap_or ( path) ;
207264 let backend = self . backend_for_path ( consent_path) ;
208265 let base_origin = match backend {
209266 DidomiBackend :: Sdk => self . config . sdk_origin . as_str ( ) ,
@@ -238,16 +295,47 @@ impl IntegrationProxy for DidomiIntegration {
238295 }
239296}
240297
298+ impl IntegrationHeadInjector for DidomiIntegration {
299+ fn integration_id ( & self ) -> & ' static str {
300+ DIDOMI_INTEGRATION_ID
301+ }
302+
303+ fn head_inserts ( & self , _ctx : & IntegrationHtmlContext < ' _ > ) -> Vec < String > {
304+ #[ derive( Serialize ) ]
305+ #[ serde( rename_all = "camelCase" ) ]
306+ struct InjectedDidomiClientConfig {
307+ proxy_path : String ,
308+ }
309+
310+ let payload = InjectedDidomiClientConfig {
311+ proxy_path : format ! ( "{}/" , self . resolved_prefix( ) ) ,
312+ } ;
313+
314+ // Escape `</` to prevent breaking out of the script tag.
315+ let config_json = serde_json:: to_string ( & payload)
316+ . unwrap_or_else ( |e| {
317+ log:: warn!( "Didomi: failed to serialize client config: {e}" ) ;
318+ "{}" . to_string ( )
319+ } )
320+ . replace ( "</" , "<\\ /" ) ;
321+
322+ vec ! [ format!(
323+ r#"<script>window.__tsjs_didomi={config_json};</script>"#
324+ ) ]
325+ }
326+ }
327+
241328#[ cfg( test) ]
242329mod tests {
243330 use super :: * ;
244- use crate :: integrations:: IntegrationRegistry ;
331+ use crate :: integrations:: { IntegrationDocumentState , IntegrationRegistry } ;
245332 use crate :: test_support:: tests:: create_test_settings;
246333 use fastly:: http:: Method ;
247334
248335 fn config ( enabled : bool ) -> DidomiIntegrationConfig {
249336 DidomiIntegrationConfig {
250337 enabled,
338+ proxy_path : None ,
251339 sdk_origin : default_sdk_origin ( ) ,
252340 api_origin : default_api_origin ( ) ,
253341 }
@@ -288,4 +376,107 @@ mod tests {
288376 assert ! ( registry. has_route( & Method :: POST , "/integrations/didomi/consent/api/events" ) ) ;
289377 assert ! ( !registry. has_route( & Method :: GET , "/other" ) ) ;
290378 }
379+
380+ #[ test]
381+ fn registers_custom_proxy_path ( ) {
382+ let mut settings = create_test_settings ( ) ;
383+ let custom_config = DidomiIntegrationConfig {
384+ enabled : true ,
385+ proxy_path : Some ( "my-custom-consent" . to_string ( ) ) ,
386+ sdk_origin : default_sdk_origin ( ) ,
387+ api_origin : default_api_origin ( ) ,
388+ } ;
389+ settings
390+ . integrations
391+ . insert_config ( DIDOMI_INTEGRATION_ID , & custom_config)
392+ . expect ( "should insert config" ) ;
393+
394+ let registry = IntegrationRegistry :: new ( & settings) . expect ( "should create registry" ) ;
395+ assert ! ( registry. has_route( & Method :: GET , "/my-custom-consent/loader.js" ) ) ;
396+ assert ! ( registry. has_route( & Method :: POST , "/my-custom-consent/api/events" ) ) ;
397+ assert ! ( !registry. has_route( & Method :: GET , "/integrations/didomi/consent/loader.js" ) ) ;
398+ }
399+
400+ #[ test]
401+ fn validates_proxy_path_rejects_empty ( ) {
402+ assert ! ( validate_proxy_path( "" ) . is_err( ) ) ;
403+ assert ! ( validate_proxy_path( "/" ) . is_err( ) ) ;
404+ }
405+
406+ #[ test]
407+ fn validates_proxy_path_rejects_trailing_slash ( ) {
408+ assert ! ( validate_proxy_path( "my-path/" ) . is_err( ) ) ;
409+ }
410+
411+ #[ test]
412+ fn validates_proxy_path_rejects_forbidden_chars ( ) {
413+ assert ! ( validate_proxy_path( "path?query" ) . is_err( ) ) ;
414+ assert ! ( validate_proxy_path( "path#frag" ) . is_err( ) ) ;
415+ assert ! ( validate_proxy_path( "{param}" ) . is_err( ) ) ;
416+ assert ! ( validate_proxy_path( "wild*card" ) . is_err( ) ) ;
417+ assert ! ( validate_proxy_path( "has space" ) . is_err( ) ) ;
418+ assert ! ( validate_proxy_path( "has\" quote" ) . is_err( ) ) ;
419+ assert ! ( validate_proxy_path( "has\\ backslash" ) . is_err( ) ) ;
420+ assert ! ( validate_proxy_path( "has\n newline" ) . is_err( ) ) ;
421+ assert ! ( validate_proxy_path( "encoded%2e%2e/path" ) . is_err( ) ) ;
422+ }
423+
424+ #[ test]
425+ fn validates_proxy_path_rejects_double_slash ( ) {
426+ assert ! ( validate_proxy_path( "my//path" ) . is_err( ) ) ;
427+ }
428+
429+ #[ test]
430+ fn validates_proxy_path_rejects_dot_segments ( ) {
431+ assert ! ( validate_proxy_path( "my/./path" ) . is_err( ) ) ;
432+ assert ! ( validate_proxy_path( "my/../path" ) . is_err( ) ) ;
433+ }
434+
435+ #[ test]
436+ fn validates_proxy_path_accepts_valid ( ) {
437+ assert ! ( validate_proxy_path( "my-custom-path" ) . is_ok( ) ) ;
438+ assert ! ( validate_proxy_path( "nested/path/here" ) . is_ok( ) ) ;
439+ assert ! ( validate_proxy_path( "/leading-slash-ok" ) . is_ok( ) ) ;
440+ }
441+
442+ #[ test]
443+ fn head_injector_emits_proxy_path ( ) {
444+ let custom_config = DidomiIntegrationConfig {
445+ enabled : true ,
446+ proxy_path : Some ( "my-consent" . to_string ( ) ) ,
447+ sdk_origin : default_sdk_origin ( ) ,
448+ api_origin : default_api_origin ( ) ,
449+ } ;
450+ let integration = DidomiIntegration :: new ( Arc :: new ( custom_config) ) ;
451+ let doc_state = IntegrationDocumentState :: default ( ) ;
452+ let ctx = IntegrationHtmlContext {
453+ request_host : "example.com" ,
454+ request_scheme : "https" ,
455+ origin_host : "example.com" ,
456+ document_state : & doc_state,
457+ } ;
458+ let inserts = integration. head_inserts ( & ctx) ;
459+ assert_eq ! ( inserts. len( ) , 1 ) ;
460+ assert_eq ! (
461+ inserts[ 0 ] ,
462+ r#"<script>window.__tsjs_didomi={"proxyPath":"/my-consent/"};</script>"#
463+ ) ;
464+ }
465+
466+ #[ test]
467+ fn head_injector_default_path ( ) {
468+ let integration = DidomiIntegration :: new ( Arc :: new ( config ( true ) ) ) ;
469+ let doc_state = IntegrationDocumentState :: default ( ) ;
470+ let ctx = IntegrationHtmlContext {
471+ request_host : "example.com" ,
472+ request_scheme : "https" ,
473+ origin_host : "example.com" ,
474+ document_state : & doc_state,
475+ } ;
476+ let inserts = integration. head_inserts ( & ctx) ;
477+ assert_eq ! (
478+ inserts[ 0 ] ,
479+ r#"<script>window.__tsjs_didomi={"proxyPath":"/integrations/didomi/consent/"};</script>"#
480+ ) ;
481+ }
291482}
0 commit comments