Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4e64fbf
Compose streamed Next.js and GTM script rewrites
ChristianPavilonis Sep 30, 2026
770f3d6
Share compressed HTML processing in Next.js tests
ChristianPavilonis Sep 30, 2026
8691d40
Share GTM routing policy across script rewrites
ChristianPavilonis Sep 30, 2026
fe77672
Ignore non-executable Flight spellings in script rewrites
ChristianPavilonis Sep 30, 2026
d738d93
Distinguish JavaScript blocks from expression-valued braces
ChristianPavilonis Oct 1, 2026
3f7c3a8
Validate request hosts before source rewriting
ChristianPavilonis Oct 1, 2026
f2a6a2a
Address script composition review cleanups
ChristianPavilonis Oct 1, 2026
e81e5d9
Merge branch 'main' into fix/1206-1208-script-composition
prk-Jr Oct 2, 2026
4d73741
Fix remaining streamed Flight composition regressions
ChristianPavilonis Oct 2, 2026
b3538b4
Merge branch 'main' into fix/1206-1208-script-composition
prk-Jr Oct 5, 2026
af69781
Fail closed on uncaptured Flight source
ChristianPavilonis Oct 5, 2026
514a617
Accept underscore request hosts safely
ChristianPavilonis Oct 5, 2026
4a1a569
Fix streamed Flight fallback and GTM routing, support serialized form…
ChristianPavilonis Oct 14, 2026
2bd103d
Merge main into fix/1206-1208-script-composition
ChristianPavilonis Oct 16, 2026
9e0dea1
Merge branch 'main' into fix/1206-1208-script-composition
aram356 Oct 17, 2026
fe456ee
Prepared scoped, uncommitted changes for findings 0–6: foreign-refere…
ChristianPavilonis Oct 19, 2026
a504fed
Merge main into fix/1206-1208-script-composition
ChristianPavilonis Oct 22, 2026
380909d
Deduplicate Flight fallback warnings, enable WASM regression coverage…
ChristianPavilonis Oct 23, 2026
bb5b551
Remove unused script text accumulator
ChristianPavilonis Oct 23, 2026
750d009
Merge branch 'main' into fix/1206-1208-script-composition
aram356 Oct 25, 2026
d61f339
Merge main into fix/1206-1208-script-composition
ChristianPavilonis Oct 26, 2026
db3d6c0
Document the nested-SVG script selector-state limitation without chan…
ChristianPavilonis Oct 26, 2026
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

