Skip to content

Commit 184ec76

Browse files
Address edge cookie review blockers
1 parent 61c3a14 commit 184ec76

9 files changed

Lines changed: 221 additions & 63 deletions

File tree

.github/actions/setup-integration-test-env/action.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ runs:
8181
TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:${{ inputs.origin-port }}
8282
TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret
8383
TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret-padded-32
84+
TRUSTED_SERVER__EC__PARTNERS: >-
85+
[{"name":"Integration Test Partner","source_domain":"inttest.example.com","bidstream_enabled":true,"api_token":"integration-test-token-alpha-32-bytes-ok"},{"name":"Integration Test Partner 2","source_domain":"inttest2.example.com","bidstream_enabled":true,"api_token":"integration-test-token-bravo-32-bytes-ok"}]
8486
TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false"
8587
run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
8688

crates/integration-tests/tests/frameworks/scenarios.rs

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ use crate::common::runtime::{origin_port, TestError, TestResult};
77
use error_stack::Report;
88
use error_stack::ResultExt as _;
99

10+
const INTTEST_API_TOKEN: &str = "integration-test-token-alpha-32-bytes-ok";
11+
const INTTEST2_API_TOKEN: &str = "integration-test-token-bravo-32-bytes-ok";
12+
1013
/// Standard test scenarios applicable to all frontend frameworks.
1114
///
1215
/// Each scenario tests a core trusted-server behavior that should work
@@ -514,21 +517,21 @@ fn use_seeded_ec(client: &EcTestClient, ec_id: &str) -> String {
514517

515518
/// Full lifecycle: seeded EC → batch sync → identify (Bearer auth) with scoped UID.
516519
///
517-
/// Uses the `inttest` partner pre-configured in `trusted-server.toml`.
520+
/// Uses the `inttest` partner injected by the integration-test build.
518521
fn ec_full_lifecycle(base_url: &str) -> TestResult<()> {
519522
let client = EcTestClient::new(base_url);
520523
allow_ec_generation(&client);
521524
let seeded_ec_id = seeded_ec_id('a', "test01");
522525
let ec_id = use_seeded_ec(&client, &seeded_ec_id);
523526
log::info!("EC full lifecycle: using seeded EC ID = {ec_id}");
524527

525-
// 2. Batch sync writes partner UID (partner "inttest" is in config)
528+
// 2. Batch sync writes partner UID (partner "inttest" is injected)
526529
let mappings = vec![BatchMapping {
527530
ec_id: ec_id.clone(),
528531
partner_uid: "user-uid-42".to_owned(),
529532
timestamp: 1_700_000_000,
530533
}];
531-
let resp = batch_sync(&client, "inttest-api-key-1-32-bytes-minimum", &mappings)?;
534+
let resp = batch_sync(&client, INTTEST_API_TOKEN, &mappings)?;
532535
let json = assert_json_response(resp, 200)
533536
.attach("EC full lifecycle: batch sync should return 200")?;
534537

@@ -544,7 +547,7 @@ fn ec_full_lifecycle(base_url: &str) -> TestResult<()> {
544547
}
545548

546549
// 3. Identify with Bearer auth should return the synced UID
547-
let json = assert_json_response(identify(&client, "inttest-api-key-1-32-bytes-minimum")?, 200)
550+
let json = assert_json_response(identify(&client, INTTEST_API_TOKEN)?, 200)
548551
.attach("EC full lifecycle: identify after batch sync")?;
549552

550553
let source_domain = json.get("source_domain").and_then(|v| v.as_str());
@@ -596,11 +599,11 @@ fn ec_consent_withdrawal(base_url: &str) -> TestResult<()> {
596599

597600
// 3. With consent still granted and the EC cookie revoked, identify should
598601
// now report no EC present.
599-
let resp = identify(&client, "inttest-api-key-1-32-bytes-minimum")?;
602+
let resp = identify(&client, INTTEST_API_TOKEN)?;
600603
assert_status(&resp, 204).attach("identify should return 204 after cookie revocation")?;
601604

602605
// 4. With GPC still asserted, identify should reflect consent denial.
603-
let resp = identify_with_headers(&client, "inttest-api-key-1-32-bytes-minimum", &[("sec-gpc", "1")])?;
606+
let resp = identify_with_headers(&client, INTTEST_API_TOKEN, &[("sec-gpc", "1")])?;
604607
assert_status(&resp, 403)
605608
.attach("identify with GPC should return 403 after consent withdrawal")?;
606609

@@ -613,7 +616,7 @@ fn ec_identify_without_ec(base_url: &str) -> TestResult<()> {
613616
let client = EcTestClient::new(base_url);
614617
allow_ec_generation(&client);
615618

616-
let resp = identify(&client, "inttest-api-key-1-32-bytes-minimum")?;
619+
let resp = identify(&client, INTTEST_API_TOKEN)?;
617620
assert_status(&resp, 204)
618621
.attach("identify without EC cookie should return 204 when consent is granted")?;
619622

@@ -631,7 +634,7 @@ fn ec_identify_consent_denied(base_url: &str) -> TestResult<()> {
631634
// Identify with GPC=1 — in the default US-CA test geo, GPC is an explicit
632635
// denial that must override the allow cookie. Per spec §11.4, consent is
633636
// evaluated after Bearer auth, so this must be 403 Forbidden.
634-
let resp = identify_with_headers(&client, "inttest-api-key-1-32-bytes-minimum", &[("sec-gpc", "1")])?;
637+
let resp = identify_with_headers(&client, INTTEST_API_TOKEN, &[("sec-gpc", "1")])?;
635638

636639
let status = resp.status().as_u16();
637640
if status != 403 {
@@ -654,25 +657,25 @@ fn ec_concurrent_partner_syncs(base_url: &str) -> TestResult<()> {
654657
let ec_id = use_seeded_ec(&client, &seeded_ec_id);
655658
log::info!("EC concurrent syncs: using seeded EC = {ec_id}");
656659

657-
// Batch sync both partners (both are pre-configured in trusted-server.toml)
660+
// Batch sync both partners injected by the integration-test build.
658661
let mappings_a = vec![BatchMapping {
659662
ec_id: ec_id.clone(),
660663
partner_uid: "uid-a".to_owned(),
661664
timestamp: 1_700_000_000,
662665
}];
663-
let resp = batch_sync(&client, "inttest-api-key-1-32-bytes-minimum", &mappings_a)?;
666+
let resp = batch_sync(&client, INTTEST_API_TOKEN, &mappings_a)?;
664667
assert_json_response(resp, 200).attach("batch sync inttest should succeed")?;
665668

666669
let mappings_b = vec![BatchMapping {
667670
ec_id: ec_id.clone(),
668671
partner_uid: "uid-b".to_owned(),
669672
timestamp: 1_700_000_000,
670673
}];
671-
let resp = batch_sync(&client, "inttest2-api-key-2-32-bytes-minimum", &mappings_b)?;
674+
let resp = batch_sync(&client, INTTEST2_API_TOKEN, &mappings_b)?;
672675
assert_json_response(resp, 200).attach("batch sync inttest2 should succeed")?;
673676

674677
// Identify as inttest → should see only inttest's UID
675-
let json = assert_json_response(identify(&client, "inttest-api-key-1-32-bytes-minimum")?, 200)
678+
let json = assert_json_response(identify(&client, INTTEST_API_TOKEN)?, 200)
676679
.attach("identify as inttest after dual sync")?;
677680
let uid = json.get("uid").and_then(|v| v.as_str());
678681
if uid != Some("uid-a") {
@@ -686,7 +689,7 @@ fn ec_concurrent_partner_syncs(base_url: &str) -> TestResult<()> {
686689
}
687690

688691
// Identify as inttest2 → should see only inttest2's UID
689-
let json = assert_json_response(identify(&client, "inttest2-api-key-2-32-bytes-minimum")?, 200)
692+
let json = assert_json_response(identify(&client, INTTEST2_API_TOKEN)?, 200)
690693
.attach("identify as inttest2 after dual sync")?;
691694
let uid = json.get("uid").and_then(|v| v.as_str());
692695
if uid != Some("uid-b") {
@@ -705,21 +708,21 @@ fn ec_concurrent_partner_syncs(base_url: &str) -> TestResult<()> {
705708

706709
/// Batch sync happy path: authenticated request writes UID, verify via identify.
707710
///
708-
/// Uses the `inttest` partner pre-configured in `trusted-server.toml`.
711+
/// Uses the `inttest` partner injected by the integration-test build.
709712
fn ec_batch_sync_happy_path(base_url: &str) -> TestResult<()> {
710713
let client = EcTestClient::new(base_url);
711714
allow_ec_generation(&client);
712715
let seeded_ec_id = seeded_ec_id('e', "test05");
713716
let ec_id = use_seeded_ec(&client, &seeded_ec_id);
714717
log::info!("EC batch sync happy path: using seeded ec_id = {ec_id}");
715718

716-
// Batch sync writes a UID for this EC ID (partner "inttest" is in config)
719+
// Batch sync writes a UID for this EC ID (partner "inttest" is injected)
717720
let mappings = vec![BatchMapping {
718721
ec_id: ec_id.clone(),
719722
partner_uid: "batch-uid-99".to_owned(),
720723
timestamp: 1_700_000_000,
721724
}];
722-
let resp = batch_sync(&client, "inttest-api-key-1-32-bytes-minimum", &mappings)?;
725+
let resp = batch_sync(&client, INTTEST_API_TOKEN, &mappings)?;
723726
let json = assert_json_response(resp, 200).attach("batch sync should return 200")?;
724727

725728
let accepted = json.get("accepted").and_then(|v| v.as_u64());
@@ -734,7 +737,7 @@ fn ec_batch_sync_happy_path(base_url: &str) -> TestResult<()> {
734737
}
735738

736739
// Verify via identify (Bearer auth, scoped response)
737-
let json = assert_json_response(identify(&client, "inttest-api-key-1-32-bytes-minimum")?, 200)
740+
let json = assert_json_response(identify(&client, INTTEST_API_TOKEN)?, 200)
738741
.attach("identify after batch sync")?;
739742

740743
let uid = json.get("uid").and_then(|v| v.as_str());

crates/trusted-server-core/src/consent/mod.rs

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -473,9 +473,9 @@ pub fn gate_eids_by_consent<T>(
473473
/// information on a device) must be explicitly consented. If no TCF data is
474474
/// available under GDPR, consent is assumed absent and EC is blocked.
475475
/// - **US state privacy**: opt-out model — EC is allowed unless the user has
476-
/// explicitly opted out via the US Privacy string **or** Global Privacy
477-
/// Control. GPC is checked independently — it always blocks EC creation
478-
/// regardless of what the US Privacy string says.
476+
/// explicitly opted out via Global Privacy Control, GPP US sale opt-out, or
477+
/// the US Privacy string. Explicit US opt-out signals take precedence over
478+
/// TCF storage consent.
479479
/// - **Non-regulated**: EC is allowed (no consent requirement).
480480
/// - **Unknown**: fail-closed — jurisdiction cannot be determined so EC is
481481
/// blocked as a precaution.
@@ -491,23 +491,34 @@ pub fn allows_ec_creation(ctx: &ConsentContext) -> bool {
491491
}
492492
jurisdiction::Jurisdiction::UsState(_) => {
493493
// GPC is an independent opt-out signal — it always blocks EC
494-
// creation regardless of what the US Privacy string says.
494+
// creation regardless of other consent signals.
495495
if ctx.gpc {
496496
return false;
497497
}
498-
// When a CMP uses TCF in the US (e.g. Didomi), respect the
499-
// TCF Purpose 1 decision — this is an explicit opt-in signal.
500-
// The Sourcepoint GPP design documents this precedence decision.
498+
// Explicit US opt-out signals take precedence over TCF storage
499+
// consent in US-state jurisdictions.
500+
if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) {
501+
return false;
502+
}
503+
if ctx
504+
.us_privacy
505+
.as_ref()
506+
.is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes)
507+
{
508+
return false;
509+
}
510+
// When a CMP uses TCF in the US (e.g. Didomi), respect the TCF
511+
// Purpose 1 decision if no explicit US opt-out signal is present.
501512
if let Some(tcf) = effective_tcf(ctx) {
502513
return tcf.has_storage_consent();
503514
}
504-
// Check GPP US section for sale opt-out.
515+
// GPP US sale_opt_out=false is an explicit non-opt-out signal.
505516
if let Some(gpp) = &ctx.gpp {
506517
if let Some(opted_out) = gpp.us_sale_opt_out {
507518
return !opted_out;
508519
}
509520
}
510-
// Check US Privacy string for explicit opt-out.
521+
// Check US Privacy string when no TCF decision is present.
511522
if let Some(usp) = &ctx.us_privacy {
512523
return usp.opt_out_sale != PrivacyFlag::Yes;
513524
}
@@ -540,12 +551,20 @@ pub fn has_explicit_ec_withdrawal(ctx: &ConsentContext) -> bool {
540551
if ctx.gpc {
541552
return true;
542553
}
543-
if let Some(tcf) = effective_tcf(ctx) {
544-
return !tcf.has_storage_consent();
554+
if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) {
555+
return true;
545556
}
546-
ctx.us_privacy
557+
if ctx
558+
.us_privacy
547559
.as_ref()
548560
.is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes)
561+
{
562+
return true;
563+
}
564+
if let Some(tcf) = effective_tcf(ctx) {
565+
return !tcf.has_storage_consent();
566+
}
567+
false
549568
}
550569
jurisdiction::Jurisdiction::NonRegulated | jurisdiction::Jurisdiction::Unknown => false,
551570
}
@@ -1145,7 +1164,7 @@ mod tests {
11451164
}
11461165

11471166
#[test]
1148-
fn ec_allowed_us_state_tcf_takes_priority_over_us_privacy() {
1167+
fn ec_blocked_us_state_us_privacy_opt_out_overrides_tcf() {
11491168
let ctx = ConsentContext {
11501169
jurisdiction: Jurisdiction::UsState("CA".to_owned()),
11511170
tcf: Some(make_tcf_with_storage(true)),
@@ -1158,8 +1177,12 @@ mod tests {
11581177
..ConsentContext::default()
11591178
};
11601179
assert!(
1161-
allows_ec_creation(&ctx),
1162-
"TCF consent should take priority over US Privacy opt-out when both present"
1180+
!allows_ec_creation(&ctx),
1181+
"US Privacy opt-out should take priority over TCF consent"
1182+
);
1183+
assert!(
1184+
has_explicit_ec_withdrawal(&ctx),
1185+
"US Privacy opt-out should be treated as an explicit withdrawal"
11631186
);
11641187
}
11651188

@@ -1197,6 +1220,10 @@ mod tests {
11971220
!allows_ec_creation(&ctx),
11981221
"US state + GPP US sale_opt_out=true should block EC"
11991222
);
1223+
assert!(
1224+
has_explicit_ec_withdrawal(&ctx),
1225+
"GPP US sale opt-out should be treated as an explicit withdrawal"
1226+
);
12001227
}
12011228

12021229
#[test]
@@ -1219,7 +1246,7 @@ mod tests {
12191246
}
12201247

12211248
#[test]
1222-
fn ec_us_state_tcf_takes_priority_over_gpp_us() {
1249+
fn ec_us_state_gpp_us_opt_out_overrides_tcf() {
12231250
let ctx = ConsentContext {
12241251
jurisdiction: Jurisdiction::UsState("TN".to_owned()),
12251252
tcf: Some(make_tcf_with_storage(true)),
@@ -1232,13 +1259,17 @@ mod tests {
12321259
..ConsentContext::default()
12331260
};
12341261
assert!(
1235-
allows_ec_creation(&ctx),
1236-
"TCF consent should take priority over GPP US opt-out"
1262+
!allows_ec_creation(&ctx),
1263+
"GPP US opt-out should take priority over TCF consent"
1264+
);
1265+
assert!(
1266+
has_explicit_ec_withdrawal(&ctx),
1267+
"GPP US opt-out should be treated as an explicit withdrawal"
12371268
);
12381269
}
12391270

12401271
#[test]
1241-
fn ec_us_state_gpp_us_takes_priority_over_us_privacy() {
1272+
fn ec_us_state_us_privacy_opt_out_overrides_gpp_non_opt_out() {
12421273
let ctx = ConsentContext {
12431274
jurisdiction: Jurisdiction::UsState("TN".to_owned()),
12441275
gpp: Some(GppConsent {
@@ -1256,8 +1287,12 @@ mod tests {
12561287
..ConsentContext::default()
12571288
};
12581289
assert!(
1259-
allows_ec_creation(&ctx),
1260-
"GPP US should take priority over us_privacy opt-out"
1290+
!allows_ec_creation(&ctx),
1291+
"US Privacy opt-out should block EC even when GPP US has no sale opt-out"
1292+
);
1293+
assert!(
1294+
has_explicit_ec_withdrawal(&ctx),
1295+
"US Privacy opt-out should be treated as an explicit withdrawal"
12611296
);
12621297
}
12631298

crates/trusted-server-core/src/proxy.rs

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ fn rebuild_response_with_body(
147147
if name == header::CONTENT_ENCODING && !preserve_encoding {
148148
continue;
149149
}
150-
resp.set_header(name, value);
150+
resp.append_header(name, value);
151151
}
152152
resp.set_header(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
153153
resp.set_body(body);
@@ -1167,8 +1167,8 @@ mod tests {
11671167
use super::{
11681168
copy_proxy_forward_headers, handle_first_party_click, handle_first_party_proxy,
11691169
handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, is_host_allowed,
1170-
reconstruct_and_validate_signed_target, redirect_is_permitted, ProxyRequestConfig,
1171-
SUPPORTED_ENCODINGS,
1170+
rebuild_response_with_body, reconstruct_and_validate_signed_target, redirect_is_permitted,
1171+
ProxyRequestConfig, SUPPORTED_ENCODINGS,
11721172
};
11731173
use crate::error::{IntoHttpResponse, TrustedServerError};
11741174
use crate::test_support::tests::create_test_settings;
@@ -1878,6 +1878,44 @@ mod tests {
18781878
);
18791879
}
18801880

1881+
#[test]
1882+
fn rebuild_response_with_body_preserves_multiple_set_cookie_headers() {
1883+
let mut beresp = Response::from_status(StatusCode::OK);
1884+
beresp.append_header(header::SET_COOKIE, "first=1; Path=/; HttpOnly");
1885+
beresp.append_header(header::SET_COOKIE, "second=2; Path=/; Secure");
1886+
beresp.set_header(header::CONTENT_TYPE, "text/plain");
1887+
beresp.set_header(header::CONTENT_LENGTH, "999");
1888+
1889+
let rebuilt = rebuild_response_with_body(
1890+
&beresp,
1891+
"text/html; charset=utf-8",
1892+
b"rewritten".to_vec(),
1893+
false,
1894+
);
1895+
1896+
let set_cookie_values: Vec<&str> = rebuilt
1897+
.get_headers()
1898+
.filter(|(name, _)| *name == header::SET_COOKIE)
1899+
.map(|(_, value)| value.to_str().expect("should be valid Set-Cookie"))
1900+
.collect();
1901+
assert_eq!(
1902+
set_cookie_values,
1903+
vec!["first=1; Path=/; HttpOnly", "second=2; Path=/; Secure"],
1904+
"should preserve multiple Set-Cookie headers"
1905+
);
1906+
assert_eq!(
1907+
rebuilt
1908+
.get_header(header::CONTENT_TYPE)
1909+
.and_then(|value| value.to_str().ok()),
1910+
Some("text/html; charset=utf-8"),
1911+
"should set rewritten Content-Type"
1912+
);
1913+
assert!(
1914+
rebuilt.get_header(header::CONTENT_LENGTH).is_none(),
1915+
"should not preserve stale Content-Length"
1916+
);
1917+
}
1918+
18811919
#[test]
18821920
fn html_uncompressed_response_is_processed_without_encoding() {
18831921
let settings = create_test_settings();

0 commit comments

Comments
 (0)