33//! This module provides functionality for parsing and creating cookies
44//! used in the trusted server system.
55
6+ use std:: borrow:: Cow ;
7+
68use cookie:: { Cookie , CookieJar } ;
79use error_stack:: { Report , ResultExt } ;
810use fastly:: http:: header;
@@ -28,6 +30,42 @@ pub const CONSENT_COOKIE_NAMES: &[&str] = &[
2830
2931const COOKIE_MAX_AGE : i32 = 365 * 24 * 60 * 60 ; // 1 year
3032
33+ fn is_allowed_synthetic_id_char ( c : char ) -> bool {
34+ c. is_ascii_alphanumeric ( ) || matches ! ( c, '.' | '-' | '_' )
35+ }
36+
37+ #[ must_use]
38+ pub ( crate ) fn synthetic_id_has_only_allowed_chars ( synthetic_id : & str ) -> bool {
39+ synthetic_id. chars ( ) . all ( is_allowed_synthetic_id_char)
40+ }
41+
42+ fn sanitize_synthetic_id_for_cookie ( synthetic_id : & str ) -> Cow < ' _ , str > {
43+ if synthetic_id_has_only_allowed_chars ( synthetic_id) {
44+ return Cow :: Borrowed ( synthetic_id) ;
45+ }
46+
47+ let safe_id = synthetic_id
48+ . chars ( )
49+ . filter ( |c| is_allowed_synthetic_id_char ( * c) )
50+ . collect :: < String > ( ) ;
51+
52+ log:: warn!(
53+ "Stripped disallowed characters from synthetic_id before setting cookie (len {} -> {}); \
54+ callers should reject invalid request IDs before cookie creation",
55+ synthetic_id. len( ) ,
56+ safe_id. len( ) ,
57+ ) ;
58+
59+ Cow :: Owned ( safe_id)
60+ }
61+
62+ fn synthetic_cookie_attributes ( settings : & Settings , max_age : i32 ) -> String {
63+ format ! (
64+ "Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={max_age}" ,
65+ settings. publisher. cookie_domain,
66+ )
67+ }
68+
3169/// Parses a cookie string into a [`CookieJar`].
3270///
3371/// Returns an empty jar if the cookie string is unparseable.
@@ -151,12 +189,46 @@ fn is_safe_cookie_value(value: &str) -> bool {
151189 . all ( |b| matches ! ( b, 0x21 | 0x23 ..=0x2B | 0x2D ..=0x3A | 0x3C ..=0x5B | 0x5D ..=0x7E ) )
152190}
153191
154- /// Formats the `Set-Cookie` header value for the synthetic ID cookie.
192+ /// Generates a `Set-Cookie` header value with the following security attributes:
193+ /// - `Secure`: transmitted over HTTPS only.
194+ /// - `HttpOnly`: inaccessible to JavaScript (`document.cookie`), blocking XSS exfiltration.
195+ /// Safe to set because integrations receive the synthetic ID via the `x-synthetic-id`
196+ /// response header instead of reading it from the cookie directly.
197+ /// - `SameSite=Lax`: sent on same-site requests and top-level cross-site navigations.
198+ /// `Strict` is intentionally avoided — it would suppress the cookie on the first
199+ /// request when a user arrives from an external page, breaking first-visit attribution.
200+ /// - `Max-Age`: 1 year retention.
201+ ///
202+ /// The `synthetic_id` is sanitized via an allowlist before embedding in the cookie value.
203+ /// Only ASCII alphanumeric characters and `.`, `-`, `_` are permitted — matching the
204+ /// known synthetic ID format (`{64-char-hex}.{6-char-alphanumeric}`). Request-sourced IDs
205+ /// with disallowed characters are rejected earlier in [`crate::synthetic::get_synthetic_id`];
206+ /// this sanitization remains as a defense-in-depth backstop for unexpected callers.
207+ ///
208+ /// The `cookie_domain` is validated at config load time via [`validator::Validate`] on
209+ /// [`crate::settings::Publisher`]; bad config fails at startup, not per-request.
210+ ///
211+ /// # Examples
212+ ///
213+ /// ```no_run
214+ /// # use trusted_server_core::cookies::create_synthetic_cookie;
215+ /// # use trusted_server_core::settings::Settings;
216+ /// // `settings` is loaded at startup via `Settings::from_toml_and_env`.
217+ /// # fn example(settings: &Settings) {
218+ /// let cookie = create_synthetic_cookie(settings, "abc123.xk92ab");
219+ /// assert!(cookie.contains("HttpOnly"));
220+ /// assert!(cookie.contains("Secure"));
221+ /// # }
222+ /// ```
155223#[ must_use]
156- fn create_synthetic_cookie ( settings : & Settings , synthetic_id : & str ) -> String {
224+ pub fn create_synthetic_cookie ( settings : & Settings , synthetic_id : & str ) -> String {
225+ let safe_id = sanitize_synthetic_id_for_cookie ( synthetic_id) ;
226+
157227 format ! (
158- "{}={}; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age={}" ,
159- COOKIE_SYNTHETIC_ID , synthetic_id, settings. publisher. cookie_domain, COOKIE_MAX_AGE ,
228+ "{}={}; {}" ,
229+ COOKIE_SYNTHETIC_ID ,
230+ safe_id,
231+ synthetic_cookie_attributes( settings, COOKIE_MAX_AGE ) ,
160232 )
161233}
162234
@@ -192,8 +264,9 @@ pub fn set_synthetic_cookie(
192264/// on receipt of this header.
193265pub fn expire_synthetic_cookie ( settings : & Settings , response : & mut fastly:: Response ) {
194266 let cookie = format ! (
195- "{}=; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age=0" ,
196- COOKIE_SYNTHETIC_ID , settings. publisher. cookie_domain,
267+ "{}=; {}" ,
268+ COOKIE_SYNTHETIC_ID ,
269+ synthetic_cookie_attributes( settings, 0 ) ,
197270 ) ;
198271 response. append_header ( header:: SET_COOKIE , cookie) ;
199272}
@@ -294,13 +367,44 @@ mod tests {
294367 assert_eq ! (
295368 cookie_str,
296369 format!(
297- "{}=abc123.XyZ789; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age={}" ,
370+ "{}=abc123.XyZ789; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={}" ,
298371 COOKIE_SYNTHETIC_ID , settings. publisher. cookie_domain, COOKIE_MAX_AGE ,
299372 ) ,
300373 "Set-Cookie header should match expected format"
301374 ) ;
302375 }
303376
377+ #[ test]
378+ fn test_create_synthetic_cookie_sanitizes_disallowed_chars_in_id ( ) {
379+ let settings = create_test_settings ( ) ;
380+ // Allowlist permits only ASCII alphanumeric, '.', '-', '_'.
381+ // ';', '=', '\r', '\n', spaces, NUL bytes, and other control chars are all stripped.
382+ let result = create_synthetic_cookie ( & settings, "evil;injected\r \n foo=bar\0 baz" ) ;
383+ // Extract the value portion anchored to the cookie name constant to
384+ // avoid false positives from disallowed chars in cookie attributes.
385+ let value = result
386+ . strip_prefix ( & format ! ( "{}=" , COOKIE_SYNTHETIC_ID ) )
387+ . and_then ( |s| s. split_once ( ';' ) . map ( |( v, _) | v) )
388+ . expect ( "should have cookie value portion" ) ;
389+ assert_eq ! (
390+ value, "evilinjectedfoobarbaz" ,
391+ "should strip disallowed characters and preserve safe chars"
392+ ) ;
393+ }
394+
395+ #[ test]
396+ fn test_create_synthetic_cookie_preserves_well_formed_id ( ) {
397+ let settings = create_test_settings ( ) ;
398+ // A well-formed ID should pass through the allowlist unmodified.
399+ let id = "abc123def0123456789abcdef0123456789abcdef0123456789abcdef01234567.xk92ab" ;
400+ let result = create_synthetic_cookie ( & settings, id) ;
401+ let value = result
402+ . strip_prefix ( & format ! ( "{}=" , COOKIE_SYNTHETIC_ID ) )
403+ . and_then ( |s| s. split_once ( ';' ) . map ( |( v, _) | v) )
404+ . expect ( "should have cookie value portion" ) ;
405+ assert_eq ! ( value, id, "should not modify a well-formed synthetic ID" ) ;
406+ }
407+
304408 #[ test]
305409 fn test_set_synthetic_cookie_rejects_semicolon ( ) {
306410 let settings = create_test_settings ( ) ;
@@ -379,6 +483,30 @@ mod tests {
379483 ) ;
380484 }
381485
486+ #[ test]
487+ fn test_expire_synthetic_cookie_matches_security_attributes ( ) {
488+ let settings = create_test_settings ( ) ;
489+ let mut response = fastly:: Response :: new ( ) ;
490+
491+ expire_synthetic_cookie ( & settings, & mut response) ;
492+
493+ let cookie_header = response
494+ . get_header ( header:: SET_COOKIE )
495+ . expect ( "Set-Cookie header should be present" ) ;
496+ let cookie_str = cookie_header
497+ . to_str ( )
498+ . expect ( "header should be valid UTF-8" ) ;
499+
500+ assert_eq ! (
501+ cookie_str,
502+ format!(
503+ "{}=; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0" ,
504+ COOKIE_SYNTHETIC_ID , settings. publisher. cookie_domain,
505+ ) ,
506+ "expiry cookie should retain the same security attributes as the live cookie"
507+ ) ;
508+ }
509+
382510 // ---------------------------------------------------------------
383511 // strip_cookies tests
384512 // ---------------------------------------------------------------
0 commit comments