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
52 changes: 48 additions & 4 deletions crates/trusted-server-core/src/integrations/lockr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use validator::Validate;

use crate::constants::INTERNAL_HEADERS;
use crate::error::TrustedServerError;
use crate::integrations::sdk_url::parse_sdk_network_url;
use crate::integrations::{
AttributeRewriteAction, INTEGRATION_MAX_BODY_BYTES, IntegrationAttributeContext,
IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationProxy, IntegrationRegistration,
Expand Down Expand Up @@ -98,10 +99,15 @@ impl LockrIntegration {

/// 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")
// Keep browser/Rust cases in crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json aligned.
let Some(parsed) = parse_sdk_network_url(url) else {
return false;
};
let host = parsed.host_str().unwrap_or_default();
let path = parsed.path().to_ascii_lowercase();
matches!(host, "aim.loc.kr" | "identity.loc.kr")
&& path.contains("identity-lockr")
&& path.ends_with(".js")
}

/// Handle SDK serving — fetch from Lockr CDN and serve through first-party domain.
Expand Down Expand Up @@ -437,6 +443,9 @@ mod tests {
use edgezero_core::http::Method as HttpMethod;
use serde_json::json;

use crate::integrations::sdk_url_test_support::{
assert_sdk_html_attribute_rewrite, assert_sdk_url_rewrite, sdk_url_cases,
};
use crate::platform::test_support::{StubHttpClient, build_services_with_http_client};
use crate::test_support::tests::create_test_settings;

Expand All @@ -463,6 +472,31 @@ mod tests {
}
}

#[test]
fn sdk_url_ownership_matches_shared_corpus() {
let integration = LockrIntegration::new(test_config());

for case in sdk_url_cases() {
let matches = case.owner.as_deref() == Some(LOCKR_INTEGRATION_ID);
assert_eq!(
integration.is_lockr_sdk_url(&case.url),
matches,
"should match expected lockr ownership for {}: {}",
case.name,
case.url
);

assert_sdk_url_rewrite(integration.as_ref(), &case, "/integrations/lockr/sdk");
if matches {
assert_sdk_html_attribute_rewrite(
integration.as_ref(),
&case,
"/integrations/lockr/sdk",
);
}
}
}

#[test]
fn test_lockr_sdk_url_detection() {
let integration = LockrIntegration::new(test_config());
Expand All @@ -472,6 +506,16 @@ mod tests {
assert!(integration.is_lockr_sdk_url("https://aim.loc.kr/identity-lockr-trust-server.js"));
assert!(integration.is_lockr_sdk_url("https://identity.loc.kr/identity-lockr-v2.0.js"));

// Hosts that only end with a Lockr SDK host are different hosts.
assert!(
!integration.is_lockr_sdk_url("https://notaim.loc.kr/identity-lockr.js"), // allow-domain: notaim.loc.kr
"should require an exact aim.loc.kr hostname"
);
assert!(
!integration.is_lockr_sdk_url("https://notidentity.loc.kr/identity-lockr.js"), // allow-domain: notidentity.loc.kr
"should require an exact identity.loc.kr hostname"
);

// Should not match non-SDK resources on Lockr domains
assert!(
!integration.is_lockr_sdk_url("https://aim.loc.kr/pixel.gif"),
Expand Down
3 changes: 3 additions & 0 deletions crates/trusted-server-core/src/integrations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ pub mod osano;
pub mod permutive;
pub mod prebid;
mod registry;
mod sdk_url;
#[cfg(test)]
mod sdk_url_test_support;
pub mod sourcepoint;
pub mod testlight;

Expand Down
52 changes: 49 additions & 3 deletions crates/trusted-server-core/src/integrations/permutive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use validator::Validate;

use crate::constants::INTERNAL_HEADERS;
use crate::error::TrustedServerError;
use crate::integrations::sdk_url::parse_sdk_network_url;
use crate::integrations::{
AttributeRewriteAction, INTEGRATION_MAX_BODY_BYTES, IntegrationAttributeContext,
IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationProxy, IntegrationRegistration,
Expand Down Expand Up @@ -99,9 +100,17 @@ impl PermutiveIntegration {

/// Check if a URL is a Permutive SDK URL.
fn is_permutive_sdk_url(&self, url: &str) -> bool {
let lower = url.to_ascii_lowercase();
(lower.contains(".edge.permutive.app") || lower.contains("cdn.permutive.com"))
&& lower.ends_with("-web.js")
// Keep browser/Rust cases in crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json aligned.
let Some(parsed) = parse_sdk_network_url(url) else {
return false;
};
let host = parsed.host_str().unwrap_or_default();
let path = parsed.path().to_ascii_lowercase();
(host == "cdn.permutive.com"
|| host
.strip_suffix(".edge.permutive.app")
.is_some_and(|subdomain| !subdomain.is_empty()))
&& path.ends_with("-web.js")
}

/// Handle SDK serving - fetch from Permutive CDN and serve through first-party domain.
Expand Down Expand Up @@ -474,9 +483,46 @@ mod tests {
use std::sync::Arc;

use super::*;
use crate::integrations::sdk_url_test_support::{
assert_sdk_html_attribute_rewrite, assert_sdk_url_rewrite, sdk_url_cases,
};
use crate::platform::test_support::{StubHttpClient, build_services_with_http_client};
use crate::test_support::tests::create_test_settings;

#[test]
fn sdk_url_ownership_matches_shared_corpus() {
let integration = PermutiveIntegration::new(PermutiveConfig {
enabled: true,
organization_id: "example".to_string(),
workspace_id: "workspace".to_string(),
project_id: String::new(),
api_endpoint: default_api_endpoint(),
secure_signals_endpoint: default_secure_signals_endpoint(),
cache_ttl_seconds: 3600,
rewrite_sdk: true,
});

for case in sdk_url_cases() {
let matches = case.owner.as_deref() == Some(PERMUTIVE_INTEGRATION_ID);
assert_eq!(
integration.is_permutive_sdk_url(&case.url),
matches,
"should match expected permutive ownership for {}: {}",
case.name,
case.url
);

assert_sdk_url_rewrite(integration.as_ref(), &case, "/integrations/permutive/sdk");
if matches {
assert_sdk_html_attribute_rewrite(
integration.as_ref(),
&case,
"/integrations/permutive/sdk",
);
}
}
}

#[test]
fn test_permutive_sdk_url_generation() {
let config = PermutiveConfig {
Expand Down
31 changes: 31 additions & 0 deletions crates/trusted-server-core/src/integrations/sdk_url.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//! Shared input policy for SDK URL ownership checks.

use url::Url;

/// Parse an HTTP(S) SDK URL with an explicit authority, or a protocol-relative URL.
///
/// Applies browser C0/space trimming, removes tab/CR/LF, and accepts backslash
/// authority separators. Literal NULs, relative paths, and base-dependent shorthand
/// stay unowned; HTML replaces NULs rather than treating them as URL whitespace.
/// Browser/Rust ownership cases live in crates/trusted-server-js/lib/test/fixtures/sdk-url-ownership.json.
pub(super) fn parse_sdk_network_url(url: &str) -> Option<Url> {
if url.contains('\0') {
return None;
}
let value = url
.trim_matches(|character| character <= '\u{20}')
.replace(['\t', '\n', '\r'], "");
let has_authority = |value: &str| matches!(value.as_bytes(), [b'/' | b'\\', b'/' | b'\\', ..]);
let parsed = if has_authority(&value) {
Url::parse(&format!("https:{value}"))
} else {
let (scheme, remainder) = value.split_once(':')?;
if !(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
|| !has_authority(remainder)
{
return None;
}
Url::parse(&value)
};
parsed.ok()
}
119 changes: 119 additions & 0 deletions crates/trusted-server-core/src/integrations/sdk_url_test_support.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//! Shared SDK URL corpus and attribute-rewrite assertions.

use lol_html::{RewriteStrSettings, element, rewrite_str};
use serde::Deserialize;

use super::{AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter};

/// One raw URL and its expected integration owner from the shared corpus.
#[derive(Deserialize)]
pub(super) struct SdkUrlCase {
/// Descriptive case name for assertion failures.
pub(super) name: String,
/// Input before browser DOM URL resolution.
pub(super) url: String,
/// Expected integration ID, or none for an unowned URL.
pub(super) owner: Option<String>,
}

/// Load the URL ownership cases used by both Rust and browser tests.
///
/// # Panics
///
/// Panics if the committed fixture is invalid.
pub(super) fn sdk_url_cases() -> Vec<SdkUrlCase> {
serde_json::from_str(include_str!(
"../../../trusted-server-js/lib/test/fixtures/sdk-url-ownership.json"
))
.expect("should parse shared SDK URL ownership corpus")
}

/// Assert SDK rewriting for both script and link attribute contexts.
///
/// # Panics
///
/// Panics if the rewrite result disagrees with the corpus owner and SDK route.
pub(super) fn assert_sdk_url_rewrite(
integration: &dyn IntegrationAttributeRewriter,
case: &SdkUrlCase,
sdk_path: &str,
) {
let expected = if case.owner.as_deref() == Some(integration.integration_id()) {
AttributeRewriteAction::Replace(sdk_path.to_string())
} else {
AttributeRewriteAction::Keep
};
for (attribute, element) in [("src", "script"), ("href", "link")] {
Comment thread
ChristianPavilonis marked this conversation as resolved.
assert!(
integration.handles_attribute(attribute),
"should route {attribute} to the {} rewriter",
integration.integration_id()
);
let context = IntegrationAttributeContext {
attribute_name: attribute,
element_name: element,
request_host: "edge.example.com",
request_scheme: "https",
origin_host: "origin.example.com",
};
assert_eq!(
integration.rewrite(attribute, &case.url, &context),
expected,
"should apply expected {} {attribute} rewrite for {}: {}",
integration.integration_id(),
case.name,
case.url
);
}
}

/// Assert that a matching SDK URL is rewritten after HTML attribute tokenization.
///
/// # Panics
///
/// Panics if HTML processing fails or the SDK attribute is not replaced.
pub(super) fn assert_sdk_html_attribute_rewrite(
integration: &dyn IntegrationAttributeRewriter,
case: &SdkUrlCase,
sdk_path: &str,
) {
for (attribute, tag) in [("src", "script"), ("href", "link")] {
let html = format!(
"<{tag} {attribute}=\"{}\"></{tag}>",
case.url.replace('"', "&quot;")
);
let context = IntegrationAttributeContext {
attribute_name: attribute,
element_name: tag,
request_host: "edge.example.com",
request_scheme: "https",
origin_host: "origin.example.com",
};
let output = rewrite_str(
&html,
RewriteStrSettings {
element_content_handlers: vec![element!(tag, |element| {
let value = element
.get_attribute(attribute)
.expect("should read the SDK URL attribute");
assert_eq!(
integration.rewrite(attribute, &value, &context),
AttributeRewriteAction::Replace(sdk_path.to_string()),
"should rewrite HTML {attribute} for {}",
case.name
);
element.set_attribute(attribute, sdk_path)?;
Ok(())
})],
..RewriteStrSettings::default()
},
)
.expect("should process SDK URL markup");
assert_eq!(
output,
format!("<{tag} {attribute}=\"{sdk_path}\"></{tag}>"),
"should replace the SDK URL in HTML for {}",
case.name
);
}
}
31 changes: 11 additions & 20 deletions crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createScriptGuard } from '../../shared/script_guard';
import { parseSdkNetworkUrl } from '../../shared/sdk_url';

/**
* Lockr SDK Script Interception Guard
Expand All @@ -17,28 +18,18 @@ import { createScriptGuard } from '../../shared/script_guard';

/**
* Check if a URL is a Lockr SDK URL.
* Matches the logic from lockr.rs:79-86
* Browser/Rust ownership cases live in test/fixtures/sdk-url-ownership.json.
*/
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;
const parsed = parseSdkNetworkUrl(url);
if (!parsed) return false;

const path = parsed.pathname.toLowerCase();
return (
(parsed.hostname === 'aim.loc.kr' || parsed.hostname === 'identity.loc.kr') &&
path.includes('identity-lockr') &&
path.endsWith('.js')
);
}

const guard = createScriptGuard({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { createScriptGuard } from '../../shared/script_guard';
import { parseSdkNetworkUrl } from '../../shared/sdk_url';

/**
* Check if a URL is a Permutive SDK URL.
* Matches the logic from permutive.rs:97-101
* Browser/Rust ownership cases live in test/fixtures/sdk-url-ownership.json.
*/
function isPermutiveSdkUrl(url: string): boolean {
if (!url) return false;
const parsed = parseSdkNetworkUrl(url);
if (!parsed) return false;

const lower = url.toLowerCase();
const host = parsed.hostname;
const edgeSuffix = '.edge.permutive.app';
return (
(lower.includes('.edge.permutive.app') || lower.includes('cdn.permutive.com')) &&
lower.endsWith('-web.js')
(host === 'cdn.permutive.com' ||
(host.endsWith(edgeSuffix) && host.length > edgeSuffix.length)) &&
parsed.pathname.toLowerCase().endsWith('-web.js')
);
}

Expand Down
Loading
Loading