Skip to main content

grabcli_core/
http.rs

1//! The shared HTTP client.
2//!
3//! Every outbound request is checked against an explicit URL allowlist before
4//! it is sent. Service crates register the read-only URLs they need. The
5//! order-submission URL is deliberately *not* registered unless ordering is
6//! enabled in config, so an accidental or injected call to it fails closed with
7//! [`ErrorKind::UrlNotAllowed`] instead of reaching Grab.
8//!
9//! Cookies are sent as a manually built `Cookie:` header (reqwest's cookie
10//! store is off), filtered per request host and path the way a browser does.
11
12use std::sync::Arc;
13
14use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
15use serde_json::Value;
16
17use crate::config::Config;
18use crate::error::{Error, ErrorKind, Result};
19use crate::session::Session;
20
21pub const DEFAULT_UA: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \
22     (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36";
23
24/// How a URL may be matched by the allowlist.
25#[derive(Debug, Clone)]
26pub enum Rule {
27    /// The request URL (without query) must equal this exactly.
28    Exact(String),
29    /// The request URL (without query) must start with this prefix. Used for
30    /// path-parameterised endpoints such as `.../merchants/<id>`.
31    Prefix(String),
32}
33
34impl Rule {
35    fn matches(&self, url_no_query: &str) -> bool {
36        match self {
37            Rule::Exact(u) => url_no_query == u,
38            Rule::Prefix(p) => url_no_query.starts_with(p),
39        }
40    }
41}
42
43/// A set of allowed URLs. Service crates build one and register their URLs.
44#[derive(Debug, Default, Clone)]
45pub struct Allowlist {
46    rules: Vec<Rule>,
47}
48
49impl Allowlist {
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    pub fn allow_exact(&mut self, url: impl Into<String>) -> &mut Self {
55        self.rules.push(Rule::Exact(url.into()));
56        self
57    }
58
59    pub fn allow_prefix(&mut self, prefix: impl Into<String>) -> &mut Self {
60        self.rules.push(Rule::Prefix(prefix.into()));
61        self
62    }
63
64    /// Add every rule of `other`.
65    pub fn extend(&mut self, other: &Allowlist) -> &mut Self {
66        self.rules.extend(other.rules.iter().cloned());
67        self
68    }
69
70    pub fn is_allowed(&self, url: &str) -> bool {
71        let no_query = url.split('?').next().unwrap_or(url);
72        self.rules.iter().any(|r| r.matches(no_query))
73    }
74}
75
76/// Whether a call needs the hydra device JWT header.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Hydra {
79    Required,
80    NotNeeded,
81}
82
83/// A prepared request the client will send if its URL is allowlisted.
84pub struct Request {
85    pub method: reqwest::Method,
86    pub url: String,
87    pub body: Option<Value>,
88    pub hydra: Hydra,
89    /// Extra per-request headers (e.g. `food-web-ua`).
90    pub extra_headers: Vec<(String, String)>,
91    /// Whether the client may auto-refresh the session and retry this request
92    /// once on a 401. False for non-idempotent calls like order submission.
93    pub refreshable: bool,
94}
95
96impl Request {
97    pub fn get(url: impl Into<String>, hydra: Hydra) -> Self {
98        Self {
99            method: reqwest::Method::GET,
100            url: url.into(),
101            body: None,
102            hydra,
103            extra_headers: Vec::new(),
104            refreshable: true,
105        }
106    }
107
108    pub fn post(url: impl Into<String>, body: Value, hydra: Hydra) -> Self {
109        Self {
110            method: reqwest::Method::POST,
111            url: url.into(),
112            body: Some(body),
113            hydra,
114            extra_headers: Vec::new(),
115            refreshable: true,
116        }
117    }
118
119    /// Mark this request as non-retryable: the client will neither auto-refresh
120    /// nor resend it on a 401 (use for order submission — no idempotency key).
121    pub fn no_refresh(mut self) -> Self {
122        self.refreshable = false;
123        self
124    }
125}
126
127/// A cookie parsed from a `Set-Cookie` response header.
128#[derive(Debug, Clone)]
129pub struct RawCookie {
130    pub name: String,
131    pub value: String,
132    pub domain: String,
133    pub path: String,
134    /// Unix seconds; 0 for a session cookie (no Max-Age/Expires we understood).
135    pub expires: f64,
136    /// `Max-Age<=0` or an `Expires` in the past: the server deleted it.
137    pub deleted: bool,
138}
139
140/// Shared state for building requests, minus the hydra token (which the
141/// higher-level client refreshes).
142pub struct Client {
143    inner: reqwest::Client,
144    allow: Allowlist,
145    user_agent: String,
146    web_app_version: Option<String>,
147    /// The session's cookies; each request gets only those a browser would
148    /// send to its host (see [`Session::cookie_header_for`]).
149    session: Session,
150    /// The `x-gfc-session` value, absent when the session cookie is missing or
151    /// expired (so the client can still run a refresh to obtain a fresh one).
152    gfc_session_key: Option<String>,
153}
154
155impl Client {
156    /// Build from a session + config. The service's allowlist is passed in.
157    pub fn new(session: &Session, config: &Config, allow: Allowlist) -> Result<Self> {
158        let inner = reqwest::Client::builder()
159            .gzip(true)
160            .build()
161            .map_err(Error::from)?;
162        let user_agent = config
163            .user_agent
164            .clone()
165            .or_else(|| session.user_agent.clone())
166            .unwrap_or_else(|| DEFAULT_UA.to_string());
167        Ok(Self {
168            inner,
169            allow,
170            user_agent,
171            web_app_version: session.web_app_version.clone(),
172            session: session.clone(),
173            gfc_session_key: session.gfc_session_key().ok(),
174        })
175    }
176
177    pub fn allowlist(&self) -> &Allowlist {
178        &self.allow
179    }
180
181    fn headers(&self, req: &Request, hydra_jwt: Option<&str>) -> Result<HeaderMap> {
182        let mut h = HeaderMap::new();
183        let mut set = |k: &'static str, v: &str| -> Result<()> {
184            let val = HeaderValue::from_str(v)
185                .map_err(|e| Error::invalid(format!("bad header {k}: {e}")))?;
186            h.insert(HeaderName::from_static(k), val);
187            Ok(())
188        };
189        set("accept", "application/json, text/plain, */*")?;
190        set("accept-language", "en")?;
191        set("user-agent", &self.user_agent)?;
192        set("origin", "https://food.grab.com")?;
193        set("referer", "https://food.grab.com/")?;
194        set("x-gfc-country", "PH")?;
195        set("x-country-code", "PH")?;
196        if let Some(key) = &self.gfc_session_key {
197            set("x-gfc-session", key)?;
198        }
199        let cookie = self.session.cookie_header_for(&req.url);
200        if !cookie.is_empty() {
201            set("cookie", &cookie)?;
202        }
203        if let Some(v) = &self.web_app_version {
204            set("x-grab-web-app-version", v)?;
205        }
206        if req.body.is_some() {
207            set("content-type", "application/json;charset=UTF-8")?;
208        }
209        if req.hydra == Hydra::Required {
210            let jwt = hydra_jwt.ok_or_else(|| {
211                Error::new(ErrorKind::HydraExpired, "this call needs a hydra JWT")
212            })?;
213            set("x-hydra-jwt", jwt)?;
214        }
215        for (k, v) in &req.extra_headers {
216            let name = HeaderName::from_bytes(k.as_bytes())
217                .map_err(|e| Error::invalid(format!("bad header {k}: {e}")))?;
218            let val = HeaderValue::from_str(v)
219                .map_err(|e| Error::invalid(format!("bad header value: {e}")))?;
220            h.insert(name, val);
221        }
222        Ok(h)
223    }
224
225    /// Send a request. Fails closed if the URL is not allowlisted. Maps HTTP
226    /// status and Grab's `invalid hydra jwt` / 401 into typed errors.
227    pub async fn send(&self, req: &Request, hydra_jwt: Option<&str>) -> Result<Value> {
228        if !self.allow.is_allowed(&req.url) {
229            let path = req.url.split('?').next().unwrap_or(&req.url).to_string();
230            return Err(Error::new(
231                ErrorKind::UrlNotAllowed,
232                format!("refusing non-allowlisted URL: {path}"),
233            ));
234        }
235        let headers = self.headers(req, hydra_jwt)?;
236        let mut builder = self
237            .inner
238            .request(req.method.clone(), &req.url)
239            .headers(headers);
240        if let Some(body) = &req.body {
241            builder = builder.json(body);
242        }
243        let resp = builder.send().await?;
244        let status = resp.status();
245        let bytes = resp.bytes().await?;
246
247        if status == reqwest::StatusCode::UNAUTHORIZED {
248            return Err(Error::new(
249                ErrorKind::AuthExpired,
250                "401 from Grab: the food.grab.com session has expired; run `grab auth refresh` (or `grab auth login` if that fails)",
251            ));
252        }
253        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
254            return Err(Error::new(
255                ErrorKind::RateLimited,
256                "429 from Grab: slow down and retry later",
257            ));
258        }
259
260        let value: Value = serde_json::from_slice(&bytes).map_err(|_| {
261            Error::new(
262                ErrorKind::Api,
263                format!(
264                    "{} from {} with non-JSON body",
265                    status.as_u16(),
266                    req.url.split('?').next().unwrap_or(&req.url)
267                ),
268            )
269        })?;
270
271        // Grab signals an expired/invalid hydra token with a 400 and a
272        // `message`/`reason` mentioning the hydra jwt.
273        if is_hydra_error(status, &value) {
274            return Err(Error::new(
275                ErrorKind::HydraExpired,
276                "Grab rejected the hydra JWT (invalid hydra jwt)",
277            ));
278        }
279        if !status.is_success() {
280            let msg = value
281                .get("message")
282                .or_else(|| value.get("reason"))
283                .and_then(Value::as_str)
284                .unwrap_or("request failed");
285            return Err(Error::new(
286                ErrorKind::Api,
287                format!("{} from Grab: {msg}", status.as_u16()),
288            ));
289        }
290        Ok(value)
291    }
292}
293
294/// Parse one `Set-Cookie` header received from `host` into a [`RawCookie`].
295///
296/// Domains are stored the way browsers (and the CDP export) do: a `Domain`
297/// attribute becomes `.grab.com` (a domain cookie), no `Domain` attribute means
298/// a host-only cookie for `host`. `Max-Age` wins over `Expires`; a
299/// `Max-Age<=0` or an `Expires` in the past marks the cookie as a deletion.
300pub fn parse_set_cookie(s: &str, host: &str) -> Option<RawCookie> {
301    parse_set_cookie_at(s, host, crate::jwt::now())
302}
303
304fn parse_set_cookie_at(s: &str, host: &str, now: i64) -> Option<RawCookie> {
305    let mut parts = s.split(';');
306    let nv = parts.next()?.trim();
307    let (name, value) = nv.split_once('=')?;
308    let name = name.trim();
309    if name.is_empty() {
310        return None;
311    }
312    let mut domain = host.to_ascii_lowercase();
313    let mut path = "/".to_string();
314    let mut max_age: Option<i64> = None;
315    let mut expires_at: Option<i64> = None;
316    for attr in parts {
317        let attr = attr.trim();
318        let Some((k, v)) = attr.split_once('=') else {
319            continue;
320        };
321        let v = v.trim();
322        match k.trim().to_ascii_lowercase().as_str() {
323            "domain" => {
324                let bare = v.trim_start_matches('.').to_ascii_lowercase();
325                if !bare.is_empty() {
326                    domain = format!(".{bare}");
327                }
328            }
329            "path" if v.starts_with('/') => path = v.to_string(),
330            "max-age" => max_age = v.parse::<i64>().ok(),
331            "expires" => expires_at = parse_http_date(v),
332            _ => {}
333        }
334    }
335    let (expires, deleted) = match (max_age, expires_at) {
336        (Some(secs), _) => ((now + secs) as f64, secs <= 0),
337        (None, Some(at)) => (at as f64, at <= now),
338        (None, None) => (0.0, false),
339    };
340    Some(RawCookie {
341        name: name.to_string(),
342        value: value.trim().to_string(),
343        domain,
344        path,
345        expires,
346        deleted,
347    })
348}
349
350/// Parse an HTTP date (`Sun, 06 Nov 1994 08:49:37 GMT`, also the
351/// `06-Nov-1994` cookie variant) into Unix seconds.
352pub fn parse_http_date(s: &str) -> Option<i64> {
353    let s = s.trim();
354    // Drop the weekday ("Sun, ").
355    let rest = s.split_once(',').map_or(s, |(_, r)| r).trim();
356    let mut fields = rest.split([' ', '-']).filter(|f| !f.is_empty());
357    let day: i64 = fields.next()?.parse().ok()?;
358    let mon = fields.next()?.to_ascii_lowercase();
359    let month = [
360        "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
361    ]
362    .iter()
363    .position(|m| mon.starts_with(m))? as i64
364        + 1;
365    let mut year: i64 = fields.next()?.parse().ok()?;
366    if year < 100 {
367        year += if year < 70 { 2000 } else { 1900 };
368    }
369    let mut hms = fields.next()?.split(':').map(|x| x.parse::<i64>().ok());
370    let (h, m, sec) = (hms.next()??, hms.next()??, hms.next()??);
371    if !(1..=31).contains(&day) || h > 23 || m > 59 || sec > 60 {
372        return None;
373    }
374    Some(days_from_civil(year, month, day) * 86_400 + h * 3600 + m * 60 + sec)
375}
376
377/// Days since 1970-01-01 for a proleptic Gregorian date (Howard Hinnant).
378fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
379    let y = if m <= 2 { y - 1 } else { y };
380    let era = y.div_euclid(400);
381    let yoe = y - era * 400;
382    let mp = (m + 9) % 12;
383    let doy = (153 * mp + 2) / 5 + d - 1;
384    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
385    era * 146_097 + doe - 719_468
386}
387
388fn is_hydra_error(status: reqwest::StatusCode, value: &Value) -> bool {
389    if status != reqwest::StatusCode::BAD_REQUEST {
390        return false;
391    }
392    let text = |k: &str| {
393        value
394            .get(k)
395            .and_then(Value::as_str)
396            .unwrap_or("")
397            .to_ascii_lowercase()
398    };
399    text("message").contains("hydra") || text("reason").contains("hydra")
400}
401
402/// A shareable handle so service code can hold the client cheaply.
403pub type SharedClient = Arc<Client>;
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn allowlist_matches_exact_and_prefix_ignoring_query() {
411        let mut a = Allowlist::new();
412        a.allow_exact("https://food.grab.com/proxy/foodweb/v2/order/cart")
413            .allow_prefix("https://food.grab.com/proxy/foodweb/v2/order/merchants/");
414        assert!(a.is_allowed("https://food.grab.com/proxy/foodweb/v2/order/cart"));
415        assert!(a.is_allowed(
416            "https://food.grab.com/proxy/foodweb/v2/order/merchants/2-EXAMPLE?latlng=1,2"
417        ));
418        // The order-submission endpoint is never registered for read-only use.
419        assert!(!a.is_allowed("https://food.grab.com/proxy/foodweb/v2/order/orders"));
420        assert!(!a.is_allowed("https://evil.example.com/"));
421    }
422
423    #[test]
424    fn hydra_error_detection() {
425        let v = serde_json::json!({"reason":"invalid_argument","message":"invalid hydra jwt"});
426        assert!(is_hydra_error(reqwest::StatusCode::BAD_REQUEST, &v));
427        assert!(!is_hydra_error(reqwest::StatusCode::OK, &v));
428    }
429
430    #[test]
431    fn parses_set_cookie_with_attrs() {
432        let c = parse_set_cookie_at(
433            "gfc_session=abc.def; Max-Age=7200; Path=/; Domain=food.grab.com; HttpOnly; Secure",
434            "food.grab.com",
435            1_000,
436        )
437        .expect("parse");
438        assert_eq!(c.name, "gfc_session");
439        assert_eq!(c.value, "abc.def");
440        assert_eq!(c.domain, ".food.grab.com");
441        assert_eq!(c.path, "/");
442        assert_eq!(c.expires, 8_200.0);
443        assert!(!c.deleted);
444    }
445
446    #[test]
447    fn parses_set_cookie_minimal_is_host_only_session() {
448        let c = parse_set_cookie_at("k=v", "api.grab.com", 1_000).expect("parse");
449        assert_eq!((c.name.as_str(), c.value.as_str()), ("k", "v"));
450        assert_eq!(c.domain, "api.grab.com");
451        assert_eq!(c.expires, 0.0);
452        assert!(parse_set_cookie("=nope", "food.grab.com").is_none());
453    }
454
455    #[test]
456    fn leading_dot_domain_is_kept_as_domain_cookie() {
457        for raw in [
458            "a=1; Domain=.grab.com",
459            "a=1; Domain=grab.com",
460            "a=1; domain=GRAB.com",
461        ] {
462            let c = parse_set_cookie_at(raw, "partner-api.grab.com", 0).expect("parse");
463            assert_eq!(c.domain, ".grab.com", "{raw}");
464        }
465    }
466
467    #[test]
468    fn expires_attribute_is_read() {
469        // Real shape from food.grab.com/auth/logout (value replaced).
470        let c = parse_set_cookie_at(
471            "gfc_country=X; Max-Age=31536000; Path=/; Expires=Sun, 17 Oct 2027 14:58:04 GMT; HttpOnly",
472            "food.grab.com",
473            1_792_248_684,
474        )
475        .expect("parse");
476        // Max-Age wins over Expires.
477        assert_eq!(c.expires, (1_792_248_684 + 31_536_000) as f64);
478
479        let c = parse_set_cookie_at(
480            "next-i18next=en; path=/; expires=Sun, 17 Oct 2027 14:58:04 GMT",
481            "food.grab.com",
482            0,
483        )
484        .expect("parse");
485        assert_eq!(c.expires, 1_823_785_084.0);
486        assert!(!c.deleted);
487    }
488
489    #[test]
490    fn deletion_by_max_age_or_past_expires() {
491        let now = 1_792_248_684; // 2026-10-17T14:51:24Z, before the 14:58:04 Expires
492        let del = parse_set_cookie_at(
493            "gfc_session=; Max-Age=0; Path=/; Expires=Sat, 17 Oct 2026 14:58:04 GMT",
494            "food.grab.com",
495            now,
496        )
497        .expect("parse");
498        assert!(del.deleted);
499        let past = parse_set_cookie_at(
500            "x=1; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
501            "food.grab.com",
502            now,
503        )
504        .expect("parse");
505        assert!(past.deleted);
506    }
507
508    #[test]
509    fn http_dates() {
510        assert_eq!(parse_http_date("Thu, 01 Jan 1970 00:00:00 GMT"), Some(0));
511        assert_eq!(
512            parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT"),
513            Some(784_111_777)
514        );
515        assert_eq!(
516            parse_http_date("Sunday, 06-Nov-94 08:49:37 GMT"),
517            Some(784_111_777)
518        );
519        assert_eq!(
520            parse_http_date("Sat, 29 Feb 2028 12:00:00 GMT"),
521            Some(1_835_438_400)
522        );
523        assert_eq!(parse_http_date("garbage"), None);
524    }
525}