1use std::io::Read;
4use std::path::Path;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use url::Url;
8
9use crate::error::{Error, Result};
10
11const MAX_FILE_BYTES: u64 = 4 << 20;
12const MAX_COOKIES: usize = 3000;
13
14#[derive(Clone, PartialEq, Eq)]
16pub struct CookieSpec {
17 name: String,
18 value: String,
19 domain: String,
20 path: String,
21 secure: bool,
22 http_only: bool,
23 include_subdomains: bool,
24}
25
26impl std::fmt::Debug for CookieSpec {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 f.debug_struct("CookieSpec")
29 .field("name", &self.name)
30 .field("value", &"<redacted>")
31 .field("domain", &self.domain)
32 .field("path", &self.path)
33 .field("secure", &self.secure)
34 .field("http_only", &self.http_only)
35 .field("include_subdomains", &self.include_subdomains)
36 .finish()
37 }
38}
39
40pub fn load_cookies(path: impl AsRef<Path>) -> Result<Vec<CookieSpec>> {
42 let path = path.as_ref();
43 let fail = |reason: String| Error::Cookies {
44 path: path.display().to_string(),
45 reason,
46 };
47 let mut text = String::new();
48 std::fs::File::open(path)
49 .and_then(|f| f.take(MAX_FILE_BYTES + 1).read_to_string(&mut text))
50 .map_err(|e| fail(e.to_string()))?;
51 if text.len() as u64 > MAX_FILE_BYTES {
52 return Err(fail(format!("file exceeds {MAX_FILE_BYTES} bytes")));
53 }
54 parse_cookies(&text).map_err(|e| fail(e.to_string()))
55}
56
57#[derive(Debug, thiserror::Error)]
58enum ParseError {
59 #[error("line {line}: expected 7 tab-separated fields, found {found}")]
60 FieldCount { line: usize, found: usize },
61 #[error("line {line}: illegal character in cookie name or value")]
62 IllegalChar { line: usize },
63 #[error("too many cookies (max {max})")]
64 TooMany { max: usize },
65}
66
67fn parse_cookies(text: &str) -> std::result::Result<Vec<CookieSpec>, ParseError> {
68 let now = now_unix();
69 let mut out = Vec::new();
70 for (i, raw) in text.lines().enumerate() {
71 let line = i + 1;
72 let (http_only, rest) = match raw.strip_prefix("#HttpOnly_") {
73 Some(rest) => (true, rest),
74 None if raw.trim().is_empty() || raw.starts_with('#') => continue,
75 None => (false, raw),
76 };
77 let fields: Vec<&str> = rest.split('\t').collect();
78 let [domain, include_sub, cpath, secure, expires, name, value] = fields[..] else {
79 return Err(ParseError::FieldCount {
80 line,
81 found: fields.len(),
82 });
83 };
84 if has_control(name) || name.contains([';', '=']) || has_control(value) || value.contains(';') {
85 return Err(ParseError::IllegalChar { line });
86 }
87 if expires
88 .split('.')
89 .next()
90 .and_then(|s| s.trim().parse::<i64>().ok())
91 .is_some_and(|e| e > 0 && e <= now)
92 {
93 continue;
94 }
95 if out.len() >= MAX_COOKIES {
96 return Err(ParseError::TooMany { max: MAX_COOKIES });
97 }
98 out.push(CookieSpec {
99 name: name.to_owned(),
100 value: value.to_owned(),
101 domain: domain.to_owned(),
102 path: if cpath.is_empty() { "/" } else { cpath }.to_owned(),
103 secure: secure.eq_ignore_ascii_case("TRUE"),
104 http_only,
105 include_subdomains: include_sub.eq_ignore_ascii_case("TRUE"),
106 });
107 }
108 Ok(out)
109}
110
111pub(crate) fn seed(servo: &servo::Servo, target: &Url, specs: &[CookieSpec]) {
113 if specs.is_empty() {
114 return;
115 }
116 let policy = crate::bridge::engine_policy();
117 let manager = servo.site_data_manager();
118 for spec in specs {
119 if let Some((url, cookie)) = cookie_for(target, spec, policy) {
120 manager.set_cookie_for_url(url, cookie);
121 }
122 }
123}
124
125fn cookie_for(
128 target: &Url,
129 spec: &CookieSpec,
130 policy: crate::net::NetworkPolicy,
131) -> Option<(Url, cookie::Cookie<'static>)> {
132 let host = spec.domain.trim_start_matches('.');
133 let scheme = if spec.secure { "https" } else { "http" };
134 let url = Url::parse(&format!("{scheme}://{host}{}", spec.path)).ok()?;
135 if crate::net::validate_url_with_policy(url.as_str(), policy).is_err() || !crate::scope::is_same_site(target, &url)
136 {
137 tracing::warn!(domain = %host, "skipped out-of-scope or disallowed cookie");
138 return None;
139 }
140 let mut builder = cookie::Cookie::build((spec.name.clone(), spec.value.clone()))
141 .path(spec.path.clone())
142 .secure(spec.secure)
143 .http_only(spec.http_only);
144 if spec.domain.starts_with('.') || spec.include_subdomains {
145 builder = builder.domain(url.host_str().unwrap_or(host).to_owned());
146 }
147 Some((url, builder.build()))
148}
149
150fn has_control(s: &str) -> bool {
151 s.bytes().any(|b| b < 0x20 || b == 0x7f)
152}
153
154fn now_unix() -> i64 {
155 let secs = SystemTime::now()
156 .duration_since(UNIX_EPOCH)
157 .unwrap_or_default()
158 .as_secs();
159 i64::try_from(secs).unwrap_or(i64::MAX)
160}
161
162#[cfg(test)]
163mod tests {
164 use std::io::Write as _;
165
166 use super::*;
167 use crate::net::NetworkPolicy;
168
169 fn spec(domain: &str, secure: bool) -> CookieSpec {
170 CookieSpec {
171 name: "n".into(),
172 value: "v".into(),
173 domain: domain.into(),
174 path: "/".into(),
175 secure,
176 http_only: false,
177 include_subdomains: false,
178 }
179 }
180
181 #[test]
182 fn parses_standard_line() {
183 let specs = parse_cookies(".example.com\tTRUE\t/\tTRUE\t0\tsid\tabc123\n").unwrap();
184 assert_eq!(specs.len(), 1);
185 let c = &specs[0];
186 assert_eq!((c.name.as_str(), c.value.as_str()), ("sid", "abc123"));
187 assert_eq!(c.domain, ".example.com");
188 assert!(c.secure && c.include_subdomains && !c.http_only);
189 }
190
191 #[test]
192 fn handles_httponly_prefix_and_comments() {
193 let specs = parse_cookies("# a comment\n\n#HttpOnly_app.example.com\tFALSE\t/\tFALSE\t0\ttok\tv\n").unwrap();
194 assert_eq!(specs.len(), 1);
195 assert!(specs[0].http_only);
196 assert_eq!(specs[0].domain, "app.example.com");
197 }
198
199 #[test]
200 fn drops_expired_keeps_session() {
201 let specs = parse_cookies(
203 "x.com\tFALSE\t/\tFALSE\t100\told\tv\nx.com\tFALSE\t/\tFALSE\t1700000000.5\tfloat\tv\nx.com\tFALSE\t/\tFALSE\t0\tlive\tv\n",
204 )
205 .unwrap();
206 assert_eq!(specs.len(), 1);
207 assert_eq!(specs[0].name, "live");
208 }
209
210 #[test]
211 fn empty_path_defaults_to_root() {
212 assert_eq!(parse_cookies("x.com\tFALSE\t\tFALSE\t0\tn\tv\n").unwrap()[0].path, "/");
213 }
214
215 #[test]
216 fn rejects_wrong_field_count() {
217 assert!(parse_cookies("x.com\tFALSE\t/\tFALSE\t0\tn\n").is_err());
218 }
219
220 #[test]
221 fn rejects_illegal_chars() {
222 assert!(parse_cookies("x.com\tFALSE\t/\tFALSE\t0\tn\ta;b\n").is_err());
223 assert!(parse_cookies("x.com\tFALSE\t/\tFALSE\t0\tn=x\tv\n").is_err());
224 assert!(parse_cookies("x.com\tFALSE\t/\tFALSE\t0\tn\tYWJj==\n").is_ok());
226 let err = parse_cookies("x.com\tFALSE\t/\tFALSE\t0\tn\tval\rinjected\n")
228 .unwrap_err()
229 .to_string();
230 assert!(err.contains("illegal character") && !err.contains("injected"));
231 }
232
233 #[test]
234 fn load_reads_and_parses_file() {
235 let mut f = tempfile::NamedTempFile::new().unwrap();
236 f.write_all(b".example.com\tTRUE\t/\tFALSE\t0\tn\tv\n").unwrap();
237 let specs = load_cookies(f.path()).unwrap();
238 assert_eq!(
239 specs,
240 vec![CookieSpec {
241 name: "n".to_owned(),
242 value: "v".to_owned(),
243 domain: ".example.com".to_owned(),
244 path: "/".to_owned(),
245 secure: false,
246 http_only: false,
247 include_subdomains: true,
248 }]
249 );
250 }
251
252 #[test]
253 fn missing_file_reports_path() {
254 let err = load_cookies("/no/such/cookies.txt").unwrap_err();
255 assert!(matches!(err, Error::Cookies { .. }));
256 }
257
258 #[test]
259 fn debug_redacts_value() {
260 let mut c = spec("example.com", false);
261 c.value = "SUPERSECRET".into();
262 let dbg = format!("{c:?}");
263 assert!(dbg.contains("<redacted>") && !dbg.contains("SUPERSECRET"));
264 }
265
266 #[test]
267 fn rejects_too_many_cookies() {
268 let text = "x.com\tFALSE\t/\tFALSE\t0\tn\tv\n".repeat(MAX_COOKIES + 1);
269 assert!(matches!(parse_cookies(&text), Err(ParseError::TooMany { .. })));
270 }
271
272 #[test]
273 fn cookie_for_scopes_to_same_site() {
274 let target = Url::parse("https://example.com/").unwrap();
275 assert!(cookie_for(&target, &spec("app.example.com", false), NetworkPolicy::STRICT).is_some());
276 assert!(cookie_for(&target, &spec("evil.com", false), NetworkPolicy::STRICT).is_none());
277 }
278
279 #[test]
280 fn cookie_for_derives_origin_and_domain_attr() {
281 let target = Url::parse("https://example.com/").unwrap();
282 let (url, c) = cookie_for(&target, &spec("example.com", true), NetworkPolicy::STRICT).unwrap();
284 assert_eq!(url.scheme(), "https");
285 assert!(c.domain().is_none());
286 let (_, c) = cookie_for(&target, &spec(".example.com", false), NetworkPolicy::STRICT).unwrap();
288 assert_eq!(c.domain(), Some("example.com"));
289 }
290
291 #[test]
292 fn cookie_for_blocks_private_under_strict_only() {
293 let target = Url::parse("http://127.0.0.1/").unwrap();
294 assert!(cookie_for(&target, &spec("127.0.0.1", false), NetworkPolicy::STRICT).is_none());
295 assert!(cookie_for(&target, &spec("127.0.0.1", false), NetworkPolicy::PERMISSIVE).is_some());
296 }
297}