Skip to main content

servo_fetch/
cookies.rs

1//! Load a Netscape `cookies.txt` file and seed Servo's cookie jar before navigation.
2
3use 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/// A cookie to seed into the jar before navigation.
15#[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
40/// Load cookies from a Netscape format `cookies.txt` file.
41pub 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
111/// Seed `specs` into the jar, scoped to `target`'s site and the network policy.
112pub(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
125/// Build a jar entry keyed by the cookie's own origin, or `None` if it is out of
126/// `target`'s site or disallowed by `policy`.
127fn 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        // integer- and float-formatted past timestamps are dropped; 0 = session is kept.
202        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        // '=' is legal in a value (e.g. base64 padding).
225        assert!(parse_cookies("x.com\tFALSE\t/\tFALSE\t0\tn\tYWJj==\n").is_ok());
226        // errors never echo the offending value.
227        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        // host-only over https: secure flag picks the https origin, no Domain attribute.
283        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        // leading dot makes it a domain cookie scoped to the registrable host.
287        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}