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::{
11    active, credential, guard_http, is_redacted_header, redact_json_text, redact_keys,
12    response_limit,
13};
14
15const VERBS: &[&str] = &["get", "post", "put", "patch", "delete"];
16
17/// Pulls the method, the URL, and which argument the URL came from — the one
18/// argument credential substitution must leave alone.
19type Target = Arc<dyn Fn(&MultiValue) -> Option<(String, String, usize)>>;
20
21pub fn apply(lua: &Lua) -> mlua::Result<()> {
22    guard_os_getenv(lua)?;
23    let Some(http) = lua.globals().get::<Option<Table>>("http")? else {
24        return Ok(());
25    };
26    for verb in VERBS {
27        wrap(lua, &http, verb, verb_target(verb))?;
28    }
29    // Client wrappers route every verb through `_client_request`, so guarding
30    // the top-level verbs alone would leave that path open.
31    wrap(lua, &http, "_client_request", client_request_target())?;
32    wrap(lua, &http, "download", verb_target("get"))?;
33    Ok(())
34}
35
36/// `env.allow` decides what the environment shows, and `os.getenv` reads the
37/// same environment by another name. assay's `os` has no `getenv`, so this
38/// only ever bites on Lua's own table behind `require("os")` — which is
39/// exactly where an allowlisted VM was handing out every variable it held.
40/// A hidden key reads as absent, matching `env.get`: presence is itself
41/// information.
42fn guard_os_getenv(lua: &Lua) -> mlua::Result<()> {
43    for os_table in crate::lua::builtins::gated::tables_for(lua, "os")? {
44        let Some(inner) = os_table.get::<Option<mlua::Function>>("getenv")? else {
45            continue;
46        };
47        let wrapper = lua.create_function(move |lua, args: mlua::MultiValue| {
48            let name = match args.iter().next() {
49                Some(Value::String(s)) => s.to_str()?.to_string(),
50                _ => return inner.call::<Value>(args),
51            };
52            if !crate::lua::policy::env_visible(lua, &name) {
53                return Ok(Value::Nil);
54            }
55            inner.call::<Value>(args)
56        })?;
57        os_table.set("getenv", wrapper)?;
58    }
59    Ok(())
60}
61
62fn verb_target(verb: &'static str) -> Target {
63    Arc::new(move |args| arg_string(args, 0).map(|url| (verb.to_string(), url, 0)))
64}
65
66fn client_request_target() -> Target {
67    Arc::new(|args| Some((arg_string(args, 1)?, arg_string(args, 2)?, 2)))
68}
69
70fn arg_string(args: &MultiValue, at: usize) -> Option<String> {
71    match args.iter().nth(at) {
72        Some(Value::String(s)) => s.to_str().ok().map(|s| s.to_string()),
73        _ => None,
74    }
75}
76
77fn wrap(lua: &Lua, http: &Table, name: &str, target: Target) -> mlua::Result<()> {
78    let Value::Function(inner) = http.get::<Value>(name)? else {
79        return Ok(());
80    };
81    let wrapper = lua.create_async_function(move |lua, args: MultiValue| {
82        let inner = inner.clone();
83        let target = Arc::clone(&target);
84        async move {
85            let mut args = args;
86            if let Some((method, url, url_index)) = target(&args) {
87                credential::reject_in_url(&url)?;
88                guard_http(&lua, &method, &url)?;
89                args = fill_credentials(&lua, args, url_index)?;
90            }
91            let result = inner.call_async::<Value>(args).await?;
92            sanitize(&lua, result)
93        }
94    })?;
95    http.set(name, wrapper)
96}
97
98/// Swap credential placeholders for real values, everywhere except the URL.
99/// This runs after the target check, so a secret is only ever materialised
100/// for a request the policy has already allowed.
101fn fill_credentials(lua: &Lua, args: MultiValue, url_index: usize) -> mlua::Result<MultiValue> {
102    let Some(policy) = active(lua) else {
103        return Ok(args);
104    };
105    if policy.credentials.is_empty() {
106        return Ok(args);
107    }
108    let mut out = Vec::with_capacity(args.len());
109    for (i, value) in args.into_iter().enumerate() {
110        out.push(if i == url_index {
111            value
112        } else {
113            credential::substitute(lua, &policy, value)?
114        });
115    }
116    Ok(MultiValue::from_iter(out))
117}
118
119/// Enforce the size cap and strip declared keys before the response reaches
120/// the script. The transport has already buffered the body, so the cap is a
121/// disclosure control rather than a memory bound.
122fn sanitize(lua: &Lua, result: Value) -> mlua::Result<Value> {
123    let Value::Table(table) = &result else {
124        return Ok(result);
125    };
126    if let Some(limit) = response_limit(lua)
127        && let Ok(body) = table.get::<mlua::String>("body")
128        && body.as_bytes().len() > limit
129    {
130        return Err(mlua::Error::runtime(format!(
131            "policy: response body exceeds max_response_bytes ({limit})"
132        )));
133    }
134
135    let keys = redact_keys(lua);
136    if keys.is_empty() {
137        return Ok(result);
138    }
139    redact_body(lua, table, &keys)?;
140    redact_headers(table, &keys)?;
141    Ok(result)
142}
143
144fn redact_body(lua: &Lua, table: &Table, keys: &[String]) -> mlua::Result<()> {
145    let Ok(body) = table.get::<mlua::String>("body") else {
146        return Ok(());
147    };
148    let bytes = body.as_bytes();
149    let Some(redacted) = std::str::from_utf8(&bytes)
150        .ok()
151        .and_then(|text| redact_json_text(text, keys))
152    else {
153        return Ok(());
154    };
155    table.set("body", lua.create_string(redacted.as_bytes())?)
156}
157
158fn redact_headers(table: &Table, keys: &[String]) -> mlua::Result<()> {
159    let Ok(headers) = table.get::<Table>("headers") else {
160        return Ok(());
161    };
162    let names: Vec<String> = headers
163        .clone()
164        .pairs::<String, Value>()
165        .filter_map(|pair| pair.ok().map(|(name, _)| name))
166        .filter(|name| is_redacted_header(name, keys))
167        .collect();
168    for name in names {
169        headers.set(name, "[redacted]")?;
170    }
171    Ok(())
172}