diff --git a/crates/common/src/backend.rs b/crates/common/src/backend.rs index cb49ccf..e2d39b0 100644 --- a/crates/common/src/backend.rs +++ b/crates/common/src/backend.rs @@ -39,7 +39,7 @@ pub fn ensure_origin_backend( .first_byte_timeout(Duration::from_secs(15)) .between_bytes_timeout(Duration::from_secs(10)); if scheme.eq_ignore_ascii_case("https") { - builder = builder.enable_ssl(); + builder = builder.enable_ssl().sni_hostname(host); } match builder.finish() { diff --git a/crates/common/src/integrations/lockr.rs b/crates/common/src/integrations/lockr.rs new file mode 100644 index 0000000..014e226 --- /dev/null +++ b/crates/common/src/integrations/lockr.rs @@ -0,0 +1,670 @@ +//! Lockr integration for identity resolution and advertising tokens. +//! +//! This module provides transparent proxying for Lockr's SDK and API, +//! enabling first-party identity resolution while maintaining privacy controls. +//! +//! ## Host Rewriting +//! +//! The integration can rewrite the Lockr SDK JavaScript to replace the hardcoded +//! API host with a relative URL pointing to the first-party proxy. This ensures +//! all API calls from the SDK go through the trusted server instead of directly +//! to Lockr's servers, improving privacy and enabling additional controls. +//! +//! The rewriting finds the obfuscated host assignment pattern in the SDK and +//! replaces it with: `'host': '/integrations/lockr/api'` + +use std::sync::Arc; + +use async_trait::async_trait; +use error_stack::{Report, ResultExt}; +use fastly::http::{header, Method, StatusCode}; +use fastly::{Request, Response}; +use regex::Regex; +use serde::Deserialize; +use validator::Validate; + +use crate::backend::ensure_backend_from_url; +use crate::error::TrustedServerError; +use crate::integrations::{ + AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, + IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, +}; +use crate::settings::{IntegrationConfig as IntegrationConfigTrait, Settings}; + +const LOCKR_INTEGRATION_ID: &str = "lockr"; + +/// Configuration for Lockr integration. +#[derive(Debug, Deserialize, Validate)] +pub struct LockrConfig { + /// Enable/disable the integration + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Lockr app ID (from meta tag lockr-signin-app_id) + #[validate(length(min = 1))] + pub app_id: String, + + /// Base URL for Lockr API (default: https://identity.lockr.kr) + #[serde(default = "default_api_endpoint")] + #[validate(url)] + pub api_endpoint: String, + + /// SDK URL (default: https://aim.loc.kr/identity-lockr-v1.0.js) + #[serde(default = "default_sdk_url")] + #[validate(url)] + pub sdk_url: String, + + /// Cache TTL for Lockr SDK in seconds (default: 3600 = 1 hour) + #[serde(default = "default_cache_ttl")] + #[validate(range(min = 60, max = 86400))] + pub cache_ttl_seconds: u32, + + /// Whether to rewrite Lockr SDK URLs in HTML + #[serde(default = "default_rewrite_sdk")] + pub rewrite_sdk: bool, + + /// Whether to rewrite the host variable in the Lockr SDK JavaScript + #[serde(default = "default_rewrite_sdk_host")] + pub rewrite_sdk_host: bool, +} + +impl IntegrationConfigTrait for LockrConfig { + fn is_enabled(&self) -> bool { + self.enabled + } +} + +/// Lockr integration implementation. +pub struct LockrIntegration { + config: LockrConfig, +} + +impl LockrIntegration { + fn new(config: LockrConfig) -> Arc { + Arc::new(Self { config }) + } + + fn error(message: impl Into) -> TrustedServerError { + TrustedServerError::Integration { + integration: LOCKR_INTEGRATION_ID.to_string(), + message: message.into(), + } + } + + /// Check if a URL is a Lockr SDK URL. + fn is_lockr_sdk_url(&self, url: &str) -> bool { + let lower = url.to_ascii_lowercase(); + lower.contains("aim.loc.kr") + || lower.contains("identity.loc.kr") + && lower.contains("identity-lockr") + && lower.ends_with(".js") + } + + /// Rewrite the host variable in the Lockr SDK JavaScript. + /// + /// Replaces the obfuscated host assignment with a direct assignment to the + /// first-party API proxy endpoint. Uses regex to match varying obfuscation patterns. + fn rewrite_sdk_host(&self, sdk_body: Vec) -> Result, Report> { + // Convert bytes to string + let sdk_string = String::from_utf8(sdk_body) + .change_context(Self::error("SDK content is not valid UTF-8"))?; + + // Pattern matches: 'host': _0xABCDEF(0x123) + _0xABCDEF(0x456) + _0xABCDEF(0x789) + // This is the obfuscated way Lockr constructs the API host + // The function names and hex values change with each build, so we use regex + let pattern = Regex::new( + r"'host':\s*_0x[a-f0-9]+\(0x[a-f0-9]+\)\s*\+\s*_0x[a-f0-9]+\(0x[a-f0-9]+\)\s*\+\s*_0x[a-f0-9]+\(0x[a-f0-9]+\)", + ) + .change_context(Self::error("Failed to compile regex pattern"))?; + + // Replace with first-party API proxy endpoint + let rewritten = pattern.replace(&sdk_string, "'host': '/integrations/lockr/api'"); + + Ok(rewritten.as_bytes().to_vec()) + } + + /// Handle SDK serving - fetch from Lockr CDN and serve through first-party domain. + async fn handle_sdk_serving( + &self, + _settings: &Settings, + _req: Request, + ) -> Result> { + log::info!("Handling Lockr SDK request"); + + let sdk_url = &self.config.sdk_url; + log::info!("Fetching Lockr SDK from: {}", sdk_url); + + // TODO: Check KV store cache first (future enhancement) + + // Fetch SDK from Lockr CDN + let mut lockr_req = Request::new(Method::GET, sdk_url); + lockr_req.set_header(header::USER_AGENT, "TrustedServer/1.0"); + lockr_req.set_header(header::ACCEPT, "application/javascript, */*"); + + let backend_name = ensure_backend_from_url(sdk_url) + .change_context(Self::error("Failed to determine backend for SDK fetch"))?; + + let mut lockr_response = + lockr_req + .send(backend_name) + .change_context(Self::error(format!( + "Failed to fetch Lockr SDK from {}", + sdk_url + )))?; + + if !lockr_response.get_status().is_success() { + log::error!( + "Lockr SDK fetch failed with status: {}", + lockr_response.get_status() + ); + return Err(Report::new(Self::error(format!( + "Lockr SDK returned error status: {}", + lockr_response.get_status() + )))); + } + + let mut sdk_body = lockr_response.take_body_bytes(); + log::info!("Successfully fetched Lockr SDK: {} bytes", sdk_body.len()); + + // Rewrite the host variable in the SDK if enabled + if self.config.rewrite_sdk_host { + sdk_body = self.rewrite_sdk_host(sdk_body)?; + log::info!("Rewrote SDK host variable: {} bytes", sdk_body.len()); + } + + // TODO: Cache in KV store (future enhancement) + + Ok(Response::from_status(StatusCode::OK) + .with_header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .with_header( + header::CACHE_CONTROL, + format!( + "public, max-age={}, immutable", + self.config.cache_ttl_seconds + ), + ) + .with_header("X-Lockr-SDK-Proxy", "true") + .with_header("X-SDK-Source", sdk_url) + .with_header( + "X-Lockr-Host-Rewritten", + self.config.rewrite_sdk_host.to_string(), + ) + .with_body(sdk_body)) + } + + /// Handle API proxy - forward requests to identity.lockr.kr. + async fn handle_api_proxy( + &self, + _settings: &Settings, + mut req: Request, + ) -> Result> { + let original_path = req.get_path(); + let method = req.get_method(); + + log::info!("Proxying Lockr API request: {} {}", method, original_path); + + // Extract path after /integrations/lockr/api and pass through directly + // This allows the Lockr SDK to use any API endpoint without hardcoded mappings + let target_path = original_path + .strip_prefix("/integrations/lockr/api") + .ok_or_else(|| Self::error(format!("Invalid Lockr API path: {}", original_path)))?; + + // Build full target URL with query parameters + let query = req + .get_url() + .query() + .map(|q| format!("?{}", q)) + .unwrap_or_default(); + let target_url = format!("{}{}{}", self.config.api_endpoint, target_path, query); + + log::info!("Forwarding to Lockr API: {}", target_url); + + // Create new request + let mut target_req = Request::new(method.clone(), &target_url); + + // Copy headers + self.copy_request_headers(&req, &mut target_req); + + // Copy body for POST/PUT/PATCH + if matches!(method, &Method::POST | &Method::PUT | &Method::PATCH) { + let body = req.take_body(); + target_req.set_body(body); + } + + // Get backend and forward + let backend_name = ensure_backend_from_url(&self.config.api_endpoint) + .change_context(Self::error("Failed to determine backend for API proxy"))?; + + let response = match target_req.send(backend_name) { + Ok(res) => res, + Err(e) => { + return Err(Self::error(format!( + "failed to forward request to {}, {}", + target_url, + e.root_cause() + )) + .into()); + } + }; + + log::info!("Lockr API responded with status: {}", response.get_status()); + + Ok(response) + } + + /// Copy relevant request headers for proxying. + fn copy_request_headers(&self, from: &Request, to: &mut Request) { + let headers_to_copy = [ + header::CONTENT_TYPE, + header::ACCEPT, + header::USER_AGENT, + header::AUTHORIZATION, + header::ACCEPT_LANGUAGE, + header::ACCEPT_ENCODING, + header::COOKIE, + ]; + + for header_name in &headers_to_copy { + if let Some(value) = from.get_header(header_name) { + to.set_header(header_name, value); + } + } + + // Copy any X-* custom headers + for header_name in from.get_header_names() { + let name_str = header_name.as_str(); + if name_str.starts_with("x-") || name_str.starts_with("X-") { + if let Some(value) = from.get_header(header_name) { + to.set_header(header_name, value); + } + } + } + } +} + +fn build(settings: &Settings) -> Option> { + let config = match settings.integration_config::(LOCKR_INTEGRATION_ID) { + Ok(Some(config)) => config, + Ok(None) => return None, + Err(err) => { + log::error!("Failed to load Lockr integration config: {err:?}"); + return None; + } + }; + + Some(LockrIntegration::new(config)) +} + +/// Register the Lockr integration. +pub fn register(settings: &Settings) -> Option { + let integration = build(settings)?; + Some( + IntegrationRegistration::builder(LOCKR_INTEGRATION_ID) + .with_proxy(integration.clone()) + .with_attribute_rewriter(integration) + .build(), + ) +} + +#[async_trait(?Send)] +impl IntegrationProxy for LockrIntegration { + fn integration_name(&self) -> &'static str { + LOCKR_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + vec![self.get("/sdk"), self.post("/api/*"), self.get("/api/*")] + } + + async fn handle( + &self, + settings: &Settings, + req: Request, + ) -> Result> { + let path = req.get_path(); + + if path == "/integrations/lockr/sdk" { + self.handle_sdk_serving(settings, req).await + } else if path.starts_with("/integrations/lockr/api/") { + self.handle_api_proxy(settings, req).await + } else { + Err(Report::new(Self::error(format!( + "Unknown Lockr route: {}", + path + )))) + } + } +} + +impl IntegrationAttributeRewriter for LockrIntegration { + fn integration_id(&self) -> &'static str { + LOCKR_INTEGRATION_ID + } + + fn handles_attribute(&self, attribute: &str) -> bool { + self.config.rewrite_sdk && matches!(attribute, "src" | "href") + } + + fn rewrite( + &self, + _attr_name: &str, + attr_value: &str, + ctx: &IntegrationAttributeContext<'_>, + ) -> AttributeRewriteAction { + if !self.config.rewrite_sdk { + return AttributeRewriteAction::Keep; + } + + if self.is_lockr_sdk_url(attr_value) { + // Rewrite to first-party SDK endpoint + AttributeRewriteAction::Replace(format!( + "{}://{}/integrations/lockr/sdk", + ctx.request_scheme, ctx.request_host + )) + } else { + AttributeRewriteAction::Keep + } + } +} + +// Default value functions +fn default_enabled() -> bool { + true +} + +fn default_api_endpoint() -> String { + "https://identity.lockr.kr".to_string() +} + +fn default_sdk_url() -> String { + "https://aim.loc.kr/identity-lockr-v1.0.js".to_string() +} + +fn default_cache_ttl() -> u32 { + 3600 // 1 hour +} + +fn default_rewrite_sdk() -> bool { + true +} + +fn default_rewrite_sdk_host() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lockr_sdk_url_detection() { + let config = LockrConfig { + enabled: true, + app_id: "test-app-id".to_string(), + api_endpoint: default_api_endpoint(), + sdk_url: default_sdk_url(), + cache_ttl_seconds: 3600, + rewrite_sdk: true, + rewrite_sdk_host: true, + }; + let integration = LockrIntegration::new(config); + + // Should match Lockr SDK URLs + assert!(integration.is_lockr_sdk_url("https://aim.loc.kr/identity-lockr-v1.0.js")); + assert!(integration.is_lockr_sdk_url("https://identity.loc.kr/identity-lockr-v2.0.js")); + + // Should not match other URLs + assert!(!integration.is_lockr_sdk_url("https://example.com/script.js")); + } + + #[test] + fn test_attribute_rewriter_rewrites_sdk_urls() { + let config = LockrConfig { + enabled: true, + app_id: "test-app-id".to_string(), + api_endpoint: default_api_endpoint(), + sdk_url: default_sdk_url(), + cache_ttl_seconds: 3600, + rewrite_sdk: true, + rewrite_sdk_host: true, + }; + let integration = LockrIntegration::new(config); + + let ctx = IntegrationAttributeContext { + attribute_name: "src", + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + }; + + let rewritten = + integration.rewrite("src", "https://aim.loc.kr/identity-lockr-v1.0.js", &ctx); + + match rewritten { + AttributeRewriteAction::Replace(url) => { + assert_eq!(url, "https://edge.example.com/integrations/lockr/sdk"); + } + _ => panic!("Expected Replace action"), + } + } + + #[test] + fn test_attribute_rewriter_noop_when_disabled() { + let config = LockrConfig { + enabled: true, + app_id: "test-app-id".to_string(), + api_endpoint: default_api_endpoint(), + sdk_url: default_sdk_url(), + cache_ttl_seconds: 3600, + rewrite_sdk: false, // Disabled + rewrite_sdk_host: true, + }; + let integration = LockrIntegration::new(config); + + let ctx = IntegrationAttributeContext { + attribute_name: "src", + request_host: "edge.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + }; + + let rewritten = + integration.rewrite("src", "https://aim.loc.kr/identity-lockr-v1.0.js", &ctx); + + assert_eq!(rewritten, AttributeRewriteAction::Keep); + } + + #[test] + fn test_api_path_extraction_with_camel_case() { + // Test that we properly extract paths with correct casing + let path = "/integrations/lockr/api/publisher/app/v1/identityLockr/settings"; + let extracted = path.strip_prefix("/integrations/lockr/api").unwrap(); + assert_eq!(extracted, "/publisher/app/v1/identityLockr/settings"); + } + + #[test] + fn test_api_path_extraction_preserves_casing() { + // Test various Lockr API endpoints maintain their original casing + let test_cases = vec![ + ( + "/integrations/lockr/api/publisher/app/v1/identityLockr/settings", + "/publisher/app/v1/identityLockr/settings", + ), + ( + "/integrations/lockr/api/publisher/app/v1/identityLockr/page-view", + "/publisher/app/v1/identityLockr/page-view", + ), + ( + "/integrations/lockr/api/publisher/app/v1/identityLockr/generate-tokens", + "/publisher/app/v1/identityLockr/generate-tokens", + ), + ]; + + for (input, expected) in test_cases { + let result = input.strip_prefix("/integrations/lockr/api").unwrap(); + assert_eq!(result, expected, "Failed for input: {}", input); + } + } + + #[test] + fn test_routes_registered() { + let config = LockrConfig { + enabled: true, + app_id: "test-app-id".to_string(), + api_endpoint: default_api_endpoint(), + sdk_url: default_sdk_url(), + cache_ttl_seconds: 3600, + rewrite_sdk: true, + rewrite_sdk_host: true, + }; + let integration = LockrIntegration::new(config); + + let routes = integration.routes(); + assert_eq!(routes.len(), 3); + + // Verify SDK route + assert!(routes + .iter() + .any(|r| r.path == "/integrations/lockr/sdk" && r.method == Method::GET)); + + // Verify API routes (GET and POST) + assert!(routes + .iter() + .any(|r| r.path == "/integrations/lockr/api/*" && r.method == Method::POST)); + assert!(routes + .iter() + .any(|r| r.path == "/integrations/lockr/api/*" && r.method == Method::GET)); + } + + #[test] + fn test_sdk_host_rewriting() { + let config = LockrConfig { + enabled: true, + app_id: "test-app-id".to_string(), + api_endpoint: default_api_endpoint(), + sdk_url: default_sdk_url(), + cache_ttl_seconds: 3600, + rewrite_sdk: true, + rewrite_sdk_host: true, + }; + let integration = LockrIntegration::new(config); + + // Mock obfuscated SDK JavaScript with the host pattern (old pattern) + let mock_sdk_old = r#" +const identityLockr = { + 'host': _0x3a740e(0x3d1) + _0x3a740e(0x367) + _0x3a740e(0x14e), + 'app_id': null, + 'expiryDateKeys': localStorage['getItem']('identityLockr_expiryDateKeys') ? JSON['parse'](localStorage['getItem']('identityLockr_expiryDateKeys')) : [], + 'firstPartyCookies': [], + 'canRefreshToken': !![] +}; + "#; + + let result = integration.rewrite_sdk_host(mock_sdk_old.as_bytes().to_vec()); + assert!(result.is_ok()); + + let rewritten = String::from_utf8(result.unwrap()).unwrap(); + + // Verify the host was rewritten to the proxy endpoint + assert!(rewritten.contains("'host': '/integrations/lockr/api'")); + + // Verify the obfuscated pattern was removed + assert!(!rewritten.contains("_0x3a740e(0x3d1) + _0x3a740e(0x367) + _0x3a740e(0x14e)")); + + // Verify other parts of the code remain intact + assert!(rewritten.contains("'app_id': null")); + assert!(rewritten.contains("'firstPartyCookies': []")); + } + + #[test] + fn test_sdk_host_rewriting_real_pattern() { + let config = LockrConfig { + enabled: true, + app_id: "test-app-id".to_string(), + api_endpoint: default_api_endpoint(), + sdk_url: default_sdk_url(), + cache_ttl_seconds: 3600, + rewrite_sdk: true, + rewrite_sdk_host: true, + }; + let integration = LockrIntegration::new(config); + + // Real obfuscated SDK JavaScript from actual Lockr SDK + let mock_sdk_real = r#" +const identityLockr = { + 'host': _0x4ed951(0xcb) + _0x4ed951(0x173) + _0x4ed951(0x1c2), + 'app_id': null, + 'expiryDateKeys': localStorage['getItem']('identityLockr_expiryDateKeys') ? JSON['parse'](localStorage['getItem']('identityLockr_expiryDateKeys')) : [], + 'firstPartyCookies': [], + 'canRefreshToken': !![] +}; + "#; + + let result = integration.rewrite_sdk_host(mock_sdk_real.as_bytes().to_vec()); + assert!(result.is_ok()); + + let rewritten = String::from_utf8(result.unwrap()).unwrap(); + + // Verify the host was rewritten to the proxy endpoint + assert!(rewritten.contains("'host': '/integrations/lockr/api'")); + + // Verify the obfuscated pattern was removed + assert!(!rewritten.contains("_0x4ed951(0xcb)")); + assert!(!rewritten.contains("_0x4ed951(0x173)")); + assert!(!rewritten.contains("_0x4ed951(0x1c2)")); + + // Verify other parts of the code remain intact + assert!(rewritten.contains("'app_id': null")); + assert!(rewritten.contains("'firstPartyCookies': []")); + } + + #[test] + fn test_sdk_host_rewriting_disabled() { + let config = LockrConfig { + enabled: true, + app_id: "test-app-id".to_string(), + api_endpoint: default_api_endpoint(), + sdk_url: default_sdk_url(), + cache_ttl_seconds: 3600, + rewrite_sdk: true, + rewrite_sdk_host: false, // Disabled + }; + + // When rewrite_sdk_host is false, the handle_sdk_serving function + // won't call rewrite_sdk_host at all, so the SDK is served as-is + assert!(!config.rewrite_sdk_host); + } + + #[test] + fn test_sdk_host_rewriting_no_match() { + let config = LockrConfig { + enabled: true, + app_id: "test-app-id".to_string(), + api_endpoint: default_api_endpoint(), + sdk_url: default_sdk_url(), + cache_ttl_seconds: 3600, + rewrite_sdk: true, + rewrite_sdk_host: true, + }; + let integration = LockrIntegration::new(config); + + // Test with SDK that doesn't have the expected pattern + let mock_sdk = r#" +const identityLockr = { + 'host': 'https://example.com', + 'app_id': null +}; + "#; + + let result = integration.rewrite_sdk_host(mock_sdk.as_bytes().to_vec()); + assert!(result.is_ok()); + + let rewritten = String::from_utf8(result.unwrap()).unwrap(); + + // When pattern doesn't match, content should be unchanged + assert!(rewritten.contains("'host': 'https://example.com'")); + } +} diff --git a/crates/common/src/integrations/mod.rs b/crates/common/src/integrations/mod.rs index 888fa5a..3bca27b 100644 --- a/crates/common/src/integrations/mod.rs +++ b/crates/common/src/integrations/mod.rs @@ -2,6 +2,7 @@ use crate::settings::Settings; +pub mod lockr; pub mod nextjs; pub mod permutive; pub mod prebid; @@ -23,5 +24,6 @@ pub(crate) fn builders() -> &'static [IntegrationBuilder] { testlight::register, nextjs::register, permutive::register, + lockr::register, ] } diff --git a/crates/js/lib/src/integrations/lockr/index.ts b/crates/js/lib/src/integrations/lockr/index.ts new file mode 100644 index 0000000..c6174cf --- /dev/null +++ b/crates/js/lib/src/integrations/lockr/index.ts @@ -0,0 +1,107 @@ +import { log } from '../../core/log'; + +import { installNextJsGuard } from './nextjs_guard'; + +// Type definition for Lockr global +declare const identityLockr: IdentityLockr | undefined; + +interface IdentityLockr { + host: string; + app_id: string; + expiryDateKeys: string[]; + firstPartyCookies: string[]; + canRefreshToken: boolean; + macroDetectionEnabled: boolean; + iluiMacroDetection: boolean; + gdprApplies: boolean; + consentString: string; + gppString: string; + ccpaString: string; + isUTMTagsLoaded: boolean; + isFirstPartyCookiesLoaded: boolean; + allowedUTMTags: string[]; + lockrTrackingID: string; + panoramaClientId: string; + writeToDeviceConsentEUID: boolean; + id5JSEnabled: boolean; + firstIDPassHEM: boolean; + panoramaPassHEM: boolean; + firstIDEnabled: boolean; + panoramaEnabled: boolean; + isAdelphicEnabled: boolean; + os: string; + browser: string; + country: string; + city: string; + latitude: string; + longitude: string; + ip: string; + hashedUserAgent: string; + tokenMappings: Record; + tokenSourceMappings: Record; + identitProvidersType: Record; + identityIdEncryptionSalt: string; +} + +/** + * Install the Lockr shim to rewrite API endpoints to first-party domain. + * This function is called after the Lockr SDK has loaded and initialized. + */ +function installLockrShim() { + log.info('Installing Lockr shim - rewriting API host to first-party domain'); + + if (typeof identityLockr === 'undefined' || !identityLockr) { + log.warn('Lockr shim: identityLockr global not found'); + return; + } + + const host = window.location.host; + const protocol = window.location.protocol === 'https:' ? 'https' : 'http'; + + // Store original host for debugging + const originalHost = identityLockr.host; + + // Rewrite to first-party domain + // The Lockr SDK will now make all API calls through our proxy + identityLockr.host = `${protocol}://${host}/integrations/lockr/api`; + + log.info('Lockr shim installed', { + originalHost, + newHost: identityLockr.host, + appId: identityLockr.app_id, + }); +} + +/** + * Wait for Lockr SDK to be available before installing shim. + * Polls for SDK availability with a maximum number of attempts. + * + * @param callback - Function to call when SDK is available + * @param maxAttempts - Maximum number of polling attempts (default: 50) + */ +function waitForLockrSDK(callback: () => void, maxAttempts = 50) { + let attempts = 0; + + const check = () => { + attempts++; + + // Check if identityLockr global exists and is initialized with host + if (typeof identityLockr !== 'undefined' && identityLockr && identityLockr.host) { + log.info('Lockr SDK detected, installing shim'); + callback(); + } else if (attempts < maxAttempts) { + // Check again in 50ms + setTimeout(check, 50); + } else { + log.warn('Lockr SDK not detected after', maxAttempts * 50, 'ms'); + } + }; + + check(); +} + +if (typeof window !== 'undefined') { + installNextJsGuard(); + + waitForLockrSDK(() => installLockrShim()); +} diff --git a/crates/js/lib/src/integrations/lockr/nextjs_guard.ts b/crates/js/lib/src/integrations/lockr/nextjs_guard.ts new file mode 100644 index 0000000..78e6157 --- /dev/null +++ b/crates/js/lib/src/integrations/lockr/nextjs_guard.ts @@ -0,0 +1,202 @@ +import { log } from '../../core/log'; + +/** + * Lockr SDK Script Interception Guard + * + * This module intercepts any dynamically inserted script tag that loads the Lockr SDK + * and rewrites it to use the first-party domain proxy endpoint. This works across all + * frameworks (Next.js, Nuxt, Gatsby, vanilla JS, etc.) and catches scripts inserted via + * appendChild, insertBefore, or any other dynamic DOM manipulation. + * + * The guard patches DOM methods to catch these dynamic insertions and rewrite + * Lockr SDK URLs to use the first-party domain proxy endpoint, bypassing the need + * for server-side HTML rewriting in dynamic client-side scenarios. + */ + +let guardInstalled = false; + +/** + * Check if a URL is a Lockr SDK URL. + * Matches the logic from lockr.rs:79-86 + */ +function isLockrSdkUrl(url: string): boolean { + if (!url) return false; + + const lower = url.toLowerCase(); + + // Check for aim.loc.kr domain + if (lower.includes('aim.loc.kr')) { + return true; + } + + // Check for identity.loc.kr with identity-lockr and .js extension + if ( + lower.includes('identity.loc.kr') && + lower.includes('identity-lockr') && + lower.endsWith('.js') + ) { + return true; + } + + return false; +} + +/** + * Rewrite a Lockr SDK URL to use the first-party domain proxy endpoint. + */ +function rewriteToFirstParty(): string { + const protocol = window.location.protocol === 'https:' ? 'https' : 'http'; + const host = window.location.host; + return `${protocol}://${host}/integrations/lockr/sdk`; +} + +/** + * Check if an element should be rewritten. + * Returns true for: + * - ANY script element with a Lockr SDK URL (framework-agnostic) + * - Link elements with rel="preload" as="script" and Lockr SDK URL + */ +function shouldRewriteElement(element: Node): element is HTMLScriptElement | HTMLLinkElement { + if (!element || !(element instanceof HTMLElement)) { + return false; + } + + // Handle script elements - catch ANY script with Lockr SDK URL + if (element.tagName === 'SCRIPT') { + const scriptElement = element as HTMLScriptElement; + + // Check if src is a Lockr SDK URL (no framework-specific checks) + const src = scriptElement.src || scriptElement.getAttribute('src'); + if (!src) { + return false; + } + + return isLockrSdkUrl(src); + } + + // Handle link preload elements + if (element.tagName === 'LINK') { + const linkElement = element as HTMLLinkElement; + + // Check if it's a preload link for a script + const rel = linkElement.getAttribute('rel'); + const as = linkElement.getAttribute('as'); + if (rel !== 'preload' || as !== 'script') { + return false; + } + + // Check if href is a Lockr SDK URL + const href = linkElement.href || linkElement.getAttribute('href'); + if (!href) { + return false; + } + + return isLockrSdkUrl(href); + } + + return false; +} + +/** + * Rewrite an element's URL attribute to use first-party proxy. + * Handles both script src and link href attributes. + */ +function rewriteElement(element: HTMLScriptElement | HTMLLinkElement): void { + if (element.tagName === 'SCRIPT') { + const scriptElement = element as HTMLScriptElement; + const originalSrc = scriptElement.src || scriptElement.getAttribute('src'); + if (!originalSrc) return; + + const rewrittenSrc = rewriteToFirstParty(originalSrc); + + log.info('Lockr guard: rewriting dynamically inserted Lockr SDK script', { + original: originalSrc, + rewritten: rewrittenSrc, + framework: scriptElement.getAttribute('data-nscript') || 'generic', + }); + + // Update both property and attribute to ensure it works in all scenarios + scriptElement.src = rewrittenSrc; + scriptElement.setAttribute('src', rewrittenSrc); + } else if (element.tagName === 'LINK') { + const linkElement = element as HTMLLinkElement; + const originalHref = linkElement.href || linkElement.getAttribute('href'); + if (!originalHref) return; + + const rewrittenHref = rewriteToFirstParty(originalHref); + + log.info('Lockr guard: rewriting Lockr SDK preload link', { + original: originalHref, + rewritten: rewrittenHref, + rel: linkElement.getAttribute('rel'), + as: linkElement.getAttribute('as'), + }); + + // Update both property and attribute to ensure it works in all scenarios + linkElement.href = rewrittenHref; + linkElement.setAttribute('href', rewrittenHref); + } +} + +/** + * Install the Lockr guard to intercept dynamic script loading. + * This patches Element.prototype.appendChild and insertBefore to catch + * ANY dynamically inserted Lockr SDK script elements and rewrite their URLs before insertion. + * Works across all frameworks and vanilla JavaScript. + */ +export function installNextJsGuard(): void { + // Prevent double installation + if (guardInstalled) { + log.debug('Lockr guard: already installed, skipping'); + return; + } + + // Check if we're in a browser environment + if (typeof window === 'undefined' || typeof Element === 'undefined') { + log.debug('Lockr guard: not in browser environment, skipping'); + return; + } + + log.info('Lockr guard: installing DOM interception for Lockr SDK'); + + // Store original methods + const originalAppendChild = Element.prototype.appendChild; + const originalInsertBefore = Element.prototype.insertBefore; + + // Patch appendChild + Element.prototype.appendChild = function (this: Element, node: T): T { + if (shouldRewriteElement(node)) { + rewriteElement(node as HTMLScriptElement | HTMLLinkElement); + } + return originalAppendChild.call(this, node); + }; + + // Patch insertBefore + Element.prototype.insertBefore = function ( + this: Element, + node: T, + reference: Node | null + ): T { + if (shouldRewriteElement(node)) { + rewriteElement(node as HTMLScriptElement | HTMLLinkElement); + } + return originalInsertBefore.call(this, node, reference); + }; + + guardInstalled = true; + log.info('Lockr guard: DOM interception installed successfully'); +} + +/** + * Check if the guard is currently installed. + */ +export function isGuardInstalled(): boolean { + return guardInstalled; +} + +/** + * Reset the guard installation state (primarily for testing). + */ +export function resetGuardState(): void { + guardInstalled = false; +} diff --git a/crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts b/crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts new file mode 100644 index 0000000..e5ee45d --- /dev/null +++ b/crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts @@ -0,0 +1,558 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + installNextJsGuard, + isGuardInstalled, + resetGuardState, +} from '../../../src/integrations/lockr/nextjs_guard'; + +describe('Lockr SDK Script Interception Guard', () => { + let originalAppendChild: typeof Element.prototype.appendChild; + let originalInsertBefore: typeof Element.prototype.insertBefore; + + beforeEach(() => { + // Store original methods + originalAppendChild = Element.prototype.appendChild; + originalInsertBefore = Element.prototype.insertBefore; + + // Reset guard state before each test + resetGuardState(); + }); + + afterEach(() => { + // Restore original methods + Element.prototype.appendChild = originalAppendChild; + Element.prototype.insertBefore = originalInsertBefore; + + // Reset guard state after each test + resetGuardState(); + }); + + describe('installNextJsGuard', () => { + it('should install the guard successfully', () => { + expect(isGuardInstalled()).toBe(false); + + installNextJsGuard(); + + expect(isGuardInstalled()).toBe(true); + }); + + it('should not install twice', () => { + installNextJsGuard(); + const firstInstall = Element.prototype.appendChild; + + installNextJsGuard(); + const secondInstall = Element.prototype.appendChild; + + // Should be the same reference (no double patching) + expect(firstInstall).toBe(secondInstall); + }); + + it('should patch Element.prototype.appendChild', () => { + installNextJsGuard(); + + expect(Element.prototype.appendChild).not.toBe(originalAppendChild); + }); + + it('should patch Element.prototype.insertBefore', () => { + installNextJsGuard(); + + expect(Element.prototype.insertBefore).not.toBe(originalInsertBefore); + }); + }); + + describe('appendChild interception', () => { + it('should rewrite Lockr SDK URL from aim.loc.kr', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(script.src).not.toContain('aim.loc.kr'); + }); + + it('should rewrite Lockr SDK URL from identity.loc.kr', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://identity.loc.kr/identity-lockr-v2.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(script.src).not.toContain('identity.loc.kr'); + }); + + it('should use location.host for rewritten URL', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.src).toContain(window.location.host); + expect(script.src).toMatch(/^https?:\/\//); + }); + + it('should not rewrite non-Lockr scripts', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://example.com/some-script.js'; + + container.appendChild(script); + + expect(script.src).toBe('https://example.com/some-script.js'); + }); + + it('should rewrite Lockr scripts regardless of data-nscript attribute', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(script.src).not.toContain('aim.loc.kr'); + }); + + it('should rewrite Lockr scripts with ANY data-nscript value', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.setAttribute('data-nscript', 'beforeInteractive'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(script.src).not.toContain('aim.loc.kr'); + }); + + it('should rewrite plain scripts without any framework attributes', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + // No framework attributes at all + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(script.src).not.toContain('aim.loc.kr'); + }); + + it('should not affect non-script elements', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const img = document.createElement('img'); + img.src = 'https://aim.loc.kr/image.png'; + + container.appendChild(img); + + expect(img.src).toBe('https://aim.loc.kr/image.png'); + }); + + it('should handle scripts with setAttribute instead of property', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.setAttribute('src', 'https://aim.loc.kr/identity-lockr-v1.0.js'); + + container.appendChild(script); + + expect(script.getAttribute('src')).toContain('/integrations/lockr/sdk'); + }); + + it('should work with vanilla JavaScript script insertion', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.type = 'text/javascript'; + script.async = true; + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(script.type).toBe('text/javascript'); + expect(script.async).toBe(true); + }); + }); + + describe('insertBefore interception', () => { + it('should rewrite Lockr SDK URL', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const reference = document.createElement('div'); + container.appendChild(reference); + + const script = document.createElement('script'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.insertBefore(script, reference); + + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(script.src).not.toContain('aim.loc.kr'); + }); + + it('should not rewrite non-Lockr scripts', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const reference = document.createElement('div'); + container.appendChild(reference); + + const script = document.createElement('script'); + script.src = 'https://example.com/some-script.js'; + + container.insertBefore(script, reference); + + expect(script.src).toBe('https://example.com/some-script.js'); + }); + + it('should work with null reference node', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.insertBefore(script, null); + + expect(script.src).toContain('/integrations/lockr/sdk'); + }); + }); + + describe('URL detection', () => { + it('should detect aim.loc.kr URLs', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + }); + + it('should detect identity.loc.kr with identity-lockr URLs', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + + script.src = 'https://identity.loc.kr/identity-lockr-v2.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + }); + + it('should handle case-insensitive URLs', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + + script.src = 'https://AIM.LOC.KR/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + }); + + it('should not match identity.loc.kr without identity-lockr', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + + script.src = 'https://identity.loc.kr/other-script.js'; + + container.appendChild(script); + + expect(script.src).toBe('https://identity.loc.kr/other-script.js'); + }); + + it('should not match identity.loc.kr with identity-lockr but wrong extension', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + + script.src = 'https://identity.loc.kr/identity-lockr-v1.0.css'; + + container.appendChild(script); + + expect(script.src).toBe('https://identity.loc.kr/identity-lockr-v1.0.css'); + }); + }); + + describe('link preload interception', () => { + it('should rewrite Lockr SDK preload link from aim.loc.kr', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(link); + + expect(link.href).toContain('/integrations/lockr/sdk'); + expect(link.href).not.toContain('aim.loc.kr'); + }); + + it('should rewrite Lockr SDK preload link from identity.loc.kr', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.href = 'https://identity.loc.kr/identity-lockr-v2.0.js'; + + container.appendChild(link); + + expect(link.href).toContain('/integrations/lockr/sdk'); + expect(link.href).not.toContain('identity.loc.kr'); + }); + + it('should use location.host for rewritten preload URL', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(link); + + expect(link.href).toContain(window.location.host); + expect(link.href).toMatch(/^https?:\/\//); + }); + + it('should not rewrite preload links without as="script"', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'style'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(link); + + expect(link.href).toBe('https://aim.loc.kr/identity-lockr-v1.0.js'); + }); + + it('should not rewrite links without rel="preload"', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('as', 'script'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(link); + + expect(link.href).toBe('https://aim.loc.kr/identity-lockr-v1.0.js'); + }); + + it('should not rewrite non-Lockr preload links', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.href = 'https://example.com/other-script.js'; + + container.appendChild(link); + + expect(link.href).toBe('https://example.com/other-script.js'); + }); + + it('should work with insertBefore for preload links', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const reference = document.createElement('div'); + container.appendChild(reference); + + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.insertBefore(link, reference); + + expect(link.href).toContain('/integrations/lockr/sdk'); + }); + + it('should handle preload link with setAttribute instead of property', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.setAttribute('href', 'https://aim.loc.kr/identity-lockr-v1.0.js'); + + container.appendChild(link); + + expect(link.getAttribute('href')).toContain('/integrations/lockr/sdk'); + }); + + it('should preserve other link attributes', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.setAttribute('crossorigin', 'anonymous'); + link.setAttribute('id', 'lockr-preload'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(link); + + expect(link.getAttribute('rel')).toBe('preload'); + expect(link.getAttribute('as')).toBe('script'); + expect(link.getAttribute('crossorigin')).toBe('anonymous'); + expect(link.getAttribute('id')).toBe('lockr-preload'); + }); + }); + + describe('integration scenarios', () => { + it('should handle multiple script insertions', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + + const script1 = document.createElement('script'); + script1.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + const script2 = document.createElement('script'); + script2.src = 'https://example.com/other.js'; + + container.appendChild(script1); + container.appendChild(script2); + + expect(script1.src).toContain('/integrations/lockr/sdk'); + expect(script2.src).toBe('https://example.com/other.js'); + }); + + it('should preserve other script attributes', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.setAttribute('async', ''); + script.setAttribute('crossorigin', 'anonymous'); + script.setAttribute('id', 'lockr-sdk'); + script.setAttribute('data-framework', 'nextjs'); // Any custom attribute + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(script); + + expect(script.getAttribute('async')).toBe(''); + expect(script.getAttribute('crossorigin')).toBe('anonymous'); + expect(script.getAttribute('id')).toBe('lockr-sdk'); + expect(script.getAttribute('data-framework')).toBe('nextjs'); + }); + + it('should work with scripts created and inserted immediately', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + // Immediate insertion (common pattern) + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + }); + + it('should handle both script and preload link together', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + + // Add preload link first (typical framework behavior) + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + // Add script tag + const script = document.createElement('script'); + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + // Immediate insertion (common in Next.js) + container.appendChild(script); + + expect(script.src).toContain('/integrations/lockr/sdk'); + }); + + it('should handle both script and preload link together', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + + // Add preload link first (typical Next.js behavior) + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'script'); + link.href = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + // Add script tag + const script = document.createElement('script'); + + script.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + + container.appendChild(link); + container.appendChild(script); + + expect(link.href).toContain('/integrations/lockr/sdk'); + expect(script.src).toContain('/integrations/lockr/sdk'); + expect(link.href).toBe(script.src); // Should be the same URL + }); + + it('should not affect non-preload links', () => { + installNextJsGuard(); + + const container = document.createElement('div'); + const link = document.createElement('link'); + link.setAttribute('rel', 'stylesheet'); + link.href = 'https://aim.loc.kr/styles.css'; + + container.appendChild(link); + + expect(link.href).toBe('https://aim.loc.kr/styles.css'); + }); + }); +}); diff --git a/trusted-server.toml b/trusted-server.toml index 6da5e63..4e2f08d 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -58,6 +58,12 @@ workspace_id = "" project_id = "" api_endpoint = "https://api.permutive.com" secure_signals_endpoint = "https://secure-signals.permutive.app" + +[integrations.lockr] +enabled = false +app_id = "" +api_endpoint = "https://identity.loc.kr" +sdk_url = "https://aim.loc.kr/identity-lockr-v1.0.js" cache_ttl_seconds = 3600 rewrite_sdk = true