-
Notifications
You must be signed in to change notification settings - Fork 8
Add HttpOnly flag to synthetic ID cookie #507
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,13 +61,38 @@ pub fn handle_request_cookies( | |
|
|
||
| /// Creates a synthetic ID cookie string. | ||
| /// | ||
| /// Generates a properly formatted cookie with security attributes | ||
| /// for storing the synthetic ID. | ||
| /// Generates a `Set-Cookie` header value with the following security attributes: | ||
| /// - `Secure`: transmitted over HTTPS only. | ||
| /// - `HttpOnly`: inaccessible to JavaScript (`document.cookie`), blocking XSS exfiltration. | ||
| /// Safe to set because integrations receive the synthetic ID via the `x-synthetic-id` | ||
| /// response header instead of reading it from the cookie directly. | ||
| /// - `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. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 — Missing Per project conventions, functions that can panic should document it. The /// # Panics
///
/// Panics in debug builds if `synthetic_id` or `cookie_domain` contains
/// cookie metacharacters (`;`, `=`, `\n`, `\r`).
|
||
| /// | ||
| /// # Panics | ||
| /// | ||
| /// Panics if `cookie_domain` in settings contains cookie metacharacters (`;`, `\n`, `\r`). | ||
| /// This indicates a configuration error and is enforced in all build profiles. | ||
| #[must_use] | ||
| pub fn create_synthetic_cookie(settings: &Settings, synthetic_id: &str) -> String { | ||
| // 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 P1 — Silent sanitization creates header/cookie mismatch In 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 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 |
||
| .chars() | ||
| .filter(|c| !matches!(c, ';' | '=' | '\n' | '\r')) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 P2 — Allowlist is safer than denylist for untrusted input The Since synthetic IDs have a known format ( 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. |
||
| .collect(); | ||
| // `=` is excluded from the domain check: it only has special meaning in the | ||
| // name=value pair, not within an attribute like Domain. | ||
| assert!( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 P1 — This is the only The project already has an established pattern for config validation: Suggestion: Add a custom validator on // 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 ( |
||
| !settings.publisher.cookie_domain.contains([';', '\n', '\r']), | ||
ChristianPavilonis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| "cookie_domain should not contain cookie metacharacters" | ||
| ); | ||
| format!( | ||
| "{}={}; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age={}", | ||
| COOKIE_SYNTHETIC_ID, synthetic_id, settings.publisher.cookie_domain, COOKIE_MAX_AGE, | ||
| "{}={}; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={}", | ||
| COOKIE_SYNTHETIC_ID, safe_id, settings.publisher.cookie_domain, COOKIE_MAX_AGE, | ||
| ) | ||
| } | ||
|
|
||
|
|
@@ -174,12 +199,28 @@ mod tests { | |
| assert_eq!( | ||
| result, | ||
| format!( | ||
| "{}=12345; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age={}", | ||
| "{}=12345; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={}", | ||
| COOKIE_SYNTHETIC_ID, settings.publisher.cookie_domain, COOKIE_MAX_AGE, | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_create_synthetic_cookie_sanitizes_metacharacters_in_id() { | ||
| let settings = create_test_settings(); | ||
| let result = create_synthetic_cookie(&settings, "evil;injected\r\nfoo=bar"); | ||
| // Extract the value portion anchored to the cookie name constant to | ||
| // avoid false positives from metacharacters in cookie attributes. | ||
| let value = result | ||
| .strip_prefix(&format!("{}=", COOKIE_SYNTHETIC_ID)) | ||
| .and_then(|s| s.split_once(';').map(|(v, _)| v)) | ||
| .expect("should have cookie value portion"); | ||
| assert_eq!( | ||
| value, "evilinjectedfoobar", | ||
| "should strip metacharacters and preserve safe chars" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_set_synthetic_cookie() { | ||
| let settings = create_test_settings(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟢 P3 — Missing
# Examplesdoc sectionPer CLAUDE.md line 198: "Add
# Examplessections for public API functions." The doc comment is already excellent — thorough security rationale,# Panicssection — but lacks# Examples. Minor, and other functions in this file don't have them either, so this is a nit rather than a blocker.