Skip to content
Merged
143 changes: 122 additions & 21 deletions crates/trusted-server-core/src/cookies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,31 @@ pub fn forward_cookie_header(from: &Request, to: &mut Request, strip_consent: bo
}
}

/// Creates a synthetic ID cookie string.
/// Returns `true` if every byte in `value` is a valid RFC 6265 `cookie-octet`.
/// An empty string is always rejected.
///
/// RFC 6265 restricts cookie values to printable US-ASCII excluding whitespace,
/// double-quote, comma, semicolon, and backslash. Rejecting these characters
/// prevents header-injection attacks where a crafted value could append
/// spurious cookie attributes (e.g. `evil; Domain=.attacker.com`).
///
/// Non-ASCII characters (multi-byte UTF-8) are always rejected because their
/// byte values exceed `0x7E`.
#[must_use]
fn is_safe_cookie_value(value: &str) -> bool {
// RFC 6265 §4.1.1 cookie-octet:
// 0x21 — '!'
// 0x23–0x2B — '#' through '+' (excludes 0x22 DQUOTE)
// 0x2D–0x3A — '-' through ':' (excludes 0x2C comma)
// 0x3C–0x5B — '<' through '[' (excludes 0x3B semicolon)
// 0x5D–0x7E — ']' through '~' (excludes 0x5C backslash, 0x7F DEL)
// All control characters (0x00–0x20) and non-ASCII (0x80+) are also excluded.
!value.is_empty()
&& value
.bytes()
.all(|b| matches!(b, 0x21 | 0x23..=0x2B | 0x2D..=0x3A | 0x3C..=0x5B | 0x5D..=0x7E))
}

/// 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.
Expand Down Expand Up @@ -211,13 +234,24 @@ pub fn create_synthetic_cookie(settings: &Settings, synthetic_id: &str) -> Strin

