Skip to content

Commit bc6d78c

Browse files
vasujain00Vasu JainChristianPavilonis
authored
feat: make integration proxy paths configurable per customer (#759)
* 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 `/<proxy_path>/*` 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. * 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 --------- Co-authored-by: Vasu Jain <vasujai@amazon.com> Co-authored-by: Christian Pavilonis <christianpavilonis@outlook.com>
1 parent 0f9a5c2 commit bc6d78c

5 files changed

Lines changed: 289 additions & 55 deletions

File tree

crates/js/lib/src/integrations/didomi/index.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
import { log } from '../../core/log';
22

3-
const DEFAULT_SDK_PATH = 'https://sdk.privacy-center.org/';
4-
const CONSENT_PROXY_PATH = '/integrations/didomi/consent/';
3+
const DEFAULT_CONSENT_PROXY_PATH = '/integrations/didomi/consent/';
54

65
type DidomiConfig = {
76
sdkPath?: string;
87
[key: string]: unknown;
98
};
109

11-
type DidomiWindow = Window & { didomiConfig?: DidomiConfig };
10+
type DidomiWindow = Window & {
11+
didomiConfig?: DidomiConfig;
12+
__tsjs_didomi?: { proxyPath?: string };
13+
};
14+
15+
/** Read the server-injected proxy path, falling back to the default. */
16+
function getConsentProxyPath(win: DidomiWindow): string {
17+
return win.__tsjs_didomi?.proxyPath ?? DEFAULT_CONSENT_PROXY_PATH;
18+
}
1219

1320
function buildProxySdkPath(win: DidomiWindow): string {
21+
const proxyPath = getConsentProxyPath(win);
1422
const base = win.location?.origin ?? win.location?.href;
15-
if (!base) return CONSENT_PROXY_PATH;
16-
const url = new URL(CONSENT_PROXY_PATH, base);
23+
if (!base) return proxyPath;
24+
const url = new URL(proxyPath, base);
1725
return `${url.origin}${url.pathname}`;
1826
}
1927

@@ -25,7 +33,7 @@ export function installDidomiSdkProxy(): boolean {
2533
const previousSdkPath =
2634
typeof config.sdkPath === 'string' && config.sdkPath.length > 0
2735
? config.sdkPath
28-
: DEFAULT_SDK_PATH;
36+
: 'https://sdk.privacy-center.org/';
2937

3038
const proxiedSdkPath = buildProxySdkPath(win);
3139
config.sdkPath = proxiedSdkPath;

crates/js/lib/test/integrations/didomi/index.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@ import { installDidomiSdkProxy } from '../../../src/integrations/didomi';
44

55
const ORIGINAL_WINDOW = global.window;
66

7+
type TestDidomiWindow = Window & {
8+
didomiConfig?: any;
9+
__tsjs_didomi?: { proxyPath?: string };
10+
};
11+
712
function createWindow(url: string) {
813
return {
914
location: new URL(url) as unknown as Location,
10-
} as Window & { didomiConfig?: any };
15+
} as TestDidomiWindow;
1116
}
1217

1318
describe('integrations/didomi', () => {
@@ -41,4 +46,12 @@ describe('integrations/didomi', () => {
4146
'https://example.com/integrations/didomi/consent/'
4247
);
4348
});
49+
50+
it('uses the server-injected custom proxy path', () => {
51+
testWindow.__tsjs_didomi = { proxyPath: '/my-custom-consent/' };
52+
53+
installDidomiSdkProxy();
54+
55+
expect(testWindow.didomiConfig.sdkPath).toBe('https://example.com/my-custom-consent/');
56+
});
4457
});

crates/trusted-server-core/src/integrations/didomi.rs

Lines changed: 198 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,31 @@ use fastly::http::{header, Method};
66
use fastly::{Request, Response};
77
use serde::{Deserialize, Serialize};
88
use url::Url;
9-
use validator::Validate;
9+
use validator::{Validate, ValidationError};
1010

1111
use crate::backend::BackendConfig;
1212
use crate::error::TrustedServerError;
13-
use crate::integrations::{IntegrationEndpoint, IntegrationProxy, IntegrationRegistration};
13+
use crate::integrations::{
14+
IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy,
15+
IntegrationRegistration,
16+
};
1417
use crate::platform::RuntimeServices;
1518
use crate::settings::{IntegrationConfig, Settings};
1619

1720
const 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)]
2225
pub 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+
3679
impl 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)]
242329
mod 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\nnewline").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

Comments
 (0)