33//! This module provides functionality for parsing, creating, stripping, and forwarding cookies
44//! used in the trusted server system.
55
6- use std:: borrow:: Cow ;
7-
86use cookie:: { Cookie , CookieJar } ;
97use edgezero_core:: body:: Body as EdgeBody ;
108use error_stack:: { Report , ResultExt } ;
119use http:: header;
1210use http:: Request ;
13- use http:: Response ;
1411
15- use crate :: constants:: {
16- COOKIE_EUCONSENT_V2 , COOKIE_GPP , COOKIE_GPP_SID , COOKIE_TS_EC , COOKIE_US_PRIVACY ,
17- } ;
12+ use crate :: constants:: { COOKIE_EUCONSENT_V2 , COOKIE_GPP , COOKIE_GPP_SID , COOKIE_US_PRIVACY } ;
1813use crate :: error:: TrustedServerError ;
19- use crate :: settings:: Settings ;
2014
2115/// Cookie names carrying privacy consent signals.
2216///
@@ -30,50 +24,6 @@ pub const CONSENT_COOKIE_NAMES: &[&str] = &[
3024 COOKIE_US_PRIVACY ,
3125] ;
3226
33- const COOKIE_MAX_AGE : i32 = 365 * 24 * 60 * 60 ;
34-
35- fn is_allowed_ec_id_char ( c : char ) -> bool {
36- c. is_ascii_alphanumeric ( ) || matches ! ( c, '.' | '-' | '_' )
37- }
38-
39- // Outbound allowlist for cookie sanitization: permits [a-zA-Z0-9._-] as a
40- // defense-in-depth backstop when setting the Set-Cookie header. This is
41- // intentionally broader than the inbound format validator
42- // (`synthetic::is_valid_synthetic_id`), which enforces the exact
43- // `<64-hex>.<6-alphanumeric>` structure and is used to reject untrusted
44- // request values before they enter the system.
45- #[ must_use]
46- pub ( crate ) fn ec_id_has_only_allowed_chars ( ec_id : & str ) -> bool {
47- ec_id. chars ( ) . all ( is_allowed_ec_id_char)
48- }
49-
50- fn sanitize_ec_id_for_cookie ( ec_id : & str ) -> Cow < ' _ , str > {
51- if ec_id_has_only_allowed_chars ( ec_id) {
52- return Cow :: Borrowed ( ec_id) ;
53- }
54-
55- let safe_id = ec_id
56- . chars ( )
57- . filter ( |c| is_allowed_ec_id_char ( * c) )
58- . collect :: < String > ( ) ;
59-
60- log:: warn!(
61- "Stripped disallowed characters from EC ID before setting cookie (len {} -> {}); \
62- callers should reject invalid request IDs before cookie creation",
63- ec_id. len( ) ,
64- safe_id. len( ) ,
65- ) ;
66-
67- Cow :: Owned ( safe_id)
68- }
69-
70- pub ( crate ) fn ec_cookie_attributes ( settings : & Settings , max_age : i32 ) -> String {
71- format ! (
72- "Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={max_age}" ,
73- settings. publisher. cookie_domain,
74- )
75- }
76-
7727/// Parses a cookie string into a [`CookieJar`].
7828///
7929/// Returns an empty jar if the cookie string is unparseable.
@@ -180,136 +130,6 @@ pub fn forward_cookie_header(
180130 }
181131}
182132
183- /// Returns `true` if every byte in `value` is a valid RFC 6265 `cookie-octet`.
184- /// An empty string is always rejected.
185- ///
186- /// RFC 6265 restricts cookie values to printable US-ASCII excluding whitespace,
187- /// double-quote, comma, semicolon, and backslash. Rejecting these characters
188- /// prevents header-injection attacks where a crafted value could append
189- /// spurious cookie attributes (e.g. `evil; Domain=.attacker.com`).
190- ///
191- /// Non-ASCII characters (multi-byte UTF-8) are always rejected because their
192- /// byte values exceed `0x7E`.
193- #[ must_use]
194- pub ( crate ) fn ec_cookie_value_is_safe ( value : & str ) -> bool {
195- // RFC 6265 §4.1.1 cookie-octet:
196- // 0x21 — '!'
197- // 0x23–0x2B — '#' through '+' (excludes 0x22 DQUOTE)
198- // 0x2D–0x3A — '-' through ':' (excludes 0x2C comma)
199- // 0x3C–0x5B — '<' through '[' (excludes 0x3B semicolon)
200- // 0x5D–0x7E — ']' through '~' (excludes 0x5C backslash, 0x7F DEL)
201- // All control characters (0x00–0x20) and non-ASCII (0x80+) are also excluded.
202- !value. is_empty ( )
203- && value
204- . bytes ( )
205- . all ( |b| matches ! ( b, 0x21 | 0x23 ..=0x2B | 0x2D ..=0x3A | 0x3C ..=0x5B | 0x5D ..=0x7E ) )
206- }
207-
208- /// Generates a `Set-Cookie` header value with the following security attributes:
209- /// - `Secure`: transmitted over HTTPS only.
210- /// - `HttpOnly`: inaccessible to JavaScript (`document.cookie`), blocking XSS exfiltration.
211- /// Safe to set because integrations receive the EC ID via the `x-ts-ec`
212- /// response header instead of reading it from the cookie directly.
213- /// - `SameSite=Lax`: sent on same-site requests and top-level cross-site navigations.
214- /// `Strict` is intentionally avoided — it would suppress the cookie on the first
215- /// request when a user arrives from an external page, breaking first-visit attribution.
216- /// - `Max-Age`: 1 year retention.
217- ///
218- /// The `ec_id` is sanitized via an allowlist before embedding in the cookie value.
219- /// Only ASCII alphanumeric characters and `.`, `-`, `_` are permitted — matching the
220- /// known EC ID format (`{64-char-hex}.{6-char-alphanumeric}`). Request-sourced IDs
221- /// with disallowed characters are rejected earlier in [`crate::ec::get_ec_id`];
222- /// this sanitization remains as a defense-in-depth backstop for unexpected callers.
223- ///
224- /// The `cookie_domain` is validated at config load time via [`validator::Validate`] on
225- /// [`crate::settings::Publisher`]; bad config fails at startup, not per-request.
226- ///
227- /// # Examples
228- ///
229- /// ```no_run
230- /// # use trusted_server_core::cookies::create_ec_cookie;
231- /// # use trusted_server_core::settings::Settings;
232- /// // `settings` is loaded at startup via `Settings::from_toml_and_env`.
233- /// # fn example(settings: &Settings) {
234- /// let cookie = create_ec_cookie(settings, "abc123.xk92ab");
235- /// assert!(cookie.contains("HttpOnly"));
236- /// assert!(cookie.contains("Secure"));
237- /// # }
238- /// ```
239- #[ must_use]
240- pub fn create_ec_cookie ( settings : & Settings , ec_id : & str ) -> String {
241- let safe_id = sanitize_ec_id_for_cookie ( ec_id) ;
242-
243- format ! (
244- "{}={}; {}" ,
245- COOKIE_TS_EC ,
246- safe_id,
247- ec_cookie_attributes( settings, COOKIE_MAX_AGE ) ,
248- )
249- }
250-
251- #[ must_use]
252- pub ( crate ) fn try_build_ec_cookie_value ( settings : & Settings , ec_id : & str ) -> Option < String > {
253- if !ec_cookie_value_is_safe ( ec_id) {
254- log:: warn!(
255- "Rejecting EC ID for Set-Cookie: value of {} bytes contains characters illegal in a cookie value" ,
256- ec_id. len( )
257- ) ;
258- return None ;
259- }
260-
261- Some ( create_ec_cookie ( settings, ec_id) )
262- }
263-
264- /// Sets the EC ID cookie on the given response.
265- ///
266- /// Validates `ec_id` against RFC 6265 `cookie-octet` rules before
267- /// interpolation. If the value contains unsafe characters (e.g. semicolons),
268- /// the cookie is not set and a warning is logged. This prevents an attacker
269- /// from injecting spurious cookie attributes via a controlled ID value.
270- ///
271- /// `cookie_domain` comes from operator configuration and is considered trusted.
272- ///
273- /// # Panics
274- ///
275- /// Does not panic in practice — the cookie value is validated by
276- /// `ec_cookie_value_is_safe` (early return if invalid) before
277- /// [`http::HeaderValue::from_str`] is called, so the expect is unreachable.
278- /// Listed here only because clippy cannot prove it statically.
279- pub fn set_ec_cookie ( settings : & Settings , response : & mut Response < EdgeBody > , ec_id : & str ) {
280- let Some ( cookie) = try_build_ec_cookie_value ( settings, ec_id) else {
281- return ;
282- } ;
283-
284- response. headers_mut ( ) . append (
285- header:: SET_COOKIE ,
286- http:: HeaderValue :: from_str ( & cookie) . expect ( "should build Set-Cookie header value" ) ,
287- ) ;
288- }
289-
290- /// Expires the EC cookie by setting `Max-Age=0`.
291- ///
292- /// Used when a user revokes consent — the browser will delete the cookie
293- /// on receipt of this header.
294- ///
295- /// # Panics
296- ///
297- /// Does not panic in practice — the formatted value contains only ASCII
298- /// printable characters (constant name, validated domain, static attributes),
299- /// so [`http::HeaderValue::from_str`] always succeeds. Listed here only
300- /// because clippy cannot prove it statically.
301- pub fn expire_ec_cookie ( settings : & Settings , response : & mut Response < EdgeBody > ) {
302- response. headers_mut ( ) . append (
303- header:: SET_COOKIE ,
304- http:: HeaderValue :: from_str ( & format ! (
305- "{}=; {}" ,
306- COOKIE_TS_EC ,
307- ec_cookie_attributes( settings, 0 ) ,
308- ) )
309- . expect ( "should build expiry Set-Cookie header value" ) ,
310- ) ;
311- }
312-
313133#[ cfg( test) ]
314134mod tests {
315135 use http:: HeaderValue ;
0 commit comments