Files
sausage/host/src/cred/inject.rs
T

206 lines
7.7 KiB
Rust

//! Transform one outgoing request. Never expose script exception text to the world.
use super::{
error::{Error, Result},
items::Item,
requests::GrantRequest,
script::{Limits, Script},
store::Cred,
types::Caller,
};
use http::request::Parts;
use serde_json::{Value, json};
pub enum Outcome {
Forward(Parts),
Denied { item: String, request: String },
}
fn matches(item: &Item, p: &Parts) -> bool {
matches_rules(&item.rules, p)
}
fn matches_rules(rules: &[super::items::Rule], p: &Parts) -> bool {
if !safe_uri(&p.uri) {
return false;
}
rules.iter().any(|r| {
super::items::rule_matches_port(
r,
p.uri.scheme_str().unwrap_or(""),
p.uri.host().unwrap_or(""),
p.uri.path(),
p.method.as_str(),
p.uri.port_u16(),
)
})
}
fn safe_uri(uri: &http::Uri) -> bool {
uri.authority().is_some_and(|a| !a.as_str().contains('@'))
&& super::items::safe_path(uri.path())
}
fn request(p: &Parts) -> Result<Value> {
let mut headers = Vec::new();
for (name, value) in &p.headers {
headers.push(json!({"name":name.as_str(),"value":value.to_str().map_err(|_|Error("invalid_headers"))?}));
}
Ok(json!({"method":p.method.as_str(),"url":p.uri.to_string(),"headers":headers}))
}
fn evaluate(script: &dyn Script, source: &str, context: &Value) -> Result<Value> {
let input = serde_json::to_string(context)?;
let result = script
.evaluate(source, &input, None, Limits::default())
.map_err(|_| Error("script"))?;
serde_json::from_str(&result).map_err(|_| Error("script"))
}
impl Cred {
pub fn inject(
&self,
mut parts: Parts,
caller: &Caller,
script: &dyn Script,
) -> Result<Outcome> {
if !safe_uri(&parts.uri) {
return Err(Error("invalid_url"));
}
let candidates: Vec<_> = {
let s = self.access()?;
s.rules
.iter()
.filter(|(_, (_, rules))| matches_rules(rules, &parts))
.map(|(id, (version, _))| (id.clone(), *version))
.collect()
};
if candidates.len() > 1 {
return Err(Error("ambiguous_item"));
}
let Some((id, version)) = candidates.into_iter().next() else {
return Ok(Outcome::Forward(parts));
};
let item = self.item_get(&id)?;
if item.version != version {
return Err(Error("stale"));
}
let original_request = request(&parts)?;
if item
.fields
.iter()
.any(|f| f.field_type == super::types::FieldType::SshPrivateKey)
{
return Err(Error("ssh_item"));
}
if item
.fields
.iter()
.any(|f| f.field_type == super::types::FieldType::Oauth2RefreshToken)
{
return Err(Error("oauth2_item"));
}
let prepared = self.reserve(&item, caller)?;
let Some((grant, secret_credential, audit_id)) = prepared else {
let request = self.file_request(
caller,
&format!("inject:{}", item.id),
GrantRequest {
items: vec![item.id.clone()],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
return Ok(Outcome::Denied {
item: item.name,
request,
});
};
let result = (|| {
let public = self.credential(&item, false)?;
let ctx = json!({"request":original_request,"credential":public,"item":{"id":item.id,"name":item.name},"grant":grant.as_ref().map(|g|json!({"conversation":match &g.subject {super::types::Subject::Conversation(id)=>Some(id),super::types::Subject::Program(_)=>None},"subject":g.subject,"uses_left":g.max_uses.map(|m|m-g.uses),"exp":g.exp}))});
for source in [
item.validate_js.as_deref(),
grant.as_ref().and_then(|g| g.validator.as_deref()),
]
.into_iter()
.flatten()
{
let value = evaluate(script, source, &ctx)?;
let allow = value
.as_bool()
.or_else(|| value.get("allow").and_then(Value::as_bool))
.ok_or(Error("script"))?;
if !allow {
return Err(Error("validator_denied"));
}
}
if !item.transform_js.is_empty() {
let mut ctx = json!({"request":original_request,"credential":secret_credential});
let url =
url::Url::parse(&parts.uri.to_string()).map_err(|_| Error("invalid_url"))?;
let mut base = url.clone();
base.set_query(None);
base.set_fragment(None);
ctx["url"] = json!({"base":base.as_str(),"query":url.query_pairs().collect::<Vec<_>>(),"fragment":url.fragment().map(|f|format!("#{f}")).unwrap_or_default()});
let output = evaluate(script, &item.transform_js, &ctx)?;
let object = output.as_object().ok_or(Error("transform"))?;
if object
.keys()
.any(|k| k != "url" && k != "headers" && k != "method")
|| output
.get("method")
.is_some_and(|m| m.as_str() != Some(parts.method.as_str()))
{
return Err(Error("transform"));
}
if let Some(url) = output.get("url") {
parts.uri = url
.as_str()
.ok_or(Error("transform"))?
.parse()
.map_err(|_| Error("transform"))?;
}
if let Some(headers) = output.get("headers") {
let mut new = http::HeaderMap::new();
for header in headers.as_array().ok_or(Error("transform"))? {
let name = header
.get("name")
.and_then(Value::as_str)
.ok_or(Error("transform"))?;
let value = header
.get("value")
.and_then(Value::as_str)
.ok_or(Error("transform"))?;
let mut value =
http::HeaderValue::from_str(value).map_err(|_| Error("transform"))?;
value.set_sensitive(true);
new.append(
http::header::HeaderName::from_bytes(name.as_bytes())
.map_err(|_| Error("transform"))?,
value,
);
}
parts.headers = new;
}
}
if !matches(&item, &parts) {
return Err(Error("rule_recheck"));
}
let authority = parts.uri.authority().ok_or(Error("rule_recheck"))?;
parts.headers.insert(
http::header::HOST,
http::HeaderValue::from_str(authority.as_str())
.map_err(|_| Error("rule_recheck"))?,
);
Ok(())
})();
self.access()?.db.execute(
"UPDATE audit SET event=?1 WHERE id=?2",
rusqlite::params![
if result.is_ok() {
"fetch.use"
} else {
"fetch.denied"
},
audit_id
],
)?;
result?;
Ok(Outcome::Forward(parts))
}
}