Skip to content

Commit 9c6ce15

Browse files
committed
Merge branch 'main' into sanitize-set-cookie-value
2 parents 6fbbac1 + ace196a commit 9c6ce15

File tree

4 files changed

+295
-19
lines changed

4 files changed

+295
-19
lines changed

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

Lines changed: 135 additions & 7 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.
@@ -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.
193265
pub 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\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+
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
// ---------------------------------------------------------------

crates/trusted-server-core/src/integrations/registry.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,6 +1303,57 @@ mod tests {
13031303
);
13041304
}
13051305

1306+
#[test]
1307+
fn handle_proxy_replaces_invalid_request_header_with_matching_response_cookie() {
1308+
let settings = create_test_settings();
1309+
let routes = vec![(
1310+
Method::GET,
1311+
"/integrations/test/synthetic",
1312+
(
1313+
Arc::new(SyntheticIdTestProxy) as Arc<dyn IntegrationProxy>,
1314+
"synthetic_id_test",
1315+
),
1316+
)];
1317+
let registry = IntegrationRegistry::from_routes(routes);
1318+
1319+
let mut req = Request::get("https://test-publisher.com/integrations/test/synthetic");
1320+
req.set_header(HEADER_X_SYNTHETIC_ID, "evil;injected");
1321+
1322+
let result = futures::executor::block_on(registry.handle_proxy(
1323+
&Method::GET,
1324+
"/integrations/test/synthetic",
1325+
&settings,
1326+
req,
1327+
))
1328+
.expect("should handle proxy request");
1329+
1330+
let response = result.expect("handler should succeed");
1331+
let response_header = response
1332+
.get_header(HEADER_X_SYNTHETIC_ID)
1333+
.expect("response should have x-synthetic-id header")
1334+
.to_str()
1335+
.expect("header should be valid UTF-8")
1336+
.to_string();
1337+
let cookie_header = response
1338+
.get_header(header::SET_COOKIE)
1339+
.expect("response should have Set-Cookie header")
1340+
.to_str()
1341+
.expect("header should be valid UTF-8");
1342+
let cookie_value = cookie_header
1343+
.strip_prefix(&format!("{}=", COOKIE_SYNTHETIC_ID))
1344+
.and_then(|s| s.split_once(';').map(|(value, _)| value))
1345+
.expect("should contain the synthetic_id cookie value");
1346+
1347+
assert_ne!(
1348+
response_header, "evil;injected",
1349+
"should not reflect the tampered request header"
1350+
);
1351+
assert_eq!(
1352+
response_header, cookie_value,
1353+
"response header and cookie should carry the same effective synthetic ID"
1354+
);
1355+
}
1356+
13061357
#[test]
13071358
fn handle_proxy_always_sets_cookie() {
13081359
let settings = create_test_settings();

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub const ENVIRONMENT_VARIABLE_SEPARATOR: &str = "__";
2020
#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)]
2121
pub struct Publisher {
2222
pub domain: String,
23+
#[validate(custom(function = validate_cookie_domain))]
2324
pub cookie_domain: String,
2425
#[validate(custom(function = validate_no_trailing_slash))]
2526
pub origin_url: String,
@@ -512,6 +513,18 @@ impl Settings {
512513
}
513514
}
514515

516+
fn validate_cookie_domain(value: &str) -> Result<(), ValidationError> {
517+
// `=` is excluded: it only has special meaning in the name=value pair,
518+
// not within the Domain attribute value.
519+
if value.contains([';', '\n', '\r']) {
520+
let mut err = ValidationError::new("cookie_metacharacters");
521+
err.message =
522+
Some("cookie_domain must not contain cookie metacharacters (;, \\n, \\r)".into());
523+
return Err(err);
524+
}
525+
Ok(())
526+
}
527+
515528
fn validate_no_trailing_slash(value: &str) -> Result<(), ValidationError> {
516529
if value.ends_with('/') {
517530
let mut err = ValidationError::new("trailing_slash");
@@ -1371,6 +1384,32 @@ mod tests {
13711384
);
13721385
}
13731386

1387+
#[test]
1388+
fn test_publisher_rejects_cookie_domain_with_metacharacters() {
1389+
for bad_domain in [
1390+
"evil.com;\nSet-Cookie: bad=1",
1391+
"evil.com\r\nX-Injected: yes",
1392+
"evil.com;path=/",
1393+
] {
1394+
let mut settings = create_test_settings();
1395+
settings.publisher.cookie_domain = bad_domain.to_string();
1396+
assert!(
1397+
settings.validate().is_err(),
1398+
"should reject cookie_domain containing metacharacters: {bad_domain:?}"
1399+
);
1400+
}
1401+
}
1402+
1403+
#[test]
1404+
fn test_publisher_accepts_valid_cookie_domain() {
1405+
let mut settings = create_test_settings();
1406+
settings.publisher.cookie_domain = ".example.com".to_string();
1407+
assert!(
1408+
settings.validate().is_ok(),
1409+
"should accept a valid cookie_domain"
1410+
);
1411+
}
1412+
13741413
/// Helper that returns a settings TOML string WITHOUT any admin handler,
13751414
/// for tests that need to verify uncovered-admin-endpoint behaviour.
13761415
fn settings_str_without_admin_handler() -> String {

0 commit comments

Comments
 (0)