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