Skip to content

Commit f771640

Browse files
Remove duplicate EC cookie helpers
1 parent c7450e4 commit f771640

2 files changed

Lines changed: 9 additions & 181 deletions

File tree

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

Lines changed: 1 addition & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,14 @@
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-
86
use cookie::{Cookie, CookieJar};
97
use edgezero_core::body::Body as EdgeBody;
108
use error_stack::{Report, ResultExt};
119
use http::header;
1210
use 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};
1813
use 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)]
314134
mod tests {
315135
use http::HeaderValue;

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1550,6 +1550,14 @@ impl Proxy {
15501550
route.prefix
15511551
);
15521552
}
1553+
1554+
if !route.prefix.is_empty() && route.prefix != "/" && !route.prefix.ends_with('/') {
1555+
log::warn!(
1556+
"proxy.asset_routes prefix `{}` does not end with `/`; matching uses raw string-prefix semantics, so this also matches paths such as `{}example`",
1557+
route.prefix,
1558+
route.prefix
1559+
);
1560+
}
15531561
}
15541562
}
15551563

0 commit comments

Comments
 (0)