Skip to main content

objstore_r/
policy.rs

1// Author: spicyzhug@gmail.com
2// Copyright: lituus-io, all rights reserved
3//
4//! Request policies for [`crate::http::Client`] (feature `http`).
5//!
6//! A [`RequestPolicy`] sees every request before any byte is sent:
7//!
8//! - [`check`](RequestPolicy::check) gets the method and the parsed URL,
9//!   exactly as it goes on the wire;
10//! - [`check_query`](RequestPolicy::check_query) gets the SQL text of every
11//!   `BigQuery` query job (`jobs.query`, `jobs.insert`). The client isolates
12//!   that text itself, so no query reaches `BigQuery` unchecked: a body whose
13//!   SQL cannot be isolated is refused under every policy
14//!   ([`PolicyError::UncheckableQuery`]).
15//!
16//! [`Permissive`] (the client's default) allows everything. [`AllowList`]
17//! allows only listed method + host + path-template triples, and no query
18//! unless wrapped by a policy that implements `check_query`.
19//!
20//! Policies are static types (no trait objects); `&P` is a policy whenever
21//! `P` is, so one policy value can serve many clients.
22
23use crate::transport::Method;
24use crate::PolicyError;
25use std::borrow::Cow;
26
27pub use reqwest::Url;
28
29/// Decides which requests a [`crate::http::Client`] may send.
30pub trait RequestPolicy {
31    /// Allow or refuse `method url`. Called once per request, before the
32    /// token is fetched or a connection opened.
33    fn check(&self, method: Method, url: &Url) -> Result<(), PolicyError>;
34
35    /// Allow or refuse the SQL text of a `BigQuery` query job. The default
36    /// refuses every query: this crate has no SQL parser, so a policy that
37    /// permits queries must say which.
38    fn check_query(&self, sql: &str) -> Result<(), PolicyError> {
39        let _ = sql;
40        Err(PolicyError::Query { reason: Cow::Borrowed("this policy allows no queries") })
41    }
42}
43
44impl<P: RequestPolicy> RequestPolicy for &P {
45    fn check(&self, method: Method, url: &Url) -> Result<(), PolicyError> {
46        (**self).check(method, url)
47    }
48    fn check_query(&self, sql: &str) -> Result<(), PolicyError> {
49        (**self).check_query(sql)
50    }
51}
52
53/// Everything allowed: the client's behaviour before request policies
54/// existed (the client's own https-only and redirect rules still apply).
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub struct Permissive;
57
58impl RequestPolicy for Permissive {
59    fn check(&self, _: Method, _: &Url) -> Result<(), PolicyError> {
60        Ok(())
61    }
62    fn check_query(&self, _: &str) -> Result<(), PolicyError> {
63        Ok(())
64    }
65}
66
67/// One allowed endpoint: a method, an exact host, and a path template.
68///
69/// The template is an absolute path whose segments are literals, `{name}`
70/// placeholders, or a placeholder with a custom-method suffix
71/// (`{location}:searchLinks`), e.g.
72/// `/bigquery/v2/projects/{project}/datasets/{dataset}`. A literal (and a
73/// suffix) matches only its identical raw spelling, so a percent-encoded
74/// spelling never matches; a placeholder matches any one non-empty segment
75/// that passes the path rules of [`AllowList`].
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct Rule<'a> {
78    /// The method.
79    pub method: Method,
80    /// The host, lowercase, without a port.
81    pub host: &'a str,
82    /// The path template.
83    pub path: &'a str,
84}
85
86impl<'a> Rule<'a> {
87    /// A rule (validated by [`AllowList::new`]).
88    #[must_use]
89    pub const fn new(method: Method, host: &'a str, path: &'a str) -> Self {
90        Self { method, host, path }
91    }
92}
93
94/// Allows only the listed [`Rule`]s, over `https` on the default port.
95///
96/// Before matching, every URL must pass these rules (each refusal is a
97/// [`PolicyError::Endpoint`]):
98///
99/// - scheme `https` (plain `http` to a loopback host only when built with
100///   `with_loopback_http`, feature `testing`), default port, no
101///   user-info, no fragment;
102/// - every path segment non-empty (no `//`, no trailing `/`), not `.` or
103///   `..` in any spelling, and free of encoded `/`, `\`, NUL or control
104///   bytes, and of characters outside RFC 3986 `pchar`.
105///
106/// The query string is not constrained. Redirects are never followed by the
107/// client, so a listed endpoint cannot hand the request on.
108#[derive(Debug, Clone, Copy)]
109pub struct AllowList<'a> {
110    rules: &'a [Rule<'a>],
111    loopback_http: bool,
112}
113
114impl<'a> AllowList<'a> {
115    /// An allow list of `rules`; refuses a malformed host or template.
116    pub fn new(rules: &'a [Rule<'a>]) -> crate::Result<Self> {
117        for r in rules {
118            if !valid_host(r.host) {
119                return Err(crate::Error::protocol("allow list: invalid host"));
120            }
121            if !valid_template(r.path) {
122                return Err(crate::Error::protocol("allow list: invalid path template"));
123            }
124        }
125        Ok(Self { rules, loopback_http: false })
126    }
127
128    /// Also accept plain `http` to a loopback host (`127.0.0.1`, `[::1]`,
129    /// `localhost`, any port), which then stands in for every listed host:
130    /// emulators and stub servers in tests.
131    #[cfg(feature = "testing")]
132    #[must_use]
133    pub fn with_loopback_http(mut self) -> Self {
134        self.loopback_http = true;
135        self
136    }
137
138    /// The rules.
139    #[must_use]
140    pub fn rules(&self) -> &'a [Rule<'a>] {
141        self.rules
142    }
143}
144
145impl RequestPolicy for AllowList<'_> {
146    fn check(&self, method: Method, url: &Url) -> Result<(), PolicyError> {
147        let refuse = |reason| Err(PolicyError::Endpoint { reason });
148        let host = url.host_str().unwrap_or("");
149        let loopback = match url.scheme() {
150            "https" => {
151                if url.port().is_some() {
152                    return refuse("non-default port");
153                }
154                false
155            }
156            "http" if self.loopback_http && is_loopback_host(host) => true,
157            _ => return refuse("scheme must be https"),
158        };
159        if !url.username().is_empty() || url.password().is_some() {
160            return refuse("credentials in the URL");
161        }
162        if url.fragment().is_some() {
163            return refuse("fragment");
164        }
165        let path = url.path();
166        if !valid_path(path) {
167            return refuse("path is not normalised");
168        }
169        let hit = self.rules.iter().any(|r| {
170            r.method == method
171                && (loopback || r.host.eq_ignore_ascii_case(host))
172                && template_matches(r.path, path)
173        });
174        if hit {
175            Ok(())
176        } else {
177            refuse("not on the allow list")
178        }
179    }
180}
181
182fn is_loopback_host(host: &str) -> bool {
183    matches!(host, "127.0.0.1" | "[::1]" | "localhost")
184}
185
186fn valid_host(h: &str) -> bool {
187    !h.is_empty()
188        && h.len() <= 253
189        && h.split('.').all(|l| {
190            !l.is_empty()
191                && !l.starts_with('-')
192                && !l.ends_with('-')
193                && l.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
194        })
195}
196
197/// For a placeholder segment, `{name}` or `{name}:verb` (a Google custom
198/// method), its literal suffix (empty for a bare placeholder); `None` for a
199/// literal segment.
200fn placeholder_suffix(seg: &str) -> Option<&str> {
201    let rest = seg.strip_prefix('{')?;
202    let (name, suffix) = rest.split_at(rest.find('}')?);
203    let suffix = &suffix[1..];
204    let name_ok = !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
205    let suffix_ok = suffix.is_empty()
206        || (suffix.len() > 1 && suffix.starts_with(':') && suffix.bytes().all(is_pchar));
207    (name_ok && suffix_ok).then_some(suffix)
208}
209
210fn valid_template(t: &str) -> bool {
211    let Some(rest) = t.strip_prefix('/') else { return false };
212    rest.split('/').all(|seg| {
213        placeholder_suffix(seg).is_some()
214            || (!seg.is_empty()
215                && seg != "."
216                && seg != ".."
217                && seg.bytes().all(|b| is_pchar(b) && b != b'%'))
218    })
219}
220
221/// RFC 3986 `pchar` minus `%` (checked separately): unreserved, sub-delims,
222/// `:` and `@`.
223fn is_pchar(b: u8) -> bool {
224    PCHAR[usize::from(b)]
225}
226
227const PCHAR: [bool; 256] = {
228    let mut t = [false; 256];
229    let mut i = 0;
230    while i < 256 {
231        let b = i as u8;
232        t[i] = b.is_ascii_alphanumeric();
233        i += 1;
234    }
235    let extra = b"-._~!$&'()*+,;=:@";
236    let mut k = 0;
237    while k < extra.len() {
238        t[extra[k] as usize] = true;
239        k += 1;
240    }
241    t
242};
243
244fn hex(b: u8) -> Option<u8> {
245    match b {
246        b'0'..=b'9' => Some(b - b'0'),
247        b'a'..=b'f' => Some(b - b'a' + 10),
248        b'A'..=b'F' => Some(b - b'A' + 10),
249        _ => None,
250    }
251}
252
253/// One path segment, percent-decoded on the fly (no allocation); `None` at
254/// a malformed escape.
255fn decoded(b: &[u8]) -> impl Iterator<Item = Option<u8>> + '_ {
256    let mut i = 0;
257    std::iter::from_fn(move || {
258        let c = *b.get(i)?;
259        if c != b'%' {
260            i += 1;
261            return Some(Some(c));
262        }
263        let v = b.get(i + 1).and_then(|&h| hex(h)).zip(b.get(i + 2).and_then(|&l| hex(l)));
264        i += 3;
265        Some(v.map(|(h, l)| h << 4 | l))
266    })
267}
268
269fn valid_segment(seg: &[u8]) -> bool {
270    let (mut escaped, mut dots) = (false, 0usize);
271    for &b in seg {
272        escaped |= b == b'%';
273        dots += usize::from(b == b'.');
274        if b != b'%' && !is_pchar(b) {
275            return false;
276        }
277    }
278    if !escaped {
279        return !(seg.len() <= 2 && dots == seg.len());
280    }
281    let (mut len, mut dots) = (0usize, 0usize);
282    for c in decoded(seg) {
283        let Some(c) = c else { return false };
284        if c == b'/' || c == b'\\' || c < 0x20 || c == 0x7f {
285            return false;
286        }
287        len += 1;
288        dots += usize::from(c == b'.');
289    }
290    !(len <= 2 && dots == len)
291}
292
293/// Segments of an absolute path, without the leading `/`.
294fn segments(path: &[u8]) -> impl Iterator<Item = &[u8]> + '_ {
295    path.get(1..).unwrap_or_default().split(|&b| b == b'/')
296}
297
298fn valid_path(path: &str) -> bool {
299    path.starts_with('/') && segments(path.as_bytes()).all(valid_segment)
300}
301
302fn template_matches(template: &str, path: &str) -> bool {
303    let mut t = segments(template.as_bytes());
304    let mut p = segments(path.as_bytes());
305    loop {
306        let (ts, ps) = match (t.next(), p.next()) {
307            (None, None) => return true,
308            (Some(ts), Some(ps)) => (ts, ps),
309            _ => return false,
310        };
311        let hit = if ts.first() == Some(&b'{') {
312            // Validated at construction: `{name}` then an optional suffix.
313            let close = ts.iter().position(|&b| b == b'}').unwrap_or(ts.len() - 1);
314            let sfx = &ts[close + 1..];
315            ps.strip_suffix(sfx)
316                .is_some_and(|v| !v.is_empty() && (sfx.is_empty() || valid_segment(v)))
317        } else {
318            ts == ps
319        };
320        if !hit {
321            return false;
322        }
323    }
324}
325
326// ---------------------------------------------------------------------------
327// The BigQuery query hook (applied by the client under every policy).
328
329/// The segment, percent-decoded, up to any `;` or `:`, compared to the
330/// lowercase `word` ignoring ASCII case.
331fn segment_is(seg: &str, word: &str) -> bool {
332    let mut w = word.bytes();
333    for c in decoded(seg.as_bytes()) {
334        match c {
335            Some(b';' | b':') => break,
336            Some(c) if w.next() == Some(c.to_ascii_lowercase()) => {}
337            _ => return false,
338        }
339    }
340    w.next().is_none()
341}
342
343fn is_bigquery(url: &Url) -> bool {
344    let host = url.host_str().unwrap_or("");
345    host.get(..8).is_some_and(|h| h.eq_ignore_ascii_case("bigquery"))
346        || url.path().split('/').any(|s| segment_is(s, "bigquery"))
347}
348
349#[derive(serde::Deserialize)]
350struct QueryRequest<'a> {
351    #[serde(borrow)]
352    query: Cow<'a, str>,
353    #[serde(rename = "useLegacySql", alias = "use_legacy_sql")]
354    use_legacy_sql: Option<bool>,
355}
356
357#[derive(serde::Deserialize)]
358struct Job<'a> {
359    #[serde(borrow)]
360    configuration: JobConfiguration<'a>,
361}
362
363#[derive(serde::Deserialize)]
364struct JobConfiguration<'a> {
365    #[serde(borrow)]
366    query: Option<QueryConfig<'a>>,
367    load: Option<serde::de::IgnoredAny>,
368    copy: Option<serde::de::IgnoredAny>,
369    extract: Option<serde::de::IgnoredAny>,
370}
371
372#[derive(serde::Deserialize)]
373struct QueryConfig<'a> {
374    #[serde(borrow)]
375    query: Cow<'a, str>,
376    #[serde(rename = "useLegacySql", alias = "use_legacy_sql")]
377    use_legacy_sql: Option<bool>,
378    #[serde(rename = "tableDefinitions", alias = "table_definitions")]
379    table_definitions: Option<serde::de::IgnoredAny>,
380    #[serde(rename = "userDefinedFunctionResources", alias = "user_defined_function_resources")]
381    udf_resources: Option<serde::de::IgnoredAny>,
382}
383
384fn uncheckable(reason: &'static str) -> PolicyError {
385    PolicyError::UncheckableQuery { reason }
386}
387
388fn standard_sql(sql: Cow<'_, str>, legacy: Option<bool>) -> Result<Cow<'_, str>, PolicyError> {
389    if legacy != Some(false) {
390        return Err(uncheckable("useLegacySql must be false"));
391    }
392    let first = sql.trim_start().lines().next().unwrap_or("");
393    if first.starts_with('#') && first.to_ascii_lowercase().contains("legacysql") {
394        return Err(uncheckable("a #legacySQL prefix"));
395    }
396    Ok(sql)
397}
398
399/// The SQL text a request carries to `BigQuery`, if it is a query job:
400/// `Ok(None)` for every other request, `Err` when it targets a query
401/// endpoint but its SQL cannot be isolated. Duplicate or aliased keys are
402/// refused, never resolved, so the text checked is the text run.
403pub(crate) fn query_sql<'b>(
404    method: Method,
405    url: &Url,
406    body: &'b [u8],
407) -> Result<Option<Cow<'b, str>>, PolicyError> {
408    if (method != Method::Post && body.is_empty()) || !is_bigquery(url) {
409        return Ok(None);
410    }
411    let path = url.path();
412    if path.split('/').any(|s| segment_is(s, "batch")) {
413        return Err(uncheckable("batch requests"));
414    }
415    let Some(last) = path.split('/').rev().find(|s| !s.is_empty()) else { return Ok(None) };
416    if segment_is(last, "queries") {
417        let q: QueryRequest<'_> =
418            serde_json::from_slice(body).map_err(|_| uncheckable("not a jobs.query body"))?;
419        standard_sql(q.query, q.use_legacy_sql).map(Some)
420    } else if segment_is(last, "jobs") {
421        let j: Job<'_> =
422            serde_json::from_slice(body).map_err(|_| uncheckable("not a jobs.insert body"))?;
423        let c = j.configuration;
424        let (Some(q), None, None, None) = (c.query, c.load, c.copy, c.extract) else {
425            return Err(uncheckable("not exactly one query job"));
426        };
427        if q.table_definitions.is_some() || q.udf_resources.is_some() {
428            return Err(uncheckable("external tables or UDF resources"));
429        }
430        standard_sql(q.query, q.use_legacy_sql).map(Some)
431    } else {
432        Ok(None)
433    }
434}
435
436/// Fuzzing entry point: arbitrary bytes as a URL plus a redirect
437/// `Location`. Every URL the allow list accepts, directly or as a redirect
438/// target, must independently satisfy its invariants; nothing panics.
439#[doc(hidden)]
440pub fn fuzz(input: &[u8]) {
441    const RULES: &[Rule<'static>] = &[
442        Rule::new(Method::Get, "bigquery.googleapis.com", "/bigquery/v2/projects/{p}/datasets/{d}"),
443        Rule::new(Method::Post, "bigquery.googleapis.com", "/bigquery/v2/projects/{p}/queries"),
444        Rule::new(Method::Get, "storage.googleapis.com", "/storage/v1/b/{b}/o/{o}"),
445    ];
446    let Ok(s) = std::str::from_utf8(input) else { return };
447    let (raw, location) = s.split_once('\n').unwrap_or((s, ""));
448    let list = AllowList::new(RULES).expect("valid rules");
449    let Ok(url) = Url::parse(raw) else { return };
450    let verify = |u: &Url| {
451        for m in [Method::Get, Method::Post, Method::Put, Method::Delete, Method::Patch] {
452            if list.check(m, u).is_ok() {
453                assert_eq!(u.scheme(), "https");
454                assert!(u.port().is_none() && u.username().is_empty() && u.fragment().is_none());
455                let host = u.host_str().unwrap_or_default();
456                let path = u.path();
457                assert!(!path.contains("//") && !path.ends_with('/'));
458                for seg in path[1..].split('/') {
459                    let lower = seg.to_ascii_lowercase();
460                    assert!(
461                        !lower.contains("%2f") && !lower.contains("%5c") && !lower.contains("%00")
462                    );
463                    assert!(!matches!(
464                        lower.as_str(),
465                        "." | ".." | "%2e" | "%2e%2e" | ".%2e" | "%2e."
466                    ));
467                }
468                assert!(RULES.iter().any(|r| r.method == m
469                    && r.host == host
470                    && r.path.split('/').count() == path.split('/').count()));
471            }
472        }
473        let _ = query_sql(Method::Post, u, location.as_bytes());
474    };
475    verify(&url);
476    // A redirect is never followed; even if a caller did, the target must
477    // face the same check. Only an https request can receive one: the client
478    // sends nothing else, and resolving against other schemes (`file:` with
479    // control bytes) trips a debug assertion inside `url` itself.
480    if url.scheme() != "https" {
481        return;
482    }
483    if let Ok(next) = url.join(location) {
484        verify(&next);
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    const H: &str = "bigquery.googleapis.com";
493    const RULES: &[Rule<'static>] = &[
494        Rule::new(Method::Get, H, "/bigquery/v2/projects/{project}/datasets/{dataset}"),
495        Rule::new(Method::Get, H, "/bigquery/v2/projects/{p}/datasets/{d}/tables/{t}"),
496        Rule::new(Method::Post, H, "/bigquery/v2/projects/{project}/queries"),
497    ];
498
499    fn list() -> AllowList<'static> {
500        AllowList::new(RULES).unwrap()
501    }
502
503    fn verdict(m: Method, u: &str) -> Result<(), PolicyError> {
504        list().check(m, &Url::parse(u).unwrap())
505    }
506
507    fn reason(m: Method, u: &str) -> &'static str {
508        match verdict(m, u) {
509            Err(PolicyError::Endpoint { reason }) => reason,
510            other => panic!("{u}: {other:?}"),
511        }
512    }
513
514    #[test]
515    fn listed_requests_pass() {
516        let base = "https://bigquery.googleapis.com/bigquery/v2/projects";
517        verdict(Method::Get, &format!("{base}/p/datasets/d")).unwrap();
518        verdict(Method::Get, &format!("{base}/my-proj/datasets/d_1/tables/t?view=BASIC")).unwrap();
519        verdict(Method::Post, &format!("{base}/p/queries")).unwrap();
520        verdict(Method::Get, "https://BigQuery.GoogleAPIs.com/bigquery/v2/projects/p/datasets/d")
521            .unwrap();
522        verdict(Method::Get, &format!("{base}/a%20b/datasets/d")).unwrap();
523    }
524
525    #[test]
526    fn the_policy_matrix_refuses_everything_else() {
527        let base = "https://bigquery.googleapis.com/bigquery/v2/projects";
528        let cases: &[(Method, String, &str)] = &[
529            (Method::Delete, format!("{base}/p/datasets/d"), "not on the allow list"),
530            (Method::Post, format!("{base}/p/datasets/d"), "not on the allow list"),
531            (Method::Get, format!("{base}/p/queries"), "not on the allow list"),
532            (Method::Get, format!("{base}/p/datasets/d/tables/t/data"), "not on the allow list"),
533            (Method::Get, format!("{base}/p/datasets"), "not on the allow list"),
534            (
535                Method::Get,
536                "https://evil.example/bigquery/v2/projects/p/datasets/d".into(),
537                "not on the allow list",
538            ),
539            (
540                Method::Get,
541                "https://bigquery.googleapis.com.evil.example/bigquery/v2/projects/p/datasets/d"
542                    .into(),
543                "not on the allow list",
544            ),
545            (
546                Method::Get,
547                "http://bigquery.googleapis.com/bigquery/v2/projects/p/datasets/d".into(),
548                "scheme must be https",
549            ),
550            (
551                Method::Get,
552                "http://127.0.0.1:9/bigquery/v2/projects/p/datasets/d".into(),
553                "scheme must be https",
554            ),
555            (
556                Method::Get,
557                "https://bigquery.googleapis.com:8443/bigquery/v2/projects/p/datasets/d".into(),
558                "non-default port",
559            ),
560            (
561                Method::Get,
562                "https://u:pw@bigquery.googleapis.com/bigquery/v2/projects/p/datasets/d".into(),
563                "credentials in the URL",
564            ),
565            (Method::Get, format!("{base}/p/datasets/d#x"), "fragment"),
566            (Method::Get, format!("{base}/p%2Fx/datasets/d"), "path is not normalised"),
567            (Method::Get, format!("{base}/p%2fx/datasets/d"), "path is not normalised"),
568            (Method::Get, format!("{base}/p%5Cx/datasets/d"), "path is not normalised"),
569            (Method::Get, format!("{base}/p%00/datasets/d"), "path is not normalised"),
570            (Method::Get, format!("{base}/p%0a/datasets/d"), "path is not normalised"),
571            (Method::Get, format!("{base}/p%zz/datasets/d"), "path is not normalised"),
572            (Method::Get, format!("{base}//datasets/d"), "path is not normalised"),
573            (Method::Get, format!("{base}/p/datasets/d/"), "path is not normalised"),
574            (Method::Get, format!("{base}/p/%64atasets/d"), "not on the allow list"),
575        ];
576        for (m, u, want) in cases {
577            assert_eq!(reason(*m, u), *want, "{m:?} {u}");
578        }
579    }
580
581    #[test]
582    fn dot_segments_never_escape_a_template() {
583        // The URL parser resolves dot segments (in any spelling) before the
584        // check, so what is checked is what is sent.
585        let u = "https://bigquery.googleapis.com/bigquery/v2/projects/p/datasets/d/tables/%2e%2e/%2E%2E/x";
586        assert_eq!(Url::parse(u).unwrap().path(), "/bigquery/v2/projects/p/datasets/x");
587        verdict(Method::Get, u).unwrap();
588        let out = "https://bigquery.googleapis.com/bigquery/v2/projects/p/datasets/d/../../../../../../storage/v1/b/x";
589        assert_eq!(Url::parse(out).unwrap().path(), "/storage/v1/b/x");
590        assert_eq!(reason(Method::Get, out), "not on the allow list");
591        // Segments that are dots only, in any spelling, are refused outright.
592        for s in [".", "..", "%2e", "%2E%2e", ".%2e"] {
593            assert!(!valid_segment(s.as_bytes()), "{s}");
594        }
595        assert!(["...", ".a", "a%2e"].iter().all(|s| valid_segment(s.as_bytes())));
596    }
597
598    #[test]
599    fn custom_method_suffixes_match_exactly() {
600        let rules = [Rule::new(
601            Method::Post,
602            "datalineage.googleapis.com",
603            "/v1/projects/{p}/locations/{l}:searchLinks",
604        )];
605        let l = AllowList::new(&rules).unwrap();
606        let at = |p: &str| {
607            l.check(
608                Method::Post,
609                &Url::parse(&format!("https://datalineage.googleapis.com{p}")).unwrap(),
610            )
611        };
612        at("/v1/projects/p/locations/us:searchLinks").unwrap();
613        for bad in [
614            "/v1/projects/p/locations/:searchLinks",
615            "/v1/projects/p/locations/us:searchLinksX",
616            "/v1/projects/p/locations/us:batchSearchLinkProcesses",
617            "/v1/projects/p/locations/us",
618            "/v1/projects/p/locations/..:searchLinks",
619            "/v1/projects/p/locations/us%3AsearchLinks",
620        ] {
621            assert!(at(bad).is_err(), "{bad}");
622        }
623    }
624
625    #[test]
626    fn malformed_rules_are_refused() {
627        for (h, p) in [
628            ("Bigquery.googleapis.com", "/x"),
629            ("bigquery.googleapis.com:443", "/x"),
630            ("", "/x"),
631            ("h", "x"),
632            ("h", "/x//y"),
633            ("h", "/x/"),
634            ("h", "/x/../y"),
635            ("h", "/x/%2e"),
636            ("h", "/{}"),
637            ("h", "/{a b}"),
638            ("h", "/{a}x"),
639            ("h", "/{a}:"),
640            ("h", "/{a}:{b}"),
641            ("h", "/x:{a}"),
642        ] {
643            assert!(AllowList::new(&[Rule::new(Method::Get, h, p)]).is_err(), "{h} {p}");
644        }
645    }
646
647    #[test]
648    fn allow_lists_allow_no_queries_and_permissive_allows_all() {
649        assert!(matches!(list().check_query("SELECT 1"), Err(PolicyError::Query { .. })));
650        let u = Url::parse("ftp://anything/at/all").unwrap();
651        assert_eq!(Permissive.check(Method::Delete, &u), Ok(()));
652        assert_eq!(Permissive.check_query("DROP TABLE x"), Ok(()));
653        let l = list();
654        let borrowed: &AllowList<'_> = &l;
655        let verdict = <&AllowList<'_> as RequestPolicy>::check(&borrowed, Method::Get, &u);
656        assert!(verdict.is_err(), "a borrowed policy is the policy");
657    }
658
659    fn sql(m: Method, u: &str, body: &str) -> Result<Option<String>, PolicyError> {
660        query_sql(m, &Url::parse(u).unwrap(), body.as_bytes()).map(|o| o.map(Cow::into_owned))
661    }
662
663    const Q: &str = "https://bigquery.googleapis.com/bigquery/v2/projects/p/queries";
664    const J: &str = "https://bigquery.googleapis.com/bigquery/v2/projects/p/jobs";
665
666    #[test]
667    fn query_text_is_isolated_from_both_job_endpoints() {
668        let body = r#"{"query":"SELECT 1","useLegacySql":false,"location":"US"}"#;
669        assert_eq!(sql(Method::Post, Q, body).unwrap().as_deref(), Some("SELECT 1"));
670        let esc = r#"{"query":"SELECT \"a\" ","useLegacySql":false}"#;
671        assert_eq!(sql(Method::Post, Q, esc).unwrap().as_deref(), Some("SELECT \"a\" "));
672        let job = r#"{"configuration":{"query":{"query":"SELECT 2","useLegacySql":false}}}"#;
673        assert_eq!(sql(Method::Post, J, job).unwrap().as_deref(), Some("SELECT 2"));
674        // Any spelling of the endpoint, any host, a loopback stand-in.
675        for u in [
676            "https://bigquery.googleapis.com/bigquery/v2/projects/p/queries/",
677            "https://x.example/%62igQuery/v2/projects/p/%71ueries",
678            "http://127.0.0.1:1/bigquery/v2/projects/p/queries;x=y",
679            "https://bigquery.us-east4.rep.googleapis.com/v2/projects/p/queries",
680        ] {
681            assert_eq!(sql(Method::Post, u, body).unwrap().as_deref(), Some("SELECT 1"), "{u}");
682        }
683    }
684
685    #[test]
686    fn uncheckable_query_bodies_are_refused() {
687        let bad = |u: &str, b: &str| {
688            assert!(
689                matches!(sql(Method::Post, u, b), Err(PolicyError::UncheckableQuery { .. })),
690                "{u} {b}"
691            );
692        };
693        bad(Q, "");
694        bad(Q, "SELECT 1");
695        bad(Q, r#"{"query":"SELECT 1"}"#); // legacy SQL is the API default
696        bad(Q, r#"{"query":"SELECT 1","useLegacySql":true}"#);
697        bad(Q, r##"{"query":"#legacySQL\nSELECT 1","useLegacySql":false}"##);
698        bad(Q, r#"{"query":"SELECT 1","query":"SELECT 2","useLegacySql":false}"#);
699        bad(Q, r#"{"query":"SELECT 1","useLegacySql":false,"use_legacy_sql":true}"#);
700        bad(Q, r#"{"query":null,"useLegacySql":false}"#);
701        bad(Q, "\u{feff}{\"query\":\"SELECT 1\",\"useLegacySql\":false}");
702        bad(J, r#"{"configuration":{"extract":{}}}"#);
703        bad(J, r#"{"configuration":{"load":{}}}"#);
704        bad(
705            J,
706            r#"{"configuration":{"query":{"query":"SELECT 1","useLegacySql":false},"extract":{}}}"#,
707        );
708        bad(
709            J,
710            r#"{"configuration":{"query":{"query":"SELECT 1","useLegacySql":false,"tableDefinitions":{}}}}"#,
711        );
712        bad(
713            J,
714            r#"{"configuration":{"query":{"query":"SELECT 1","useLegacySql":false,"user_defined_function_resources":[]}}}"#,
715        );
716        bad("https://bigquery.googleapis.com/batch/bigquery/v2", "--b\r\nPOST /x");
717        bad("https://upload.example/upload/bigquery/v2/projects/p/jobs", "--multipart");
718        // Any method that carries a body is inspected.
719        assert!(sql(Method::Put, Q, "x").is_err());
720    }
721
722    #[test]
723    fn other_requests_carry_no_query() {
724        assert_eq!(sql(Method::Get, Q, ""), Ok(None), "getQueryResults");
725        assert_eq!(sql(Method::Get, J, ""), Ok(None), "jobs.list");
726        assert_eq!(sql(Method::Post, &format!("{J}/abc/cancel"), ""), Ok(None));
727        let vertex = "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/l/jobs";
728        assert_eq!(sql(Method::Post, vertex, "{}"), Ok(None));
729        assert_eq!(sql(Method::Post, "https://storage.googleapis.com/b/queries", "x"), Ok(None));
730    }
731
732    #[test]
733    fn the_fuzz_entry_point_accepts_known_inputs() {
734        for s in [
735            "https://bigquery.googleapis.com/bigquery/v2/projects/p/queries\n/x/../y",
736            "https://bigquery.googleapis.com/bigquery/v2/projects/p/datasets/d\nhttps://evil.example/",
737            "not a url",
738            "",
739        ] {
740            fuzz(s.as_bytes());
741        }
742    }
743}