-
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
Open
prk-Jr
wants to merge
4
commits into
main
Choose a base branch
from
fix/synthetic-id-cookie-httponly
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
08a6108
Add HttpOnly flag to synthetic ID cookie
prk-Jr 17eb311
Address PR review feedback on synthetic ID cookie sanitization
prk-Jr 2f3282f
Address PR review feedback on synthetic ID cookie sanitization
prk-Jr 605cac9
Merge branch 'main' into fix/synthetic-id-cookie-httponly
prk-Jr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,13 +61,57 @@ 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`).
|
||
| /// | ||
| /// The `synthetic_id` is sanitized via an allowlist before embedding in the cookie value. | ||
| /// Only ASCII alphanumeric characters and `.`, `-`, `_` are permitted — matching the | ||
| /// known synthetic ID format (`{64-char-hex}.{6-char-alphanumeric}`). Any stripped | ||
| /// characters are logged as a warning so header/cookie mismatches can be detected. | ||
| /// | ||
| /// The `cookie_domain` is validated at config load time via [`validator::Validate`] on | ||
| /// [`crate::settings::Publisher`]; bad config fails at startup, not per-request. | ||
| /// | ||
| /// # Examples | ||
| /// | ||
| /// ```no_run | ||
| /// # use trusted_server_common::cookies::create_synthetic_cookie; | ||
| /// # use trusted_server_common::settings::Settings; | ||
| /// // `settings` is loaded at startup via `Settings::from_toml_and_env`. | ||
| /// # fn example(settings: &Settings) { | ||
| /// let cookie = create_synthetic_cookie(settings, "abc123.xk92ab"); | ||
| /// assert!(cookie.contains("HttpOnly")); | ||
| /// assert!(cookie.contains("Secure")); | ||
| /// # } | ||
| /// ``` | ||
| #[must_use] | ||
| pub fn create_synthetic_cookie(settings: &Settings, synthetic_id: &str) -> String { | ||
| // Sanitize synthetic_id at runtime using an allowlist: only ASCII alphanumeric | ||
| // and `.`, `-`, `_` are permitted. This is stricter than a denylist and covers | ||
| // NUL bytes, spaces, tabs, and other control characters that a denylist would miss. | ||
| // Synthetic IDs originating from the x-synthetic-id request header are untrusted. | ||
| let safe_id: String = synthetic_id | ||
prk-Jr marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .chars() | ||
| .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) | ||
| .collect(); | ||
| if safe_id.len() != synthetic_id.len() { | ||
| log::warn!( | ||
| "Stripped disallowed characters from synthetic_id before setting cookie (len {} -> {}); \ | ||
| the x-synthetic-id response header may differ from the cookie value", | ||
| synthetic_id.len(), | ||
| safe_id.len(), | ||
| ); | ||
| } | ||
| 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 +218,43 @@ 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_disallowed_chars_in_id() { | ||
| let settings = create_test_settings(); | ||
| // Allowlist permits only ASCII alphanumeric, '.', '-', '_'. | ||
| // ';', '=', '\r', '\n', spaces, NUL bytes, and other control chars are all stripped. | ||
| let result = create_synthetic_cookie(&settings, "evil;injected\r\nfoo=bar\0baz"); | ||
| // Extract the value portion anchored to the cookie name constant to | ||
| // avoid false positives from disallowed chars 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, "evilinjectedfoobarbaz", | ||
| "should strip disallowed characters and preserve safe chars" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_create_synthetic_cookie_preserves_well_formed_id() { | ||
| let settings = create_test_settings(); | ||
| // A well-formed ID should pass through the allowlist unmodified. | ||
| let id = "abc123def0123456789abcdef0123456789abcdef0123456789abcdef01234567.xk92ab"; | ||
| let result = create_synthetic_cookie(&settings, id); | ||
| 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, id, "should not modify a well-formed synthetic ID"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_set_synthetic_cookie() { | ||
| let settings = create_test_settings(); | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.