Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

- 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

### Added

- Implemented basic authentication for configurable endpoint paths (#73)
Expand Down
6 changes: 6 additions & 0 deletions crates/trusted-server-core/src/cookies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ fn is_allowed_synthetic_id_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')
}

// Outbound allowlist for cookie sanitization: permits [a-zA-Z0-9._-] as a
// defense-in-depth backstop when setting the Set-Cookie header. This is
// intentionally broader than the inbound format validator
// (`synthetic::is_valid_synthetic_id`), which enforces the exact
// `<64-hex>.<6-alphanumeric>` structure and is used to reject untrusted
// request values before they enter the system.
#[must_use]
pub(crate) fn synthetic_id_has_only_allowed_chars(synthetic_id: &str) -> bool {
synthetic_id.chars().all(is_allowed_synthetic_id_char)
Expand Down
11 changes: 9 additions & 2 deletions crates/trusted-server-core/src/integrations/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1369,8 +1369,15 @@ mod tests {
let registry = IntegrationRegistry::from_routes(routes);

let mut req = Request::get("https://test.example.com/integrations/test/synthetic");
// Pre-existing cookie
req.set_header(header::COOKIE, "synthetic_id=existing_id_12345");
// Pre-existing cookie with a valid-format synthetic ID
req.set_header(
header::COOKIE,
format!(
"{}={}",
crate::constants::COOKIE_SYNTHETIC_ID,
crate::test_support::tests::VALID_SYNTHETIC_ID
),
);

let result = futures::executor::block_on(registry.handle_proxy(
&Method::GET,
Expand Down
5 changes: 3 additions & 2 deletions crates/trusted-server-core/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1328,7 +1328,8 @@ mod tests {
sig
),
);
req.set_header(crate::constants::HEADER_X_SYNTHETIC_ID, "synthetic-123");
let valid_synthetic_id = crate::test_support::tests::VALID_SYNTHETIC_ID;
req.set_header(crate::constants::HEADER_X_SYNTHETIC_ID, valid_synthetic_id);

let resp = handle_first_party_click(&settings, req)
.await
Expand All @@ -1346,7 +1347,7 @@ mod tests {
assert_eq!(pairs.remove("foo").as_deref(), Some("1"));
assert_eq!(
pairs.remove("synthetic_id").as_deref(),
Some("synthetic-123")
Some(valid_synthetic_id)
);
assert!(pairs.is_empty());
}
Expand Down
40 changes: 27 additions & 13 deletions crates/trusted-server-core/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::rsc_flight::RscFlightUrlRewriter;
use crate::settings::Settings;
use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline};
use crate::streaming_replacer::create_url_replacer;
use crate::synthetic::get_or_generate_synthetic_id;
use crate::synthetic::{get_or_generate_synthetic_id, is_valid_synthetic_id};

/// Unified tsjs static serving: `/static/tsjs=<filename>`
///
Expand Down Expand Up @@ -377,14 +377,23 @@ pub fn handle_publisher_request(
// characters. The header is still emitted when consent allows it.
set_synthetic_cookie(settings, &mut response, synthetic_id.as_str());
} else if let Some(cookie_synthetic_id) = existing_ssc_cookie.as_deref() {
log::info!(
"SSC revoked for '{}': consent withdrawn (jurisdiction={})",
cookie_synthetic_id,
consent_context.jurisdiction,
);
// Always expire the cookie — consent is withdrawn regardless of whether the
// stored value is well-formed.
expire_synthetic_cookie(settings, &mut response);
if let Some(store_name) = &settings.consent.consent_store {
crate::consent::kv::delete_consent_from_kv(store_name, cookie_synthetic_id);
if is_valid_synthetic_id(cookie_synthetic_id) {
log::info!(
"SSC revoked: consent withdrawn (jurisdiction={})",
consent_context.jurisdiction,
);
if let Some(store_name) = &settings.consent.consent_store {
crate::consent::kv::delete_consent_from_kv(store_name, cookie_synthetic_id);
}
} else {
log::warn!(
"SSC cookie has invalid format, skipping KV deletion (len={}, jurisdiction={})",
cookie_synthetic_id.len(),
consent_context.jurisdiction,
);
}
} else {
log::debug!(
Expand All @@ -400,7 +409,7 @@ pub fn handle_publisher_request(
mod tests {
use super::*;
use crate::integrations::IntegrationRegistry;
use crate::test_support::tests::create_test_settings;
use crate::test_support::tests::{create_test_settings, VALID_SYNTHETIC_ID};
use fastly::http::{Method, StatusCode};

#[test]
Expand Down Expand Up @@ -518,9 +527,14 @@ mod tests {
#[test]
fn revocation_targets_cookie_synthetic_id_not_header() {
let settings = create_test_settings();
let cookie_synthetic_id =
"b2a1c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0b1a2.Zx98y7";
let mut req = Request::new(Method::GET, "https://test.example.com/page");
req.set_header("x-synthetic-id", "header_id");
req.set_header("cookie", "synthetic_id=cookie_id; other=value");
req.set_header(HEADER_X_SYNTHETIC_ID, VALID_SYNTHETIC_ID);
req.set_header(
header::COOKIE,
format!("synthetic_id={cookie_synthetic_id}; other=value"),
);

let cookie_jar = handle_request_cookies(&req).expect("should parse cookies");
let existing_ssc_cookie = cookie_jar
Expand All @@ -533,11 +547,11 @@ mod tests {

assert_eq!(
existing_ssc_cookie.as_deref(),
Some("cookie_id"),
Some(cookie_synthetic_id),
"should read revocation target from cookie value"
);
assert_eq!(
resolved_synthetic_id, "header_id",
resolved_synthetic_id, VALID_SYNTHETIC_ID,
"should still resolve request synthetic ID from header precedence"
);
}
Expand Down
Loading
Loading