Conversation
The synthetic_id cookie was missing the HttpOnly attribute, allowing any XSS on a publisher page to exfiltrate the tracking identifier via document.cookie. HttpOnly is safe to set because integrations receive the synthetic ID via the x-synthetic-id response header and no client-side JS reads it from the cookie directly. Also documents the rationale for each security attribute (Secure, HttpOnly, SameSite=Lax, Max-Age) in the doc comment, and adds debug_assert guards against cookie metacharacter injection in both the synthetic_id value and cookie_domain. Closes #411
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Review Summary
Clean, well-scoped security hardening. The HttpOnly addition is correct, the doc comment is excellent, and the change is minimal. One concern about the debug_assert! approach and a few smaller items below.
0 Blockers · 1 High · 3 Medium
| /// - `SameSite=Lax`: sent on same-site requests and top-level cross-site navigations. | ||
| /// `Strict` is intentionally avoided — it would suppress the cookie on the first | ||
| /// request when a user arrives from an external page, breaking first-visit attribution. | ||
| /// - `Max-Age`: 1 year retention. |
There was a problem hiding this comment.
P2 — Missing # Panics doc section
Per project conventions, functions that can panic should document it. The debug_assert! macros below will panic in debug/test builds. Consider adding:
/// # Panics
///
/// Panics in debug builds if `synthetic_id` or `cookie_domain` contains
/// cookie metacharacters (`;`, `=`, `\n`, `\r`).
There was a problem hiding this comment.
@prk-Jr sorry the previous review didn't have clear suggestions.
Solid, well-scoped security fix. The HttpOnly addition is correct and the doc comment is excellent. Three actionable findings — two about the sanitization approach, one about the assert!.
| // Sanitize synthetic_id at runtime: strip cookie metacharacters to prevent | ||
| // header injection when the ID originates from untrusted input (e.g., the | ||
| // x-synthetic-id request header or an inbound cookie). | ||
| let safe_id: String = synthetic_id |
There was a problem hiding this comment.
🔴 P1 — Silent sanitization creates header/cookie mismatch
In publisher.rs:337-338, the raw synthetic_id is set as the x-synthetic-id response header, then the sanitized value goes into the cookie:
response.set_header(HEADER_X_SYNTHETIC_ID, synthetic_id.as_str()); // raw
set_synthetic_cookie(settings, &mut response, synthetic_id.as_str()); // sanitized insideIf sanitization ever strips characters, the response header and cookie will contain different IDs — silently breaking any downstream system that correlates them.
Minimal fix for this PR: Add a log::warn! when sanitization actually modifies the value, so operators can detect the mismatch:
if safe_id.len() != synthetic_id.len() {
log::warn!(
"Stripped cookie metacharacters from synthetic_id (len {} -> {})",
synthetic_id.len(),
safe_id.len(),
);
}Follow-up: Move sanitization upstream to get_or_generate_synthetic_id() in synthetic.rs so both the header and cookie use the same cleaned value.
| .collect(); | ||
| // `=` is excluded from the domain check: it only has special meaning in the | ||
| // name=value pair, not within an attribute like Domain. | ||
| assert!( |
There was a problem hiding this comment.
🔴 P1 — assert! should be a #[validate] on Publisher, not a per-request panic
This is the only assert! in production (non-test) code in the entire codebase, and it runs on every request. Since cookie_domain is baked into the binary at compile time via include_bytes!(), the value is immutable at runtime — if it's bad, every single request triggers a WASM trap (ungraceful 500, no structured logging, no to_error_response() fallback).
The project already has an established pattern for config validation: Publisher derives Validate, and origin_url uses #[validate(custom(function = validate_no_trailing_slash))]. The cookie_domain field has no validation at all — not even a length check.
Suggestion: Add a custom validator on Publisher::cookie_domain and remove the assert!:
// In settings.rs, on the Publisher struct:
#[validate(length(min = 1), custom(function = validate_cookie_domain))]
pub cookie_domain: String,
// Validator:
fn validate_cookie_domain(domain: &str) -> Result<(), validator::ValidationError> {
if domain.contains([';', '\n', '\r']) {
return Err(validator::ValidationError::new("cookie_domain contains cookie metacharacters"));
}
Ok(())
}This way bad config fails the build (from_toml_and_env() calls settings.validate()) and fails at startup (get_settings() calls settings.validate()) — never at request time.
| // x-synthetic-id request header or an inbound cookie). | ||
| let safe_id: String = synthetic_id | ||
| .chars() | ||
| .filter(|c| !matches!(c, ';' | '=' | '\n' | '\r')) |
There was a problem hiding this comment.
🟡 P2 — Allowlist is safer than denylist for untrusted input
The synthetic_id can arrive from the untrusted x-synthetic-id request header (synthetic.rs:130-136). The current denylist strips ;, =, \n, \r but misses NUL bytes, spaces, tabs, and other control characters that could cause issues in HTTP header serialization.
Since synthetic IDs have a known format ({64-char-hex}.{6-char-alphanumeric} per synthetic.rs:110), an allowlist is strictly safer:
let safe_id: String = synthetic_id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
.collect();This is a defense-in-depth improvement — the denylist works for known attack vectors, but the allowlist eliminates entire categories of future risk.
| @@ -61,13 +61,38 @@ pub fn handle_request_cookies( | |||
|
|
|||
| /// Creates a synthetic ID cookie string. | |||
There was a problem hiding this comment.
🟢 P3 — Missing # Examples doc section
Per CLAUDE.md line 198: "Add # Examples sections for public API functions." The doc comment is already excellent — thorough security rationale, # Panics section — but lacks # Examples. Minor, and other functions in this file don't have them either, so this is a nit rather than a blocker.
Summary
synthetic_idcookie was missingHttpOnly, allowing XSS on a publisher page to exfiltrate the tracking identifier viadocument.cookie.HttpOnlyis safe to add because no client-side JS reads this cookie — integrations receive the synthetic ID via thex-synthetic-idresponse header instead.debug_assert!guards against cookie metacharacter injection in both thesynthetic_idvalue andcookie_domain, and documents the rationale for each security attribute in the function's doc comment.Changes
crates/common/src/cookies.rsHttpOnlyto cookie format string; adddebug_assert!metacharacter guards; update doc comment to enumerate all security attributes with rationaleCloses
Closes #411
Test plan
cargo test --workspacecargo clippy --all-targets --all-features -- -D warningscargo fmt --all -- --checkcd crates/js/lib && npx vitest run— pre-existing ESM/CJS failure onmain, unrelated to this changecd crates/js/lib && npm run formatcd docs && npm run formatcargo build --bin trusted-server-fastly --release --target wasm32-wasip1fastly compute serveChecklist
unwrap()in production code — useexpect("should ...")tracingmacros (notprintln!)