use std::collections::BTreeMap;
use act_types::{Capabilities, CapabilityRequest, HttpAllow};
use crate::Decision;
use crate::effective::effective_http;
use crate::grant::{CapabilityGrant, HttpConfig, HttpRule, PolicyError};
use crate::net::{NetworkCheck, rule_matches};
use crate::provider::{CapabilityProvider, CompiledCeiling, Explained, ResourceOp};
pub struct HttpProvider;
#[async_trait::async_trait]
impl CapabilityProvider for HttpProvider {
async fn resolve(
&self,
cap_id: &str,
declared: Option<&[serde_json::Value]>,
grant: &CapabilityGrant,
) -> Result<Box<dyn CompiledCeiling>, PolicyError> {
let declared = declared.unwrap_or(&[]);
let user = http_config_from_grant(grant)?;
let decl_rules = parse_http_rules_from_httpallow(declared)?;
let caps = caps_from_declared(cap_id, declared);
let eff = effective_http(&user, &caps);
Ok(Box::new(HttpCeiling {
config: eff.config,
decl_rules,
is_declared: eff.declared,
}))
}
}
struct HttpCeiling {
config: HttpConfig,
decl_rules: Vec<HttpRule>,
is_declared: bool,
}
impl HttpCeiling {
fn matched(&self, op: &ResourceOp) -> (Decision, Option<String>) {
let (host, port) = parse_host_port(&op.key);
let check = NetworkCheck::new(host, port);
let scheme = op.attrs.get("scheme").and_then(|v| v.as_str());
let method = if op.action.is_empty() {
None
} else {
Some(op.action.as_str())
};
match self.config.mode {
crate::grant::PolicyMode::Deny => (Decision::Deny, None),
crate::grant::PolicyMode::Open => (Decision::Allow, None),
crate::grant::PolicyMode::Ask => {
if self
.config
.deny
.iter()
.any(|r| http_rule_matches_net(r, &check, scheme))
{
return (Decision::Deny, None);
}
match self.config.allow.iter().find(|eff_rule| {
http_rule_matches_net(eff_rule, &check, scheme)
&& decl_allows_method(&self.decl_rules, &check, scheme, method)
}) {
Some(rule) => (Decision::Ask, Some(render_http_rule(rule))),
None => (Decision::Deny, None),
}
}
crate::grant::PolicyMode::Allowlist => {
if self
.config
.deny
.iter()
.any(|r| http_rule_matches_net(r, &check, scheme))
{
return (Decision::Deny, None);
}
match self.config.allow.iter().find(|eff_rule| {
http_rule_matches_net(eff_rule, &check, scheme)
&& decl_allows_method(&self.decl_rules, &check, scheme, method)
}) {
Some(rule) => (Decision::Allow, Some(render_http_rule(rule))),
None => (Decision::Deny, None),
}
}
}
}
}
impl CompiledCeiling for HttpCeiling {
fn classify(&self, op: &ResourceOp) -> Decision {
self.matched(op).0
}
fn classify_explained(&self, op: &ResourceOp) -> Explained {
let (decision, rule) = self.matched(op);
Explained { decision, rule }
}
fn declared(&self) -> bool {
self.is_declared
}
fn effective_mode(&self) -> crate::grant::PolicyMode {
self.config.mode
}
}
fn render_http_rule(rule: &HttpRule) -> String {
rule.net
.host
.clone()
.or_else(|| rule.net.cidr.clone())
.unwrap_or_else(|| "*".to_string())
}
fn decl_allows_method(
decl_rules: &[HttpRule],
check: &NetworkCheck,
scheme: Option<&str>,
method: Option<&str>,
) -> bool {
if decl_rules.is_empty() {
return true;
}
decl_rules.iter().any(|r| {
if !rule_matches(&r.net, check) {
return false;
}
if let (Some(rule_scheme), Some(req_scheme)) = (&r.scheme, scheme)
&& !rule_scheme.eq_ignore_ascii_case(req_scheme)
{
return false;
}
if let Some(allowed_methods) = &r.methods
&& let Some(req_method) = method
&& !allowed_methods
.iter()
.any(|m| m.eq_ignore_ascii_case(req_method))
{
return false;
}
true
})
}
fn http_rule_matches_net(rule: &HttpRule, check: &NetworkCheck, scheme: Option<&str>) -> bool {
if !rule_matches(&rule.net, check) {
return false;
}
if let (Some(rule_scheme), Some(req_scheme)) = (&rule.scheme, scheme)
&& !rule_scheme.eq_ignore_ascii_case(req_scheme)
{
return false;
}
true
}
fn parse_host_port(key: &str) -> (&str, u16) {
if key.starts_with('[')
&& let Some(bracket_end) = key.find(']')
{
let host = &key[..=bracket_end];
if let Some(port_str) = key.get(bracket_end + 2..)
&& let Ok(port) = port_str.parse::<u16>()
{
return (host, port);
}
return (host, 443);
}
if let Some(colon_pos) = key.rfind(':') {
let port_str = &key[colon_pos + 1..];
if let Ok(port) = port_str.parse::<u16>() {
return (&key[..colon_pos], port);
}
}
(key, 443)
}
fn http_config_from_grant(grant: &CapabilityGrant) -> Result<HttpConfig, PolicyError> {
let allow = parse_http_rules(&grant.allow)?;
let deny = parse_http_rules(&grant.deny)?;
Ok(HttpConfig {
mode: grant.mode,
allow,
deny,
})
}
fn parse_http_rules(cs: &[serde_json::Value]) -> Result<Vec<HttpRule>, PolicyError> {
cs.iter()
.map(|c| {
serde_json::from_value::<HttpRule>(c.clone()).map_err(|e| PolicyError::Constraint {
cap: "wasi:http",
source: e,
})
})
.collect()
}
fn parse_http_rules_from_httpallow(
declared: &[serde_json::Value],
) -> Result<Vec<HttpRule>, PolicyError> {
declared
.iter()
.map(|c| {
let a: HttpAllow =
serde_json::from_value(c.clone()).map_err(|e| PolicyError::Constraint {
cap: "wasi:http",
source: e,
})?;
Ok(HttpRule {
net: crate::net::NetworkRule {
host: Some(a.host),
ports: a.ports,
cidr: None,
except_ports: None,
},
scheme: a.scheme,
methods: a.methods,
})
})
.collect()
}
fn caps_from_declared(cap_id: &str, declared: &[serde_json::Value]) -> Capabilities {
if declared.is_empty() {
return Capabilities::default();
}
let req = CapabilityRequest {
constraints: declared.to_vec(),
..Default::default()
};
Capabilities(BTreeMap::from([(cap_id.to_string(), req)]))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Decision;
use crate::grant::{CapabilityGrant, PolicyMode};
use crate::provider::{CapabilityProvider, ResourceOp};
use serde_json::json;
#[tokio::test]
async fn http_provider_matches_host_and_method() {
let p = HttpProvider;
let declared = vec![json!({"host":"api.example.com","methods":["GET"]})];
let grant = CapabilityGrant {
mode: PolicyMode::Allowlist,
allow: vec![json!({"host":"api.example.com"})],
deny: vec![],
};
let c = p
.resolve("wasi:http", Some(&declared), &grant)
.await
.unwrap();
let op = |m: &str| ResourceOp {
cap_id: "wasi:http".into(),
key: "api.example.com:443".into(),
action: m.into(),
attrs: json!({"scheme":"https"}),
};
assert_eq!(c.classify(&op("GET")), Decision::Allow);
assert_eq!(c.classify(&op("POST")), Decision::Deny); }
#[tokio::test]
async fn http_provider_undeclared_denies_all() {
let p = HttpProvider;
let grant = CapabilityGrant {
mode: PolicyMode::Open,
allow: vec![],
deny: vec![],
};
let c = p.resolve("wasi:http", None, &grant).await.unwrap();
let op = ResourceOp {
cap_id: "wasi:http".into(),
key: "api.example.com:443".into(),
action: "GET".into(),
attrs: json!({"scheme":"https"}),
};
assert_eq!(c.classify(&op), Decision::Deny);
assert!(!c.declared());
}
#[tokio::test]
async fn http_provider_ask_mode_in_ceiling() {
let p = HttpProvider;
let declared = vec![json!({"host":"api.example.com"})];
let grant = CapabilityGrant {
mode: PolicyMode::Ask,
allow: vec![],
deny: vec![],
};
let c = p
.resolve("wasi:http", Some(&declared), &grant)
.await
.unwrap();
let in_op = ResourceOp {
cap_id: "wasi:http".into(),
key: "api.example.com:443".into(),
action: "GET".into(),
attrs: json!({"scheme":"https"}),
};
assert_eq!(c.classify(&in_op), Decision::Ask);
let out_op = ResourceOp {
cap_id: "wasi:http".into(),
key: "evil.com:443".into(),
action: "GET".into(),
attrs: json!({"scheme":"https"}),
};
assert_eq!(c.classify(&out_op), Decision::Deny);
}
}