/// Sets the synthetic ID cookie on the given response.
///
/// This helper abstracts the logic of creating the cookie string and appending
/// the Set-Cookie header to the response.
/// Validates `synthetic_id` against RFC 6265 `cookie-octet` rules before
/// interpolation. If the value contains unsafe characters (e.g. semicolons),
/// the cookie is not set and a warning is logged. This prevents an attacker
/// from injecting spurious cookie attributes via a controlled ID value.
///
/// `cookie_domain` comes from operator configuration and is considered trusted.
Comment thread
prk-Jr marked this conversation as resolved.
pub fn set_synthetic_cookie(
settings: &Settings,
response: &mut fastly::Response,
synthetic_id: &str,
) {
if !is_safe_cookie_value(synthetic_id) {
log::warn!(
Comment thread
prk-Jr marked this conversation as resolved.
"Rejecting synthetic_id for Set-Cookie: value of {} bytes contains characters illegal in a cookie value",
synthetic_id.len()
);
return;
}
response.append_header(
header::SET_COOKIE,
create_synthetic_cookie(settings, synthetic_id),
Expand Down Expand Up @@ -263,7 +297,7 @@ mod tests {
}

#[test]
fn test_parse_cookies_to_jar_emtpy() {
fn test_parse_cookies_to_jar_empty() {
let cookie_str = "";
let jar = parse_cookies_to_jar(cookie_str);

Expand Down Expand Up @@ -319,15 +353,24 @@ mod tests {
}

#[test]
fn test_create_synthetic_cookie() {
fn test_set_synthetic_cookie() {
let settings = create_test_settings();
let result = create_synthetic_cookie(&settings, "12345");
let mut response = fastly::Response::new();
set_synthetic_cookie(&settings, &mut response, "abc123.XyZ789");

let cookie_str = response
.get_header(header::SET_COOKIE)
.expect("Set-Cookie header should be present")
.to_str()
.expect("header should be valid UTF-8");

assert_eq!(
result,
cookie_str,
format!(
"{}=12345; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={}",
"{}=abc123.XyZ789; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={}",
COOKIE_SYNTHETIC_ID, settings.publisher.cookie_domain, COOKIE_MAX_AGE,
)
),
"Set-Cookie header should match expected format"
);
}

Expand Down Expand Up @@ -363,22 +406,80 @@ mod tests {
}

#[test]
fn test_set_synthetic_cookie() {
fn test_set_synthetic_cookie_rejects_semicolon() {
let settings = create_test_settings();
let mut response = fastly::Response::new();
set_synthetic_cookie(&settings, &mut response, "test-id-123");
set_synthetic_cookie(&settings, &mut response, "evil; Domain=.attacker.com");

let cookie_header = response
.get_header(header::SET_COOKIE)
.expect("Set-Cookie header should be present");
let cookie_str = cookie_header
.to_str()
.expect("header should be valid UTF-8");
assert!(
response.get_header(header::SET_COOKIE).is_none(),
"Set-Cookie should not be set when value contains a semicolon"
);
}

let expected = create_synthetic_cookie(&settings, "test-id-123");
assert_eq!(
cookie_str, expected,
"Set-Cookie header should match create_synthetic_cookie output"
#[test]
fn test_set_synthetic_cookie_rejects_crlf() {
let settings = create_test_settings();
let mut response = fastly::Response::new();
set_synthetic_cookie(&settings, &mut response, "evil\r\nX-Injected: header");

assert!(
response.get_header(header::SET_COOKIE).is_none(),
"Set-Cookie should not be set when value contains CRLF"
);
}

#[test]
fn test_set_synthetic_cookie_rejects_space() {
let settings = create_test_settings();
let mut response = fastly::Response::new();
set_synthetic_cookie(&settings, &mut response, "bad value");

assert!(
response.get_header(header::SET_COOKIE).is_none(),
"Set-Cookie should not be set when value contains whitespace"
);
}

#[test]
fn test_is_safe_cookie_value_rejects_empty_string() {
assert!(!is_safe_cookie_value(""), "should reject empty string");
}

#[test]
fn test_is_safe_cookie_value_accepts_valid_synthetic_id_characters() {
// Hex digits, dot separator, alphanumeric suffix — the full synthetic ID character set
assert!(
is_safe_cookie_value("abcdef0123456789.ABCDEFabcdef"),
"should accept hex digits, dots, and alphanumeric characters"
);
}

#[test]
fn test_is_safe_cookie_value_rejects_non_ascii() {
assert!(
!is_safe_cookie_value("valüe"),
"should reject non-ASCII UTF-8 characters"
);
}

#[test]
fn test_is_safe_cookie_value_rejects_illegal_characters() {
assert!(!is_safe_cookie_value("val;ue"), "should reject semicolon");
assert!(!is_safe_cookie_value("val,ue"), "should reject comma");
assert!(
!is_safe_cookie_value("val\"ue"),
"should reject double-quote"
);
assert!(!is_safe_cookie_value("val\\ue"), "should reject backslash");
assert!(!is_safe_cookie_value("val ue"), "should reject space");
assert!(
!is_safe_cookie_value("val\x00ue"),
"should reject null byte"
);
assert!(
!is_safe_cookie_value("val\x7fue"),
"should reject DEL character"
);
}

Expand Down
10 changes: 9 additions & 1 deletion crates/trusted-server-core/src/integrations/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,9 @@ impl IntegrationRegistry {
// Generate synthetic ID before consuming request
let synthetic_id_result = get_or_generate_synthetic_id(settings, &req);

// Set synthetic ID header on the request so integrations can read it
// Set synthetic ID header on the request so integrations can read it.
// Header injection: Fastly's HeaderValue API rejects values containing \r, \n, or \0,
// so a crafted synthetic_id cannot inject additional request headers.
if let Ok(ref synthetic_id) = synthetic_id_result {
req.set_header(HEADER_X_SYNTHETIC_ID, synthetic_id.as_str());
}
Expand All @@ -669,7 +671,13 @@ impl IntegrationRegistry {
if let Ok(ref mut response) = result {
match synthetic_id_result {
Ok(ref synthetic_id) => {
// Response-header injection: Fastly's HeaderValue API rejects values
// containing \r, \n, or \0, so a crafted synthetic_id cannot inject
// additional response headers.
response.set_header(HEADER_X_SYNTHETIC_ID, synthetic_id.as_str());
// Cookie is intentionally not set when synthetic_id contains RFC 6265-illegal
// characters (e.g. a crafted x-synthetic-id header value). The response header
// is still emitted; only cookie persistence is skipped.
set_synthetic_cookie(settings, response, synthetic_id.as_str());
}
Err(ref err) => {
Expand Down
4 changes: 4 additions & 0 deletions crates/trusted-server-core/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,11 @@ pub fn handle_publisher_request(
// - Consent absent + existing cookie → revoke (expire cookie + delete KV entry).
// - Consent absent + no cookie → do nothing.
if ssc_allowed {
// Fastly's HeaderValue API rejects \r, \n, and \0, so the synthetic ID
// cannot inject additional response headers.
response.set_header(HEADER_X_SYNTHETIC_ID, synthetic_id.as_str());
// Cookie persistence is skipped if the synthetic ID contains RFC 6265-illegal
// characters. The header is still emitted when consent allows it.
set_synthetic_cookie(settings, &mut response, synthetic_id.as_str());
} else if let Some(cookie_synthetic_id) = existing_ssc_cookie.as_deref() {
log::info!(
Expand Down
Loading