Skip to content

Commit aa406fe

Browse files
Merge branch 'main' into fix/prebid-host-scheme-trust
2 parents e05e592 + 8da4eed commit aa406fe

5 files changed

Lines changed: 421 additions & 33 deletions

File tree

crates/trusted-server-core/src/cookies.rs

Lines changed: 248 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
//! This module provides functionality for parsing and creating cookies
44
//! used in the trusted server system.
55
6+
use std::borrow::Cow;
7+
68
use cookie::{Cookie, CookieJar};
79
use error_stack::{Report, ResultExt};
810
use fastly::http::header;
@@ -28,6 +30,42 @@ pub const CONSENT_COOKIE_NAMES: &[&str] = &[
2830

2931
const 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]
134224
pub 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.
145243
pub 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.
160265
pub 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\nfoo=bar\0baz");
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\nX-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\x00ue"),
478+
"should reject null byte"
479+
);
480+
assert!(
481+
!is_safe_cookie_value("val\x7fue"),
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

Comments
 (0)