Skip to main content

opentele2_browser/
headers.rs

1//! Browser HTTP header generation, based on browserforge's
2//! `headers/generator.py` and `headers/utils.py` (browserforge 1.2.4).
3//!
4//! The input network draws a browser, operating system and device that fit
5//! the [`HeaderOptions`]; the header network then draws the headers given
6//! that draw. The result is filtered, ordered as the browser orders its
7//! headers (`data/headers-order.json`) and, for HTTP/2, pascalized.
8//!
9//! The module fixes these browserforge defects, each documented where it
10//! lives:
11//!
12//! 1. the Sec-Fetch headers carry real navigation values ([`SEC_FETCH`]);
13//! 2. the operating system option is relaxed (browserforge's relaxation key
14//!    `operatingSystems` never matched its `os` option);
15//! 3. the HTTP/1 to HTTP/2 fallback cannot fail on its own arguments
16//!    (browserforge passed `http_version` twice and raised `TypeError`);
17//! 4. [`browser_of`] recognises Edge before Chrome, so Edge headers use the
18//!    Edge order;
19//! 5. with a `user_agent` override the header network is sampled with the
20//!    `User-Agent` restricted to the given values, so the output carries one
21//!    of them;
22//! 6. at most [`MAX_LOCALES`] locales, so no `q` falls below 0.1;
23//! 7. request-dependent headers are kept rather than dropped by the ordering.
24
25use std::collections::HashMap;
26
27use crate::network::{BayesianNetwork, header_network, input_network};
28use crate::{Error, OrderedMap, Restrictions, Result, Rng, Values};
29
30/// The browsers browserforge supports, in its default order.
31pub const SUPPORTED_BROWSERS: [&str; 4] = ["chrome", "firefox", "safari", "edge"];
32/// The operating systems browserforge supports.
33pub const SUPPORTED_OPERATING_SYSTEMS: [&str; 5] = ["windows", "macos", "linux", "android", "ios"];
34/// The devices browserforge supports.
35pub const SUPPORTED_DEVICES: [&str; 2] = ["desktop", "mobile"];
36/// The dataset token for an absent header.
37pub const MISSING_VALUE: &str = "*MISSING_VALUE*";
38/// The most locales an `Accept-Language` header takes.
39///
40/// Defect (6): browserforge documents this bound but never enforces it, so an
41/// eleventh locale got `q=0.0` and later ones negative weights. Ten locales
42/// give weights 1.0 down to 0.1.
43pub const MAX_LOCALES: usize = 10;
44
45/// The Sec-Fetch headers of a user-initiated top-level navigation, in HTTP/2
46/// spelling.
47///
48/// Defect (1): browserforge assigned these values to the wrong names
49/// (`Sec-Fetch-Mode: same-site`, `Sec-Fetch-Dest: navigate`,
50/// `Sec-Fetch-Site: ?1`, `Sec-Fetch-User: document`), a combination no
51/// browser sends.
52pub const SEC_FETCH: [(&str, &str); 4] = [
53    ("sec-fetch-site", "same-site"),
54    ("sec-fetch-mode", "navigate"),
55    ("sec-fetch-user", "?1"),
56    ("sec-fetch-dest", "document"),
57];
58
59/// How many input samples a `user_agent`-restricted generation draws before
60/// it counts as a failed attempt.
61///
62/// The `user_agent` closures constrain the browser, operating system and
63/// device one at a time, so an input sample can combine them in a way no
64/// listed User-Agent has (a Safari build on Windows, say); a fresh draw then
65/// usually fits.
66const UA_INPUT_DRAWS: usize = 16;
67
68const BROWSER_HELPER_JSON: &str = include_str!("../data/browser-helper-file.json");
69const HEADERS_ORDER_JSON: &str = include_str!("../data/headers-order.json");
70
71/// Generated headers: name to value, in sending order.
72pub type Headers = OrderedMap<String>;
73
74/// The HTTP version of the generated request.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
76pub enum HttpVersion {
77    /// HTTP/1.1: Pascal-Case header names.
78    One,
79    /// HTTP/2: lower-case header names in the networks, pascalized on
80    /// output.
81    #[default]
82    Two,
83}
84
85impl HttpVersion {
86    /// The version as browserforge spells it: `"1"` or `"2"`.
87    #[must_use]
88    pub const fn as_str(self) -> &'static str {
89        match self {
90            Self::One => "1",
91            Self::Two => "2",
92        }
93    }
94
95    fn parse(value: &str) -> Option<Self> {
96        match value {
97            "1" => Some(Self::One),
98            "2" => Some(Self::Two),
99            _ => None,
100        }
101    }
102}
103
104/// A browser the headers may come from, with optional bounds on its major
105/// version.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Browser {
108    /// `chrome`, `firefox`, `safari` or `edge`.
109    pub name: String,
110    /// The lowest major version, inclusive.
111    pub min_version: Option<u32>,
112    /// The highest major version, inclusive.
113    pub max_version: Option<u32>,
114    /// The HTTP version the browser speaks.
115    pub http_version: HttpVersion,
116}
117
118impl Browser {
119    /// Any version of `name` over HTTP/2.
120    #[must_use]
121    pub fn new(name: impl Into<String>) -> Self {
122        Self {
123            name: name.into(),
124            min_version: None,
125            max_version: None,
126            http_version: HttpVersion::Two,
127        }
128    }
129
130    fn matches(&self, candidate: &HttpBrowser) -> bool {
131        self.name == candidate.name
132            && self.min_version.is_none_or(|min| min <= candidate.major)
133            && self.max_version.is_none_or(|max| max >= candidate.major)
134            && self.http_version == candidate.http_version
135    }
136}
137
138fn default_browsers(http_version: HttpVersion) -> Vec<Browser> {
139    SUPPORTED_BROWSERS
140        .iter()
141        .map(|&name| Browser {
142            http_version,
143            ..Browser::new(name)
144        })
145        .collect()
146}
147
148fn owned(values: &[&str]) -> Vec<String> {
149    values.iter().map(|&v| v.to_owned()).collect()
150}
151
152/// The options of a [`HeaderGenerator`], defaulting as browserforge does:
153/// every supported browser, operating system and device, `en-US`, HTTP/2,
154/// not strict.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct HeaderOptions {
157    /// The browsers to draw from; each carries its own HTTP version.
158    pub browsers: Vec<Browser>,
159    /// The operating systems to draw from.
160    pub os: Vec<String>,
161    /// The devices to draw from.
162    pub devices: Vec<String>,
163    /// The `Accept-Language` locales, most preferred first; at most
164    /// [`MAX_LOCALES`].
165    pub locales: Vec<String>,
166    /// The requested HTTP version. HTTP/1 falls back to HTTP/2 when no
167    /// HTTP/1 sample fits.
168    pub http_version: HttpVersion,
169    /// Fail instead of relaxing the options when no sample fits them.
170    pub strict: bool,
171}
172
173impl Default for HeaderOptions {
174    fn default() -> Self {
175        Self {
176            browsers: default_browsers(HttpVersion::Two),
177            os: owned(&SUPPORTED_OPERATING_SYSTEMS),
178            devices: owned(&SUPPORTED_DEVICES),
179            locales: vec!["en-US".to_owned()],
180            http_version: HttpVersion::Two,
181            strict: false,
182        }
183    }
184}
185
186impl HeaderOptions {
187    /// Sets the requested HTTP version and that of every browser, as
188    /// browserforge's `http_version` argument does for browsers given by
189    /// name.
190    #[must_use]
191    pub fn with_http_version(mut self, http_version: HttpVersion) -> Self {
192        self.http_version = http_version;
193        for browser in &mut self.browsers {
194            browser.http_version = http_version;
195        }
196        self
197    }
198
199    /// Resets the first option that differs from its default, in
200    /// browserforge's relaxation order: locales, devices, operating systems,
201    /// browsers. `false` when every one is already at its default.
202    ///
203    /// Defect (2): browserforge's order named `operatingSystems`, a key its
204    /// options never had, so the operating system was never relaxed.
205    fn relax(&mut self) -> bool {
206        let defaults = Self::default().with_http_version(self.http_version);
207        if self.locales != defaults.locales {
208            self.locales = defaults.locales;
209        } else if self.devices != defaults.devices {
210            self.devices = defaults.devices;
211        } else if self.os != defaults.os {
212            self.os = defaults.os;
213        } else if self.browsers != defaults.browsers {
214            self.browsers = defaults.browsers;
215        } else {
216            return false;
217        }
218        true
219    }
220}
221
222/// Per-call additions to the [`HeaderOptions`].
223#[derive(Debug, Clone, Default, PartialEq, Eq)]
224pub struct HeaderOverrides {
225    /// When non-empty, the generated `User-Agent` is one of these.
226    pub user_agent: Vec<String>,
227    /// Headers that depend on the request (`Referer`, `Cookie`, ...).
228    ///
229    /// Defect (7): browserforge merged these before ordering, so any name
230    /// missing from the browser's order was silently dropped. Here a name
231    /// that matches a generated header (ignoring ASCII case) replaces its
232    /// value in place; any other is appended after the ordered headers.
233    pub request_dependent_headers: Headers,
234}
235
236/// A `*BROWSER_HTTP` value: `name/a.b.c|http`.
237#[derive(Debug, Clone, PartialEq, Eq)]
238struct HttpBrowser {
239    name: String,
240    major: u32,
241    complete: String,
242    http_version: HttpVersion,
243}
244
245impl HttpBrowser {
246    fn parse(value: &str) -> Result<Self> {
247        let malformed = || Error::Data(format!("malformed browser entry {value:?}"));
248        let (browser, http) = value.split_once('|').ok_or_else(malformed)?;
249        let (name, version) = browser.split_once('/').ok_or_else(malformed)?;
250        let mut parts = version.split('.');
251        let major = parts
252            .next()
253            .and_then(|part| part.parse().ok())
254            .ok_or_else(malformed)?;
255        if parts.any(|part| part.parse::<u32>().is_err()) {
256            return Err(malformed());
257        }
258        Ok(Self {
259            name: name.to_owned(),
260            major,
261            complete: value.to_owned(),
262            http_version: HttpVersion::parse(http).ok_or_else(malformed)?,
263        })
264    }
265
266    /// browserforge's `_should_add_sec_fetch`; Safari never sends them.
267    fn sends_sec_fetch(&self) -> bool {
268        match self.name.as_str() {
269            "chrome" => self.major >= 76,
270            "firefox" => self.major >= 90,
271            "edge" => self.major >= 79,
272            _ => false,
273        }
274    }
275}
276
277/// The `possible_values` closures of the `User-Agent` override, per HTTP
278/// version.
279struct UaClosures {
280    http1: Restrictions,
281    http2: Restrictions,
282}
283
284impl UaClosures {
285    fn contains(closure: &Restrictions, key: &str, value: &str) -> bool {
286        closure
287            .get(key)
288            .is_some_and(|values| values.iter().any(|v| v == value))
289    }
290
291    /// browserforge's `filter_browser_http`.
292    fn allows_browser_http(&self, value: &str) -> bool {
293        let (browser, http) = value.split_once('|').unwrap_or((value, ""));
294        let closure = if http == "1" {
295            &self.http1
296        } else {
297            &self.http2
298        };
299        Self::contains(closure, "*BROWSER", browser)
300    }
301
302    /// browserforge's `filter_other_values`.
303    fn allows(&self, key: &str, value: &str) -> bool {
304        Self::contains(&self.http1, key, value) || Self::contains(&self.http2, key, value)
305    }
306}
307
308/// Generates browser HTTP headers, as browserforge's `HeaderGenerator`.
309#[derive(Debug, Clone)]
310pub struct HeaderGenerator {
311    options: HeaderOptions,
312    unique_browsers: Vec<HttpBrowser>,
313    headers_order: HashMap<String, Vec<String>>,
314}
315
316impl HeaderGenerator {
317    /// Builds a generator for `options`.
318    ///
319    /// # Errors
320    ///
321    /// [`Error::InvalidOptions`] for a browser whose minimum version exceeds
322    /// its maximum or for more than [`MAX_LOCALES`] locales;
323    /// [`Error::Data`] when the embedded browser list or header order is
324    /// malformed (never for the shipped data).
325    pub fn new(options: HeaderOptions) -> Result<Self> {
326        for browser in &options.browsers {
327            if let (Some(min), Some(max)) = (browser.min_version, browser.max_version)
328                && min > max
329            {
330                return Err(Error::InvalidOptions(format!(
331                    "Browser min version constraint ({min}) cannot exceed max version ({max})"
332                )));
333            }
334        }
335        if options.locales.len() > MAX_LOCALES {
336            return Err(Error::InvalidOptions(format!(
337                "at most {MAX_LOCALES} locales are allowed, got {}",
338                options.locales.len()
339            )));
340        }
341        let data_error = |e: serde_json::Error| Error::Data(e.to_string());
342        let helper: Vec<String> = serde_json::from_str(BROWSER_HELPER_JSON).map_err(data_error)?;
343        let unique_browsers = helper
344            .iter()
345            .filter(|entry| *entry != MISSING_VALUE)
346            .map(|entry| HttpBrowser::parse(entry))
347            .collect::<Result<_>>()?;
348        let headers_order = serde_json::from_str(HEADERS_ORDER_JSON).map_err(data_error)?;
349        Ok(Self {
350            options,
351            unique_browsers,
352            headers_order,
353        })
354    }
355
356    /// The options the generator was built with.
357    #[must_use]
358    pub fn options(&self) -> &HeaderOptions {
359        &self.options
360    }
361
362    /// Generates one set of headers. The same `rng` state, options and
363    /// overrides always give the same headers.
364    ///
365    /// When no input sample fits, HTTP/1 falls back to HTTP/2; then, unless
366    /// the options are strict, the first non-default option among locales,
367    /// devices, operating systems and browsers is reset and the draw
368    /// retried.
369    ///
370    /// Defect (3): browserforge's fallback passed `http_version` twice and
371    /// raised `TypeError` whenever `generate` had been given one; here it
372    /// only switches every browser to HTTP/2.
373    ///
374    /// # Errors
375    ///
376    /// [`Error::NoHeaders`] when nothing fits and nothing is left to relax
377    /// (or the options are strict); [`Error::MissingUserAgent`] or
378    /// [`Error::UnknownBrowser`] when the drawn headers cannot be ordered;
379    /// the network errors of [`input_network`] and [`header_network`].
380    pub fn generate(&self, rng: &mut Rng, overrides: &HeaderOverrides) -> Result<Headers> {
381        let input_net = input_network()?;
382        let header_net = header_network()?;
383        let closures = if overrides.user_agent.is_empty() {
384            None
385        } else {
386            let restrict = |node: &str| -> Result<Restrictions> {
387                let mut restriction = Restrictions::new();
388                restriction.insert(node, overrides.user_agent.clone());
389                header_net.possible_values(&restriction)
390            };
391            Some(UaClosures {
392                http1: restrict("User-Agent")?,
393                http2: restrict("user-agent")?,
394            })
395        };
396        let draws = if closures.is_some() {
397            UA_INPUT_DRAWS
398        } else {
399            1
400        };
401
402        let mut state = self.options.clone();
403        loop {
404            let constraints = self.constraints(&state, closures.as_ref());
405            for _ in 0..draws {
406                let Some(input) = input_net.consistent_sample(rng, &constraints) else {
407                    break;
408                };
409                if let Some(mut headers) =
410                    self.headers_for(rng, header_net, &input, &state.locales, overrides)?
411                {
412                    add_request_dependent(&mut headers, &overrides.request_dependent_headers);
413                    return Ok(headers);
414                }
415            }
416            if state.http_version == HttpVersion::One {
417                state = state.with_http_version(HttpVersion::Two);
418            } else if state.strict || !state.relax() {
419                return Err(Error::NoHeaders);
420            }
421        }
422    }
423
424    /// browserforge's `_get_possible_attribute_values` followed by
425    /// `_prepare_constraints`.
426    fn constraints(&self, state: &HeaderOptions, closures: Option<&UaClosures>) -> Restrictions {
427        let mut browser_http: Vec<String> = state
428            .browsers
429            .iter()
430            .flat_map(|browser| {
431                self.unique_browsers
432                    .iter()
433                    .filter(|candidate| browser.matches(candidate))
434                    .map(|candidate| candidate.complete.clone())
435            })
436            .collect();
437        let mut os = state.os.clone();
438        let mut devices = state.devices.clone();
439        if let Some(closures) = closures {
440            browser_http.retain(|value| closures.allows_browser_http(value));
441            os.retain(|value| closures.allows("*OPERATING_SYSTEM", value));
442            devices.retain(|value| closures.allows("*DEVICE", value));
443        }
444        let mut constraints = Restrictions::new();
445        constraints.insert("*BROWSER_HTTP", browser_http);
446        constraints.insert("*OPERATING_SYSTEM", os);
447        constraints.insert("*DEVICE", devices);
448        constraints
449    }
450
451    /// The headers for one input sample; `None` when the header network has
452    /// no sample with one of the override User-Agents.
453    fn headers_for(
454        &self,
455        rng: &mut Rng,
456        header_net: &BayesianNetwork,
457        input: &Values,
458        locales: &[String],
459        overrides: &HeaderOverrides,
460    ) -> Result<Option<Headers>> {
461        let browser_http = input
462            .get("*BROWSER_HTTP")
463            .ok_or_else(|| Error::Data("input sample without *BROWSER_HTTP".to_owned()))?;
464        let browser = HttpBrowser::parse(browser_http)?;
465        let http2 = browser.http_version == HttpVersion::Two;
466
467        // Defect (5): browserforge sampled the header network freely after
468        // the input network, so the output User-Agent ignored the override.
469        let mut restrictions = Restrictions::new();
470        if !overrides.user_agent.is_empty() {
471            let node = if http2 { "user-agent" } else { "User-Agent" };
472            restrictions.insert(node, overrides.user_agent.clone());
473        }
474        let Some(mut sample) = header_net.restricted_sample(rng, input, &restrictions) else {
475            return Ok(None);
476        };
477
478        let accept_language = if http2 {
479            "accept-language"
480        } else {
481            "Accept-Language"
482        };
483        sample.insert(accept_language, accept_language_header(locales));
484        if browser.sends_sec_fetch() {
485            for (name, value) in SEC_FETCH {
486                let name = if http2 {
487                    name.to_owned()
488                } else {
489                    pascalize(name)
490                };
491                sample.insert(name, value.to_owned());
492            }
493        }
494        let kept: Headers = sample
495            .into_iter()
496            .filter(|(name, value)| {
497                !(name.starts_with('*')
498                    || value == MISSING_VALUE
499                    || (name.eq_ignore_ascii_case("connection") && value == "close"))
500            })
501            .collect();
502        let ordered = self.order(kept)?;
503        Ok(Some(if http2 {
504            ordered
505                .into_iter()
506                .map(|(name, value)| (pascalize(&name), value))
507                .collect()
508        } else {
509            ordered
510        }))
511    }
512
513    /// browserforge's `order_headers`: the headers named in the order of the
514    /// User-Agent's browser, in that order; the rest are dropped. Headers of
515    /// a browser without an order stay as they are.
516    fn order(&self, headers: Headers) -> Result<Headers> {
517        let user_agent = get_user_agent(&headers).ok_or(Error::MissingUserAgent)?;
518        let browser =
519            browser_of(user_agent).ok_or_else(|| Error::UnknownBrowser(user_agent.to_owned()))?;
520        let Some(order) = self.headers_order.get(browser) else {
521            return Ok(headers);
522        };
523        Ok(order
524            .iter()
525            .filter_map(|name| {
526                headers
527                    .get(name)
528                    .map(|value| (name.as_str(), value.clone()))
529            })
530            .collect())
531    }
532}
533
534/// `l1;q=1.0, l2;q=0.9, ...`, browserforge's `_get_accept_language_header`.
535fn accept_language_header(locales: &[String]) -> String {
536    locales
537        .iter()
538        .enumerate()
539        .map(|(index, locale)| {
540            // Weights in tenths, never below 0.1 (defect (6)).
541            let tenths = 10_usize.saturating_sub(index).max(1);
542            format!("{locale};q={}.{}", tenths / 10, tenths % 10)
543        })
544        .collect::<Vec<_>>()
545        .join(", ")
546}
547
548/// Defect (7): see [`HeaderOverrides::request_dependent_headers`].
549fn add_request_dependent(headers: &mut Headers, extra: &Headers) {
550    for (name, value) in extra {
551        let existing = headers
552            .keys()
553            .find(|key| key.eq_ignore_ascii_case(name))
554            .map(str::to_owned);
555        headers.insert(existing.unwrap_or_else(|| name.to_owned()), value.clone());
556    }
557}
558
559/// The `User-Agent` (or HTTP/2 `user-agent`) of `headers`, browserforge's
560/// `get_user_agent`; an empty value counts as absent.
561#[must_use]
562pub fn get_user_agent(headers: &Headers) -> Option<&str> {
563    ["User-Agent", "user-agent"]
564        .into_iter()
565        .filter_map(|name| headers.get(name))
566        .map(String::as_str)
567        .find(|value| !value.is_empty())
568}
569
570/// The browser a User-Agent belongs to: `firefox`, `edge`, `chrome` or
571/// `safari`; browserforge's `get_browser`.
572///
573/// Defect (4): Edge User-Agents also contain `Chrome` and `Safari`, and
574/// browserforge tested `Chrome` first, so it never returned `edge`. Edge's
575/// tokens are checked before Chrome's here.
576#[must_use]
577pub fn browser_of(user_agent: &str) -> Option<&'static str> {
578    let has = |aliases: &[&str]| aliases.iter().any(|alias| user_agent.contains(alias));
579    if has(&["Firefox", "FxiOS"]) {
580        Some("firefox")
581    } else if has(&["Edge", "EdgA", "EdgiOS", "Edg"]) {
582        Some("edge")
583    } else if has(&["Chrome", "CriOS"]) {
584        Some("chrome")
585    } else if has(&["Safari"]) {
586        Some("safari")
587    } else {
588        None
589    }
590}
591
592/// The HTTP/1 spelling of an HTTP/2 header name, browserforge's
593/// `pascalize`: pseudo-headers (`:path`) and `sec-ch-ua*` stay as they are,
594/// `dnt`, `rtt` and `ect` are upper-cased, anything else is title-cased as
595/// Python's `str.title` does.
596#[must_use]
597pub fn pascalize(name: &str) -> String {
598    if name.starts_with(':') || name.starts_with("sec-ch-ua") {
599        return name.to_owned();
600    }
601    if matches!(name, "dnt" | "rtt" | "ect") {
602        return name.to_ascii_uppercase();
603    }
604    let mut pascal = String::with_capacity(name.len());
605    let mut after_letter = false;
606    for c in name.chars() {
607        if after_letter {
608            pascal.extend(c.to_lowercase());
609        } else {
610            pascal.extend(c.to_uppercase());
611        }
612        after_letter = c.is_alphabetic();
613    }
614    pascal
615}