- Publisher and page-bids processing now reject malformed effective request hosts with 400 before inserting request-derived bytes into rewrite output. `/first-party/sign` also returns 400 for a malformed effective host when the request URI has no scheme and request metadata is needed to determine it. Host selection remains `Forwarded` (`host=`), then `X-Forwarded-Host`, then `Host`; a malformed or explicitly empty selected host is rejected without fallback. Valid local hostnames, underscore labels, ports, and bracketed IPv6 remain supported. Forwarded-header trust policy is unchanged (#1206, #1207, #1208).
Comment thread
ChristianPavilonis marked this conversation as resolved.
- `/first-party/sign` now rejects valid targets outside `proxy.allowed_domains` before minting a proxy token. The creative runtime keeps image and iframe assignments blocked after this `403` policy response instead of loading the rejected URL directly; fetch-time checks still cover the initial target and every redirect.
- Reserved the complete admin namespace at the publisher-fallback boundary. Percent-encoded separators (`/_ts/admin%2Fec`, `%2f`, and double-encoded forms) matched the `^/_ts/admin` Basic-auth handler but escaped the literal-slash namespace check, so an authenticated request fell through to publisher fallback and forwarded its `Authorization` header and body to the publisher origin. The reservation now spans the whole `/_ts/admin` prefix plus the retired `/admin/keys` aliases — including trailing, descendant, and encoded-separator forms — evaluated on the raw path and on each of its bounded percent-decodings, so multi-encoded separators such as `/admin%252Fkeys/rotate` cannot survive to fallback for a proxy or origin to decode again, and applies to every adapter.
- Validate synthetic ID format on inbound values from the `x-synthetic-id` header and `synthetic_id` cookie; values that do not match the expected format (`64-hex-hmac.6-alphanumeric-suffix`) are discarded and a fresh ID is generated rather than forwarded to response headers, cookies, or third-party APIs

### Fixed

- Next.js and Google Tag Manager script rewrites now compose in streamed HTML instead of overwriting each other, preserving Pages Router JSON and App Router Flight payloads with recomputed decoded `T`-record lengths. GTM/GA Flight rewrites require both integrations to be enabled. Buffering is bounded and per document; an uncaptured data head (including inert text quoting a Flight push), an unsupported or binary push, or a size limit keeps the remaining Flight stream raw and restores queued originals, but cannot undo already-streamed rewrites. Complete serialized form state does not disable later rewriting, foreign property references no longer hide a later qualified Flight push, and placeholder-stage document fallback emits a reason-bearing warning without source data (#1206, #1207, #1208).
Comment thread
ChristianPavilonis marked this conversation as resolved.
- Publisher HTML is now processed when the request carries its host only in the URI (absolute-form HTTP/1.1, or HTTP/2 without a usable `Host`), instead of being returned unmodified with an "Empty request host" warning (#1118). URI userinfo is excluded from rewritten URLs, while ports and IPv6 brackets are preserved.
- Publisher responses now map origin URLs in `Location`, `Content-Location`, `Refresh`, and `Link` headers to the serving host, and each CSP source naming the origin gains a serving-host source beside it (#1128). A redirect to the current URL under a different scheme, or a `GET`/`HEAD` redirect to the current URL that sets no cookie (or any such redirect under a `Host` override), is left unchanged to avoid a loop. `<meta>` refresh and CSP tags in the page body are not rewritten. See "Origin URLs in proxied response headers" in the configuration guide.
- Image Optimizer configuration now rejects profile and profile-set names within the same map that collide after trimming, even when their values match, instead of silently overwriting an entry. Empty names continue to be discarded.
Expand Down
161 changes: 157 additions & 4 deletions crates/trusted-server-adapter-axum/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
use axum::body::Body as AxumBody;
use axum::http::Request;
use edgezero_adapter_axum::service::EdgeZeroAxumService;
use std::sync::Arc;
Comment thread
ChristianPavilonis marked this conversation as resolved.
use tower::{Service as _, ServiceExt as _};
use trusted_server_adapter_axum::app::TrustedServerApp;
use trusted_server_core::test_support::nextjs_auction;

const LEGACY_ADMIN_DENY_METHODS: &[&str] =
&["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"];
Expand Down Expand Up @@ -878,10 +880,6 @@ async fn first_party_proxy_rebuild_is_routed() {
/// Fastly adapter alone and not by this test.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn nextjs_auction_output_holds_until_the_structural_body_close() {
use std::sync::Arc;

use trusted_server_core::test_support::nextjs_auction;

let client = Arc::new(nextjs_auction::NextJsAuctionOrigin::default());
let router = TrustedServerApp::routes_with_settings_and_services(
nextjs_auction::settings(),
Expand Down Expand Up @@ -939,6 +937,161 @@ async fn nextjs_auction_output_holds_until_the_structural_body_close() {
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn nextjs_gtm_rejects_malformed_request_hosts() {
for header in ["host", "x-forwarded-host", "forwarded"] {
let origin = Arc::new(nextjs_auction::NextJsAuctionOrigin::with_html_response(
&nextjs_auction::script_composition_html(),
false,
true,
));
let router = TrustedServerApp::routes_with_settings_and_services(
nextjs_auction::script_composition_settings(),
nextjs_auction::services(origin),
)
.expect("should build script composition router");
let mut service = EdgeZeroAxumService::new(router);
let hostile_host = "example.com</script><script>alert(1)</script>";
let value = if header == "forwarded" {
format!("host=\"{hostile_host}\"")
} else {
hostile_host.to_owned()
};
let mut request = Request::builder()
.uri("/script-composition")
.header("host", nextjs_auction::PUBLISHER_HOST)
.body(AxumBody::empty())
.expect("should build hostile host request");
request.headers_mut().insert(
header,
value.parse().expect("should encode hostile host header"),
);
let response = service
.ready()
.await
.expect("should be ready")
.call(request)
.await
.expect("should respond to hostile host request");
assert_eq!(
response.status().as_u16(),
400,
"should reject malformed {header} rather than rewrite script source"
);
let body = axum::body::to_bytes(response.into_body(), 1024)
.await
.expect("should consume host rejection response");
assert!(
!String::from_utf8_lossy(&body).contains(hostile_host),
"should not reflect the rejected host in the response"
);
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn nextjs_gtm_accepts_local_request_hosts_and_ports() {
for host in [
"localhost:3000",
"example.com:8443",
"[::1]:3000",
"my_service:8080",
"_service_.example.com:8443",
] {
let origin = Arc::new(nextjs_auction::NextJsAuctionOrigin::with_html_response(
&nextjs_auction::script_composition_html(),
false,
true,
));
let router = TrustedServerApp::routes_with_settings_and_services(
nextjs_auction::script_composition_settings(),
nextjs_auction::services(origin),
)
.expect("should build script composition router");
let mut service = EdgeZeroAxumService::new(router);
let request = Request::builder()
.uri("/script-composition")
.header("host", host)
.body(AxumBody::empty())
.expect("should build local host request");
let response = service
.ready()
.await
.expect("should be ready")
.call(request)
.await
.expect("should serve local host request");
assert_eq!(
response.status().as_u16(),
200,
"should accept host: {host}"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("should consume local host response");
let html = String::from_utf8(body.to_vec()).expect("should emit UTF-8 HTML");
assert!(
html.contains(&format!("http://{host}/app")),
"should preserve host and port in rewritten Flight URL: {host}"
);
assert!(
!html.contains("__ts_rsc_"),
"should resolve every local host payload"
);
assert!(
html.ends_with("</body></html>"),
"should complete local response"
);
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn nextjs_gtm_html_response_is_complete() {
for gzip in [false, true] {
let origin = Arc::new(nextjs_auction::NextJsAuctionOrigin::with_html_response(
&nextjs_auction::script_composition_html(),
gzip,
false,
));
let router = TrustedServerApp::routes_with_settings_and_services(
nextjs_auction::script_composition_settings(),
nextjs_auction::services(origin),
)
.expect("should build mixed integration router");
let mut service = EdgeZeroAxumService::new(router);
let request = Request::builder()
.method("GET")
.uri("/article")
.header("host", nextjs_auction::PUBLISHER_HOST)
.header("accept", "text/html")
.body(AxumBody::empty())
.expect("should build navigation");
let response = service
.ready()
.await
.expect("should be ready")
.call(request)
.await
.expect("should serve HTML");
assert_eq!(
response.status().as_u16(),
200,
"should return a successful composed publisher response"
);
assert_eq!(
response
.headers()
.get("content-encoding")
.map(|value| value.to_str().expect("should have ASCII coding")),
gzip.then_some("gzip"),
"should retain the requested response compression"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("should consume entire adapter body");
nextjs_auction::assert_script_composition_response(&body, gzip);
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn health_reports_git_version() {
let mut service = make_service();
Expand Down
54 changes: 54 additions & 0 deletions crates/trusted-server-adapter-fastly/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1394,6 +1394,7 @@ mod tests {
use std::net::{IpAddr, Ipv4Addr};

use error_stack::Report;
use futures::StreamExt as _;
use futures::executor::block_on;
use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE;
use trusted_server_core::ec::device::DeviceSignals;
Expand All @@ -1410,6 +1411,7 @@ mod tests {
TemplateCacheMiss, TemplateCacheReservation, TemplateEntry, TemplateMetadata,
};
use trusted_server_core::settings::Settings;
use trusted_server_core::test_support::nextjs_auction;

#[test]
fn hooks_store_metadata_matches_edgezero_manifest() {
Expand Down Expand Up @@ -3152,6 +3154,58 @@ mod tests {
);
}

#[test]
fn dispatch_fallback_nextjs_gtm_response_is_complete() {
for gzip in [false, true] {
let state = build_state_from_settings(nextjs_auction::script_composition_settings())
.expect("should build mixed state");
let origin = Arc::new(nextjs_auction::NextJsAuctionOrigin::with_html_response(
&nextjs_auction::script_composition_html(),
gzip,
true,
));
let services = nextjs_auction::services(origin);
let request = request_builder()
.method(Method::GET)
.uri(format!(
"https://{}/article",
nextjs_auction::PUBLISHER_HOST
))
.header("host", nextjs_auction::PUBLISHER_HOST)
.header("accept", "text/html")
.body(Body::empty())
.expect("should build navigation");
let response = block_on(super::dispatch_fallback(&state, &services, request));
assert_eq!(
response.status(),
StatusCode::OK,
"should return a successful composed publisher response"
);
assert!(
!response.headers().contains_key(header::CONTENT_LENGTH),
"should drop the stale content length after streaming rewrites"
);
assert_eq!(
response
.headers()
.get(header::CONTENT_ENCODING)
.map(|value| value.to_str().expect("should have ASCII coding")),
gzip.then_some("gzip"),
"should retain the requested response compression"
);
let Body::Stream(mut stream) = response.into_body() else {
panic!("should retain lazy Fastly body");
};
let mut bytes = Vec::new();
block_on(async {
while let Some(chunk) = stream.next().await {
bytes.extend_from_slice(&chunk.expect("should process every stream item"));
}
});
nextjs_auction::assert_script_composition_response(&bytes, gzip);
}
}

#[test]
fn dispatch_fallback_streams_publisher_body_without_buffering() {
// Regression guard for the publisher streaming cutover (#849): a
Expand Down
Loading
Loading