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.
@@ -126,27 +164,94 @@ pub fn forward_cookie_header(from: &Request, to: &mut Request, strip_consent: bo
126164 }
127165}
128166
129- /// Creates a synthetic ID cookie string.
167+ /// Returns `true` if every byte in `value` is a valid RFC 6265 `cookie-octet`.
168+ /// An empty string is always rejected.
169+ ///
170+ /// RFC 6265 restricts cookie values to printable US-ASCII excluding whitespace,
171+ /// double-quote, comma, semicolon, and backslash. Rejecting these characters
172+ /// prevents header-injection attacks where a crafted value could append
173+ /// spurious cookie attributes (e.g. `evil; Domain=.attacker.com`).
174+ ///
175+ /// Non-ASCII characters (multi-byte UTF-8) are always rejected because their
176+ /// byte values exceed `0x7E`.
177+ #[ must_use]
178+ fn is_safe_cookie_value ( value : & str ) -> bool {
179+ // RFC 6265 §4.1.1 cookie-octet:
180+ // 0x21 — '!'
181+ // 0x23–0x2B — '#' through '+' (excludes 0x22 DQUOTE)
182+ // 0x2D–0x3A — '-' through ':' (excludes 0x2C comma)
183+ // 0x3C–0x5B — '<' through '[' (excludes 0x3B semicolon)
184+ // 0x5D–0x7E — ']' through '~' (excludes 0x5C backslash, 0x7F DEL)
185+ // All control characters (0x00–0x20) and non-ASCII (0x80+) are also excluded.
186+ !value. is_empty ( )
187+ && value
188+ . bytes ( )
189+ . all ( |b| matches ! ( b, 0x21 | 0x23 ..=0x2B | 0x2D ..=0x3A | 0x3C ..=0x5B | 0x5D ..=0x7E ) )
190+ }
191+
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.
130201///
131- /// Generates a properly formatted cookie with security attributes
132- /// for storing the synthetic ID.
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+ /// ```
133223#[ must_use]
134224pub fn create_synthetic_cookie ( settings : & Settings , synthetic_id : & str ) -> String {
225+ let safe_id = sanitize_synthetic_id_for_cookie ( synthetic_id) ;
226+
135227 format ! (
136- "{}={}; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age={}" ,
137- 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 ) ,
138232 )
139233}
140234
141235/// Sets the synthetic ID cookie on the given response.
142236///
143- /// This helper abstracts the logic of creating the cookie string and appending
144- /// the Set-Cookie header to the response.
237+ /// Validates `synthetic_id` against RFC 6265 `cookie-octet` rules before
238+ /// interpolation. If the value contains unsafe characters (e.g. semicolons),
239+ /// the cookie is not set and a warning is logged. This prevents an attacker
240+ /// from injecting spurious cookie attributes via a controlled ID value.
241+ ///
242+ /// `cookie_domain` comes from operator configuration and is considered trusted.
145243pub fn set_synthetic_cookie (
146244 settings : & Settings ,
147245 response : & mut fastly:: Response ,
148246 synthetic_id : & str ,
149247) {
248+ if !is_safe_cookie_value ( synthetic_id) {
249+ log:: warn!(
250+ "Rejecting synthetic_id for Set-Cookie: value of {} bytes contains characters illegal in a cookie value" ,
251+ synthetic_id. len( )
252+ ) ;
253+ return ;
254+ }
150255 response. append_header (
151256 header:: SET_COOKIE ,
152257 create_synthetic_cookie ( settings, synthetic_id) ,
@@ -159,8 +264,9 @@ pub fn set_synthetic_cookie(
159264/// on receipt of this header.
160265pub fn expire_synthetic_cookie ( settings : & Settings , response : & mut fastly:: Response ) {
161266 let cookie = format ! (
162- "{}=; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age=0" ,
163- COOKIE_SYNTHETIC_ID , settings. publisher. cookie_domain,
267+ "{}=; {}" ,
268+ COOKIE_SYNTHETIC_ID ,
269+ synthetic_cookie_attributes( settings, 0 ) ,
164270 ) ;
165271 response. append_header ( header:: SET_COOKIE , cookie) ;
166272}
@@ -191,7 +297,7 @@ mod tests {
191297 }
192298
193299 #[ test]
194- fn test_parse_cookies_to_jar_emtpy ( ) {
300+ fn test_parse_cookies_to_jar_empty ( ) {
195301 let cookie_str = "" ;
196302 let jar = parse_cookies_to_jar ( cookie_str) ;
197303
@@ -247,23 +353,142 @@ mod tests {
247353 }
248354
249355 #[ test]
250- fn test_create_synthetic_cookie ( ) {
356+ fn test_set_synthetic_cookie ( ) {
251357 let settings = create_test_settings ( ) ;
252- let result = create_synthetic_cookie ( & settings, "12345" ) ;
358+ let mut response = fastly:: Response :: new ( ) ;
359+ set_synthetic_cookie ( & settings, & mut response, "abc123.XyZ789" ) ;
360+
361+ let cookie_str = response
362+ . get_header ( header:: SET_COOKIE )
363+ . expect ( "Set-Cookie header should be present" )
364+ . to_str ( )
365+ . expect ( "header should be valid UTF-8" ) ;
366+
253367 assert_eq ! (
254- result ,
368+ cookie_str ,
255369 format!(
256- "{}=12345 ; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age={}" ,
370+ "{}=abc123.XyZ789 ; Domain={}; Path=/; Secure; HttpOnly ; SameSite=Lax; Max-Age={}" ,
257371 COOKIE_SYNTHETIC_ID , settings. publisher. cookie_domain, COOKIE_MAX_AGE ,
258- )
372+ ) ,
373+ "Set-Cookie header should match expected format"
259374 ) ;
260375 }
261376
262377 #[ test]
263- fn test_set_synthetic_cookie ( ) {
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+
408+ #[ test]
409+ fn test_set_synthetic_cookie_rejects_semicolon ( ) {
410+ let settings = create_test_settings ( ) ;
411+ let mut response = fastly:: Response :: new ( ) ;
412+ set_synthetic_cookie ( & settings, & mut response, "evil; Domain=.attacker.com" ) ;
413+
414+ assert ! (
415+ response. get_header( header:: SET_COOKIE ) . is_none( ) ,
416+ "Set-Cookie should not be set when value contains a semicolon"
417+ ) ;
418+ }
419+
420+ #[ test]
421+ fn test_set_synthetic_cookie_rejects_crlf ( ) {
422+ let settings = create_test_settings ( ) ;
423+ let mut response = fastly:: Response :: new ( ) ;
424+ set_synthetic_cookie ( & settings, & mut response, "evil\r \n X-Injected: header" ) ;
425+
426+ assert ! (
427+ response. get_header( header:: SET_COOKIE ) . is_none( ) ,
428+ "Set-Cookie should not be set when value contains CRLF"
429+ ) ;
430+ }
431+
432+ #[ test]
433+ fn test_set_synthetic_cookie_rejects_space ( ) {
434+ let settings = create_test_settings ( ) ;
435+ let mut response = fastly:: Response :: new ( ) ;
436+ set_synthetic_cookie ( & settings, & mut response, "bad value" ) ;
437+
438+ assert ! (
439+ response. get_header( header:: SET_COOKIE ) . is_none( ) ,
440+ "Set-Cookie should not be set when value contains whitespace"
441+ ) ;
442+ }
443+
444+ #[ test]
445+ fn test_is_safe_cookie_value_rejects_empty_string ( ) {
446+ assert ! ( !is_safe_cookie_value( "" ) , "should reject empty string" ) ;
447+ }
448+
449+ #[ test]
450+ fn test_is_safe_cookie_value_accepts_valid_synthetic_id_characters ( ) {
451+ // Hex digits, dot separator, alphanumeric suffix — the full synthetic ID character set
452+ assert ! (
453+ is_safe_cookie_value( "abcdef0123456789.ABCDEFabcdef" ) ,
454+ "should accept hex digits, dots, and alphanumeric characters"
455+ ) ;
456+ }
457+
458+ #[ test]
459+ fn test_is_safe_cookie_value_rejects_non_ascii ( ) {
460+ assert ! (
461+ !is_safe_cookie_value( "valüe" ) ,
462+ "should reject non-ASCII UTF-8 characters"
463+ ) ;
464+ }
465+
466+ #[ test]
467+ fn test_is_safe_cookie_value_rejects_illegal_characters ( ) {
468+ assert ! ( !is_safe_cookie_value( "val;ue" ) , "should reject semicolon" ) ;
469+ assert ! ( !is_safe_cookie_value( "val,ue" ) , "should reject comma" ) ;
470+ assert ! (
471+ !is_safe_cookie_value( "val\" ue" ) ,
472+ "should reject double-quote"
473+ ) ;
474+ assert ! ( !is_safe_cookie_value( "val\\ ue" ) , "should reject backslash" ) ;
475+ assert ! ( !is_safe_cookie_value( "val ue" ) , "should reject space" ) ;
476+ assert ! (
477+ !is_safe_cookie_value( "val\x00 ue" ) ,
478+ "should reject null byte"
479+ ) ;
480+ assert ! (
481+ !is_safe_cookie_value( "val\x7f ue" ) ,
482+ "should reject DEL character"
483+ ) ;
484+ }
485+
486+ #[ test]
487+ fn test_expire_synthetic_cookie_matches_security_attributes ( ) {
264488 let settings = create_test_settings ( ) ;
265489 let mut response = fastly:: Response :: new ( ) ;
266- set_synthetic_cookie ( & settings, & mut response, "test-id-123" ) ;
490+
491+ expire_synthetic_cookie ( & settings, & mut response) ;
267492
268493 let cookie_header = response
269494 . get_header ( header:: SET_COOKIE )
@@ -272,10 +497,13 @@ mod tests {
272497 . to_str ( )
273498 . expect ( "header should be valid UTF-8" ) ;
274499
275- let expected = create_synthetic_cookie ( & settings, "test-id-123" ) ;
276500 assert_eq ! (
277- cookie_str, expected,
278- "Set-Cookie header should match create_synthetic_cookie output"
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"
279507 ) ;
280508 }
281509
0 commit comments