diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs
index ee67bdc1..c00b0879 100644
--- a/crates/trusted-server-core/src/html_processor.rs
+++ b/crates/trusted-server-core/src/html_processor.rs
@@ -258,6 +258,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
move |el| {
if let Some(mut href) = el.get_attribute("href") {
let original_href = href.clone();
+ let element_name = el.tag_name();
if let Some(rewritten) = patterns.rewrite_url_value(&href) {
href = rewritten;
}
@@ -267,6 +268,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
&href,
&IntegrationAttributeContext {
attribute_name: "href",
+ element_name: &element_name,
request_host: &patterns.request_host,
request_scheme: &patterns.request_scheme,
origin_host: &patterns.origin_host,
@@ -296,6 +298,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
move |el| {
if let Some(mut src) = el.get_attribute("src") {
let original_src = src.clone();
+ let element_name = el.tag_name();
if let Some(rewritten) = patterns.rewrite_url_value(&src) {
src = rewritten;
}
@@ -304,6 +307,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
&src,
&IntegrationAttributeContext {
attribute_name: "src",
+ element_name: &element_name,
request_host: &patterns.request_host,
request_scheme: &patterns.request_scheme,
origin_host: &patterns.origin_host,
@@ -333,6 +337,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
move |el| {
if let Some(mut action) = el.get_attribute("action") {
let original_action = action.clone();
+ let element_name = el.tag_name();
if let Some(rewritten) = patterns.rewrite_url_value(&action) {
action = rewritten;
}
@@ -342,6 +347,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
&action,
&IntegrationAttributeContext {
attribute_name: "action",
+ element_name: &element_name,
request_host: &patterns.request_host,
request_scheme: &patterns.request_scheme,
origin_host: &patterns.origin_host,
@@ -371,6 +377,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
move |el| {
if let Some(mut srcset) = el.get_attribute("srcset") {
let original_srcset = srcset.clone();
+ let element_name = el.tag_name();
let new_srcset = srcset
.replace(&patterns.https_origin(), &patterns.replacement_url())
.replace(&patterns.http_origin(), &patterns.replacement_url())
@@ -388,6 +395,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
&srcset,
&IntegrationAttributeContext {
attribute_name: "srcset",
+ element_name: &element_name,
request_host: &patterns.request_host,
request_scheme: &patterns.request_scheme,
origin_host: &patterns.origin_host,
@@ -417,6 +425,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
move |el| {
if let Some(mut imagesrcset) = el.get_attribute("imagesrcset") {
let original_imagesrcset = imagesrcset.clone();
+ let element_name = el.tag_name();
let new_imagesrcset = imagesrcset
.replace(&patterns.https_origin(), &patterns.replacement_url())
.replace(&patterns.http_origin(), &patterns.replacement_url())
@@ -433,6 +442,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso
&imagesrcset,
&IntegrationAttributeContext {
attribute_name: "imagesrcset",
+ element_name: &element_name,
request_host: &patterns.request_host,
request_scheme: &patterns.request_scheme,
origin_host: &patterns.origin_host,
diff --git a/crates/trusted-server-core/src/integrations/datadome.rs b/crates/trusted-server-core/src/integrations/datadome.rs
index da3a64a2..a91d6181 100644
--- a/crates/trusted-server-core/src/integrations/datadome.rs
+++ b/crates/trusted-server-core/src/integrations/datadome.rs
@@ -737,6 +737,7 @@ mod tests {
let ctx = IntegrationAttributeContext {
attribute_name: "src",
+ element_name: "script",
request_host: "publisher.com",
request_scheme: "https",
origin_host: "origin.publisher.com",
@@ -777,6 +778,7 @@ mod tests {
let ctx = IntegrationAttributeContext {
attribute_name: "src",
+ element_name: "script",
request_host: "publisher.com",
request_scheme: "https",
origin_host: "origin.publisher.com",
diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs
index 64f27415..e58cca26 100644
--- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs
+++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs
@@ -707,6 +707,7 @@ mod tests {
let ctx = IntegrationAttributeContext {
attribute_name: "src",
+ element_name: "script",
request_host: "example.com",
request_scheme: "https",
origin_host: "origin.example.com",
@@ -823,6 +824,7 @@ mod tests {
let ctx = IntegrationAttributeContext {
attribute_name: "href",
+ element_name: "a",
request_host: "example.com",
request_scheme: "https",
origin_host: "origin.example.com",
diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs
index 0affbe95..b6b8f3f0 100644
--- a/crates/trusted-server-core/src/integrations/gpt.rs
+++ b/crates/trusted-server-core/src/integrations/gpt.rs
@@ -480,6 +480,7 @@ mod tests {
fn test_context() -> IntegrationAttributeContext<'static> {
IntegrationAttributeContext {
attribute_name: "src",
+ element_name: "script",
request_host: "edge.example.com",
request_scheme: "https",
origin_host: "origin.example.com",
diff --git a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs
new file mode 100644
index 00000000..263c61f6
--- /dev/null
+++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs
@@ -0,0 +1,1053 @@
+//! JavaScript asset proxy integration.
+//!
+//! This integration serves explicitly configured third-party JavaScript assets
+//! from first-party paths. Each asset maps one exact publisher-facing path to
+//! one exact HTTPS upstream URL and can independently enable proxying, disable
+//! proxying, or block matching script tags from publisher HTML.
+
+use std::collections::HashSet;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use error_stack::Report;
+use fastly::http::{header, Method, StatusCode};
+use fastly::{Request, Response};
+use serde::{Deserialize, Serialize};
+use url::Url;
+use validator::{Validate, ValidationError, ValidationErrors};
+
+use crate::constants::{
+ HEADER_ACCEPT, HEADER_ACCEPT_ENCODING, HEADER_ACCEPT_LANGUAGE, HEADER_USER_AGENT,
+};
+use crate::error::TrustedServerError;
+use crate::integrations::{
+ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter,
+ IntegrationEndpoint, IntegrationProxy, IntegrationRegistration,
+};
+use crate::proxy::{proxy_request, ProxyRequestConfig};
+use crate::settings::{IntegrationConfig, Settings};
+
+const JS_ASSET_PROXY_INTEGRATION_ID: &str = "js_asset_proxy";
+const HEADER_X_TS_JS_ASSET_PROXY: &str = "X-TS-JS-Asset-Proxy";
+const HEADER_X_TS_ERROR: &str = "X-TS-Error";
+const ERROR_ORIGIN_UNREACHABLE: &str = "js-asset-origin-unreachable";
+const ERROR_ORIGIN_STATUS: &str = "js-asset-origin-status";
+
+/// Configuration for the JavaScript asset proxy integration.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct JsAssetProxyConfig {
+ /// Enables or disables the integration.
+ #[serde(default)]
+ pub enabled: bool,
+ /// Optional downstream cache TTL override for every asset.
+ #[serde(default)]
+ pub cache_ttl_seconds: Option,
+ /// JavaScript assets managed by this integration.
+ #[serde(default)]
+ pub assets: Vec,
+}
+
+/// One configured JavaScript asset mapping.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct JsAssetProxyAsset {
+ /// Exact first-party request path handled by Trusted Server.
+ pub path: String,
+ /// Exact upstream JavaScript URL to fetch and to match during HTML rewriting.
+ pub origin_url: String,
+ /// Per-asset proxy behavior.
+ #[serde(default)]
+ pub proxy: JsAssetProxyMode,
+ /// Optional downstream cache TTL override for this asset.
+ #[serde(default)]
+ pub cache_ttl_seconds: Option,
+}
+
+/// Per-asset proxy behavior.
+#[derive(Debug, Clone, Copy, Default, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum JsAssetProxyMode {
+ /// Rewrite matching script URLs and serve the configured route.
+ #[default]
+ Enabled,
+ /// Keep the asset in configuration without rewriting or route registration.
+ Disabled,
+ /// Remove matching script elements without route registration.
+ Blocked,
+}
+
+impl IntegrationConfig for JsAssetProxyConfig {
+ fn is_enabled(&self) -> bool {
+ self.enabled
+ }
+}
+
+impl Validate for JsAssetProxyConfig {
+ fn validate(&self) -> Result<(), ValidationErrors> {
+ let mut errors = ValidationErrors::new();
+ errors.merge_self("assets", self.assets.validate());
+
+ if self.enabled && self.assets.is_empty() {
+ errors.add("assets", ValidationError::new("empty_assets"));
+ }
+
+ let mut paths = HashSet::new();
+ let mut origin_urls = HashSet::new();
+ for asset in &self.assets {
+ if !paths.insert(asset.path.as_str()) {
+ errors.add("asset_path", ValidationError::new("duplicate_asset_path"));
+ }
+ if !origin_urls.insert(asset.origin_url.as_str()) {
+ errors.add(
+ "asset_origin_url",
+ ValidationError::new("duplicate_asset_origin_url"),
+ );
+ }
+ }
+
+ if errors.is_empty() {
+ Ok(())
+ } else {
+ Err(errors)
+ }
+ }
+}
+
+impl Validate for JsAssetProxyAsset {
+ fn validate(&self) -> Result<(), ValidationErrors> {
+ let mut errors = ValidationErrors::new();
+
+ if !self.path.starts_with('/') {
+ errors.add("path", ValidationError::new("path_must_start_with_slash"));
+ }
+ if self.path.starts_with("//") {
+ errors.add(
+ "path",
+ ValidationError::new("path_must_not_be_protocol_relative"),
+ );
+ }
+ if self.path.contains('*') {
+ errors.add(
+ "path",
+ ValidationError::new("path_must_not_contain_wildcard"),
+ );
+ }
+ if path_contains_parent_segment(&self.path) {
+ errors.add(
+ "path",
+ ValidationError::new("path_must_not_contain_parent_segment"),
+ );
+ }
+ if self.path.contains(['{', '}']) {
+ errors.add("path", ValidationError::new("path_must_be_exact_route"));
+ }
+ if self.path.contains(['?', '#']) {
+ errors.add(
+ "path",
+ ValidationError::new("path_must_not_contain_query_or_fragment"),
+ );
+ }
+ if self
+ .path
+ .chars()
+ .any(|ch| ch.is_whitespace() || ch.is_control())
+ {
+ errors.add(
+ "path",
+ ValidationError::new("path_must_not_contain_whitespace_or_control"),
+ );
+ }
+
+ match Url::parse(&self.origin_url) {
+ Ok(url) => {
+ if url.scheme() != "https" {
+ errors.add(
+ "origin_url",
+ ValidationError::new("origin_url_must_be_https"),
+ );
+ }
+ if url.host_str().is_none() {
+ errors.add(
+ "origin_url",
+ ValidationError::new("origin_url_must_have_host"),
+ );
+ }
+ }
+ Err(_) => {
+ errors.add("origin_url", ValidationError::new("invalid_origin_url"));
+ }
+ }
+
+ if errors.is_empty() {
+ Ok(())
+ } else {
+ Err(errors)
+ }
+ }
+}
+
+fn path_contains_parent_segment(path: &str) -> bool {
+ path.split('/').any(|segment| segment == "..")
+}
+
+/// JavaScript asset proxy integration implementation.
+pub struct JsAssetProxyIntegration {
+ config: JsAssetProxyConfig,
+}
+
+impl JsAssetProxyIntegration {
+ fn new(config: JsAssetProxyConfig) -> Arc {
+ Arc::new(Self { config })
+ }
+
+ fn error(message: impl Into) -> TrustedServerError {
+ TrustedServerError::Integration {
+ integration: JS_ASSET_PROXY_INTEGRATION_ID.to_string(),
+ message: message.into(),
+ }
+ }
+
+ fn enabled_asset_for_path(&self, path: &str) -> Option<&JsAssetProxyAsset> {
+ self.config
+ .assets
+ .iter()
+ .find(|asset| asset.proxy == JsAssetProxyMode::Enabled && asset.path == path)
+ }
+
+ fn asset_for_origin_url(&self, origin_url: &str) -> Option<&JsAssetProxyAsset> {
+ self.config
+ .assets
+ .iter()
+ .find(|asset| asset.origin_url == origin_url)
+ }
+
+ fn build_proxy_config<'a>(origin_url: &'a str, req: &Request) -> ProxyRequestConfig<'a> {
+ let mut config = ProxyRequestConfig::new(origin_url)
+ .with_streaming()
+ .without_forward_headers();
+ config.follow_redirects = false;
+ config.forward_ec_id = false;
+
+ for header_name in [
+ &HEADER_ACCEPT,
+ &HEADER_ACCEPT_LANGUAGE,
+ &HEADER_ACCEPT_ENCODING,
+ ] {
+ if let Some(value) = req.get_header(header_name).cloned() {
+ config = config.with_header(header_name.clone(), value);
+ }
+ }
+
+ config.with_header(
+ HEADER_USER_AGENT.clone(),
+ fastly::http::HeaderValue::from_static("TrustedServer/1.0"),
+ )
+ }
+
+ fn origin_host(origin_url: &str) -> String {
+ Url::parse(origin_url)
+ .ok()
+ .and_then(|url| url.host_str().map(str::to_string))
+ .unwrap_or_else(|| "unknown".to_string())
+ }
+
+ fn origin_unreachable_response() -> Response {
+ let mut response = Response::from_status(StatusCode::BAD_GATEWAY);
+ response.set_header(HEADER_X_TS_ERROR, ERROR_ORIGIN_UNREACHABLE);
+ response
+ }
+
+ fn origin_status_response() -> Response {
+ let mut response = Response::from_status(StatusCode::BAD_GATEWAY);
+ response.set_header(HEADER_X_TS_ERROR, ERROR_ORIGIN_STATUS);
+ response
+ }
+
+ fn vary_with_accept_encoding(upstream_vary: Option<&str>) -> String {
+ match upstream_vary.map(str::trim) {
+ Some("*") => "*".to_string(),
+ Some(vary) if !vary.is_empty() => {
+ if vary
+ .split(',')
+ .any(|header_name| header_name.trim().eq_ignore_ascii_case("accept-encoding"))
+ {
+ vary.to_string()
+ } else {
+ format!("{vary}, Accept-Encoding")
+ }
+ }
+ _ => "Accept-Encoding".to_string(),
+ }
+ }
+
+ fn resolved_cache_ttl_seconds(&self, asset: &JsAssetProxyAsset) -> Option {
+ asset.cache_ttl_seconds.or(self.config.cache_ttl_seconds)
+ }
+
+ fn finalize_asset_response(
+ &self,
+ asset: &JsAssetProxyAsset,
+ mut response: Response,
+ ) -> Response {
+ let status = response.get_status();
+ let content_type = response.get_header(header::CONTENT_TYPE).cloned();
+ let content_encoding = response.get_header(header::CONTENT_ENCODING).cloned();
+ let etag = response.get_header(header::ETAG).cloned();
+ let last_modified = response.get_header(header::LAST_MODIFIED).cloned();
+ let upstream_vary = response
+ .get_header(header::VARY)
+ .and_then(|value| value.to_str().ok())
+ .map(str::to_owned);
+ let upstream_cache_control = response.get_header(header::CACHE_CONTROL).cloned();
+ let body = response.take_body();
+
+ let mut finalized = Response::from_status(status).with_body(body);
+ finalized.set_header(HEADER_X_TS_JS_ASSET_PROXY, "true");
+
+ if let Some(content_type) = content_type {
+ finalized.set_header(header::CONTENT_TYPE, content_type);
+ }
+ if let Some(content_encoding) = content_encoding {
+ finalized.set_header(header::CONTENT_ENCODING, content_encoding);
+ finalized.set_header(
+ header::VARY,
+ Self::vary_with_accept_encoding(upstream_vary.as_deref()),
+ );
+ } else if let Some(upstream_vary) = upstream_vary {
+ finalized.set_header(header::VARY, upstream_vary);
+ }
+ if let Some(etag) = etag {
+ finalized.set_header(header::ETAG, etag);
+ }
+ if let Some(last_modified) = last_modified {
+ finalized.set_header(header::LAST_MODIFIED, last_modified);
+ }
+
+ if let Some(ttl) = self.resolved_cache_ttl_seconds(asset) {
+ finalized.set_header(header::CACHE_CONTROL, format!("public, max-age={ttl}"));
+ } else if let Some(cache_control) = upstream_cache_control {
+ finalized.set_header(header::CACHE_CONTROL, cache_control);
+ }
+
+ finalized
+ }
+}
+
+fn build(
+ settings: &Settings,
+) -> Result