Skip to content

Commit de0525d

Browse files
Fix EC review findings: error handling, docs, dead code, and UID limit consistency
Address PR review findings across 11 files: - Elevate current_timestamp() fallback log from warn to error - Cache known_browser_h2_hashes() with OnceLock instead of recomputing per request - Unify MAX_UID_LENGTH to 512 across sync_pixel, batch_sync, and pull_sync - Fix identify json_response to use EdgeCookie error variant instead of Configuration - Remove unused _geo_info param from ec_finalize_response - Remove dead uppercase X- prefix check in copy_custom_headers - Add navigation-request Accept-header fallback debug logging - Document RefCell intent, pull-enabled index race condition, normalize_ec_id_for_kv defense-in-depth, and consent withdrawal test jurisdiction dependency
1 parent e096977 commit de0525d

11 files changed

Lines changed: 54 additions & 39 deletions

File tree

crates/integration-tests/tests/common/ec.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ pub struct EcTestClient {
3232
client: Client,
3333
pub base_url: String,
3434
/// The active `ts-ec` cookie value, updated after each response.
35+
///
36+
/// Uses `RefCell` for interior mutability because the test runner is
37+
/// single-threaded. This would need to become `Arc<Mutex<..>>` if tests
38+
/// are ever parallelized.
3539
ec_cookie: std::cell::RefCell<Option<String>>,
3640
}
3741

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,12 @@ fn ec_consent_withdrawal(base_url: &str) -> TestResult<()> {
569569
// 2. Second request with GPC=1 should revoke consent and expire the EC
570570
// cookie. This endpoint was selected because step #1 proved EC was
571571
// allowed for this client in the active runtime config.
572+
//
573+
// Implementation note: this works because GPC + no geo headers →
574+
// `Jurisdiction::Unknown` → fail-closed → consent denied. If the
575+
// consent engine ever becomes more permissive for Unknown jurisdictions,
576+
// this test should be updated to use an explicit GDPR consent denial
577+
// (e.g. a TCF string with denied purposes).
572578
let resp = client.get_with_headers("/", &[("sec-gpc", "1")])?;
573579

574580
if !is_ec_cookie_expired(&resp) {

crates/trusted-server-adapter-fastly/src/main.rs

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,7 @@ async fn route_request(
188188
match enforce_basic_auth(settings, &req) {
189189
Ok(Some(mut response)) => {
190190
if is_real_browser {
191-
ec_finalize_response(
192-
settings,
193-
geo_info.as_ref(),
194-
&ec_context,
195-
kv_graph.as_ref(),
196-
&mut response,
197-
);
191+
ec_finalize_response(settings, &ec_context, kv_graph.as_ref(), &mut response);
198192
}
199193
finalize_response(settings, geo_info.as_ref(), &mut response);
200194
return Ok(RouteOutcome {
@@ -337,13 +331,7 @@ async fn route_request(
337331

338332
// Bot gate: skip EC cookie writes and pull sync for unrecognized clients.
339333
if is_real_browser {
340-
ec_finalize_response(
341-
settings,
342-
geo_info.as_ref(),
343-
&ec_context,
344-
kv_graph.as_ref(),
345-
&mut response,
346-
);
334+
ec_finalize_response(settings, &ec_context, kv_graph.as_ref(), &mut response);
347335
}
348336

349337
finalize_response(settings, geo_info.as_ref(), &mut response);

crates/trusted-server-core/src/ec/batch_sync.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,12 @@ fn process_mappings(
260260
(accepted, errors)
261261
}
262262

263+
/// Normalizes an EC ID for use as a KV key by lowercasing the hash prefix.
264+
///
265+
/// `hex::encode` (used in `generate_ec_id`) always produces lowercase hex,
266+
/// so internal EC IDs are already lowercase. This normalization is a
267+
/// defense-in-depth measure for EC IDs submitted by external partners
268+
/// (via batch sync) that may use uppercase hex.
263269
fn normalize_ec_id_for_kv(ec_id: &str) -> String {
264270
let mut parts = ec_id.splitn(2, '.');
265271
let hash = parts.next().unwrap_or_default();

crates/trusted-server-core/src/ec/device.rs

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -175,15 +175,17 @@ const KNOWN_BROWSERS: &[(&str, &str, bool)] = &[
175175
("t13d1717h2", "1:65536;2:0;4:131072;5:16384", true),
176176
];
177177

178-
/// Computes H2 fingerprint hashes for the known browser allowlist.
178+
/// Returns H2 fingerprint hashes for the known browser allowlist.
179179
///
180-
/// Recomputed on each call — the list is tiny (3 entries) so the cost
181-
/// is negligible compared to any KV I/O.
182-
fn known_browser_h2_hashes() -> Vec<(&'static str, String, bool)> {
183-
KNOWN_BROWSERS
184-
.iter()
185-
.map(|(ja4, h2_raw, known)| (*ja4, compute_h2_fp_hash(h2_raw), *known))
186-
.collect()
180+
/// Computed once on first call and cached via `OnceLock`.
181+
fn known_browser_h2_hashes() -> &'static Vec<(&'static str, String, bool)> {
182+
static CACHE: std::sync::OnceLock<Vec<(&str, String, bool)>> = std::sync::OnceLock::new();
183+
CACHE.get_or_init(|| {
184+
KNOWN_BROWSERS
185+
.iter()
186+
.map(|(ja4, h2_raw, known)| (*ja4, compute_h2_fp_hash(h2_raw), *known))
187+
.collect()
188+
})
187189
}
188190

189191
/// Evaluates whether a request comes from a known browser.
@@ -199,8 +201,7 @@ pub fn evaluate_known_browser(ja4_class: Option<&str>, h2_fp_hash: Option<&str>)
199201
let ja4 = ja4_class?;
200202
let h2_hash = h2_fp_hash?;
201203

202-
let hashes = known_browser_h2_hashes();
203-
for (known_ja4, known_h2_hash, is_browser) in &hashes {
204+
for (known_ja4, known_h2_hash, is_browser) in known_browser_h2_hashes() {
204205
if ja4 == *known_ja4 && h2_hash == *known_h2_hash {
205206
return Some(*is_browser);
206207
}

crates/trusted-server-core/src/ec/finalize.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ use fastly::Response;
1010
use crate::consent::allows_ec_creation;
1111
use crate::consent::kv::delete_consent_from_kv;
1212
use crate::constants::HEADER_X_TS_EC;
13-
use crate::geo::GeoInfo;
1413
use crate::settings::Settings;
1514

1615
use super::cookies::{expire_ec_cookie, set_ec_cookie};
@@ -33,7 +32,6 @@ const EC_RESPONSE_HEADERS: &[&str] = &[
3332
/// and cookie writes for new EC generation.
3433
pub fn ec_finalize_response(
3534
settings: &Settings,
36-
_geo_info: Option<&GeoInfo>, // reserved for future route-specific finalize behavior
3735
ec_context: &EcContext,
3836
kv: Option<&KvIdentityGraph>,
3937
response: &mut Response,
@@ -233,7 +231,7 @@ mod tests {
233231
response.set_header("x-ts-ec", "stale");
234232
response.set_header("x-ts-eids", "[]");
235233

236-
ec_finalize_response(&settings, None, &ec_context, None, &mut response);
234+
ec_finalize_response(&settings, &ec_context, None, &mut response);
237235

238236
assert!(
239237
response.get_header("x-ts-ec").is_none(),
@@ -268,7 +266,7 @@ mod tests {
268266
);
269267
let mut response = Response::new();
270268

271-
ec_finalize_response(&settings, None, &ec_context, None, &mut response);
269+
ec_finalize_response(&settings, &ec_context, None, &mut response);
272270

273271
let header = response
274272
.get_header("x-ts-ec")
@@ -301,7 +299,7 @@ mod tests {
301299
);
302300
let mut response = Response::new();
303301

304-
ec_finalize_response(&settings, None, &ec_context, None, &mut response);
302+
ec_finalize_response(&settings, &ec_context, None, &mut response);
305303

306304
let header = response
307305
.get_header("x-ts-ec")
@@ -334,7 +332,7 @@ mod tests {
334332
);
335333
let mut response = Response::new();
336334

337-
ec_finalize_response(&settings, None, &ec_context, None, &mut response);
335+
ec_finalize_response(&settings, &ec_context, None, &mut response);
338336

339337
let header = response
340338
.get_header("x-ts-ec")
@@ -355,7 +353,7 @@ mod tests {
355353
let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown);
356354
let mut response = Response::new();
357355

358-
ec_finalize_response(&settings, None, &ec_context, None, &mut response);
356+
ec_finalize_response(&settings, &ec_context, None, &mut response);
359357

360358
assert!(
361359
response.get_header("x-ts-ec").is_none(),

crates/trusted-server-core/src/ec/identify.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ fn json_response<T: serde::Serialize>(
186186
status: StatusCode,
187187
body: &T,
188188
) -> Result<Response, Report<TrustedServerError>> {
189-
let body = serde_json::to_string(body).change_context(TrustedServerError::Configuration {
189+
let body = serde_json::to_string(body).change_context(TrustedServerError::EdgeCookie {
190190
message: "Failed to serialize identify response".to_owned(),
191191
})?;
192192

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ pub(crate) fn current_timestamp() -> u64 {
460460
.duration_since(std::time::UNIX_EPOCH)
461461
.map(|d| d.as_secs())
462462
.unwrap_or_else(|err| {
463-
log::warn!("SystemTime::now() failed, falling back to epoch 0: {err}");
463+
log::error!("SystemTime::now() failed, falling back to epoch 0: {err}");
464464
0
465465
})
466466
}

crates/trusted-server-core/src/ec/partner.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,13 @@ impl PartnerStore {
532532
/// Reads the current index, adds or removes the partner ID, and writes
533533
/// it back. All errors are logged and swallowed — the primary record
534534
/// write has already succeeded.
535+
///
536+
/// **Race condition:** This performs a read-modify-write without CAS.
537+
/// Concurrent partner registrations can overwrite each other's index
538+
/// updates (last write wins). The index is self-healing: any lost entry
539+
/// will be re-added on the next `upsert()` for that partner, and
540+
/// `pull_enabled_partners()` falls back to `list_registered()` when the
541+
/// index is missing or stale.
535542
fn update_pull_enabled_index(
536543
&self,
537544
store: &KVStore,

crates/trusted-server-core/src/ec/pull_sync.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,9 @@ fn extract_pull_uid(mut response: fastly::Response, partner_id: &str) -> Option<
334334
}
335335
};
336336

337-
const MAX_UID_LENGTH: usize = 256;
337+
// Must match MAX_UID_LENGTH in sync_pixel.rs / batch_sync.rs so that
338+
// partners see a consistent limit regardless of sync mechanism.
339+
const MAX_UID_LENGTH: usize = 512;
338340

339341
let uid = payload.uid.filter(|value| !value.is_empty());
340342
match uid {
@@ -526,12 +528,12 @@ mod tests {
526528

527529
#[test]
528530
fn extract_pull_uid_rejects_oversized_uid() {
529-
let long_uid = "x".repeat(257);
531+
let long_uid = "x".repeat(513);
530532
let body = format!("{{\"uid\":\"{long_uid}\"}}");
531533
let response = fastly::Response::from_status(StatusCode::OK).with_body(body);
532534

533535
let uid = extract_pull_uid(response, "ssp_x");
534-
assert!(uid.is_none(), "should reject uid exceeding 256 bytes");
536+
assert!(uid.is_none(), "should reject uid exceeding 512 bytes");
535537
}
536538

537539
#[test]

0 commit comments

Comments
 (0)