Skip to main content

assay/lua/policy/
apply.rs

1//! Wraps the HTTP builtins with policy guards after registration, the same
2//! shape `readonly` and `approval` use. Enforcement lives here rather than
3//! inside the builtins so one place decides, and the transport code stays
4//! unaware of who is allowed to call it.
5
6use std::sync::Arc;
7
8use mlua::{Lua, MultiValue, Table, Value};
9
10use super::{guard_http, is_redacted_header, redact_json_text, redact_keys, response_limit};
11
12const VERBS: &[&str] = &["get", "post", "put", "patch", "delete"];
13
14/// Pulls the (method, URL) pair a given builtin puts in its arguments.
15type Target = Arc<dyn Fn(&MultiValue) -> Option<(String, String)>>;
16
17pub fn apply(lua: &Lua) -> mlua::Result<()> {
18    let Some(http) = lua.globals().get::<Option<Table>>("http")? else {
19        return Ok(());
20    };
21    for verb in VERBS {
22        wrap(lua, &http, verb, verb_target(verb))?;
23    }
24    // Client wrappers route every verb through `_client_request`, so guarding
25    // the top-level verbs alone would leave that path open.
26    wrap(lua, &http, "_client_request", client_request_target())?;
27    wrap(lua, &http, "download", verb_target("get"))?;
28    Ok(())
29}
30
31fn verb_target(verb: &'static str) -> Target {
32    Arc::new(move |args| arg_string(args, 0).map(|url| (verb.to_string(), url)))
33}
34
35fn client_request_target() -> Target {
36    Arc::new(|args| Some((arg_string(args, 1)?, arg_string(args, 2)?)))
37}
38
39fn arg_string(args: &MultiValue, at: usize) -> Option<String> {
40    match args.iter().nth(at) {
41        Some(Value::String(s)) => s.to_str().ok().map(|s| s.to_string()),
42        _ => None,
43    }
44}
45
46fn wrap(lua: &Lua, http: &Table, name: &str, target: Target) -> mlua::Result<()> {
47    let Value::Function(inner) = http.get::<Value>(name)? else {
48        return Ok(());
49    };
50    let wrapper = lua.create_async_function(move |lua, args: MultiValue| {
51        let inner = inner.clone();
52        let target = Arc::clone(&target);
53        async move {
54            if let Some((method, url)) = target(&args) {
55                guard_http(&lua, &method, &url)?;
56            }
57            let result = inner.call_async::<Value>(args).await?;
58            sanitize(&lua, result)
59        }
60    })?;
61    http.set(name, wrapper)
62}
63
64/// Enforce the size cap and strip declared keys before the response reaches
65/// the script. The transport has already buffered the body, so the cap is a
66/// disclosure control rather than a memory bound.
67fn sanitize(lua: &Lua, result: Value) -> mlua::Result<Value> {
68    let Value::Table(table) = &result else {
69        return Ok(result);
70    };
71    if let Some(limit) = response_limit(lua)
72        && let Ok(body) = table.get::<mlua::String>("body")
73        && body.as_bytes().len() > limit
74    {
75        return Err(mlua::Error::runtime(format!(
76            "policy: response body exceeds max_response_bytes ({limit})"
77        )));
78    }
79
80    let keys = redact_keys(lua);
81    if keys.is_empty() {
82        return Ok(result);
83    }
84    redact_body(lua, table, &keys)?;
85    redact_headers(table, &keys)?;
86    Ok(result)
87}
88
89fn redact_body(lua: &Lua, table: &Table, keys: &[String]) -> mlua::Result<()> {
90    let Ok(body) = table.get::<mlua::String>("body") else {
91        return Ok(());
92    };
93    let bytes = body.as_bytes();
94    let Some(redacted) = std::str::from_utf8(&bytes)
95        .ok()
96        .and_then(|text| redact_json_text(text, keys))
97    else {
98        return Ok(());
99    };
100    table.set("body", lua.create_string(redacted.as_bytes())?)
101}
102
103fn redact_headers(table: &Table, keys: &[String]) -> mlua::Result<()> {
104    let Ok(headers) = table.get::<Table>("headers") else {
105        return Ok(());
106    };
107    let names: Vec<String> = headers
108        .clone()
109        .pairs::<String, Value>()
110        .filter_map(|pair| pair.ok().map(|(name, _)| name))
111        .filter(|name| is_redacted_header(name, keys))
112        .collect();
113    for name in names {
114        headers.set(name, "[redacted]")?;
115    }
116    Ok(())
117}