Skip to main content

act_runtime/
http_policy.rs

1//! Layer 1 phase C2: per-request HTTP policy hook.
2//!
3//! Intercepts `wasi:http/outgoing-handler` via `WasiHttpHooks::send_request`
4//! (both p2 and p3). Checks each outgoing request against the resolved
5//! `HttpConfig` and either delegates to the default handler or returns
6//! `ErrorCode::HttpRequestDenied`. Deny-by-default for `allowlist` mode;
7//! `open` allows every request; `deny` blocks every request.
8//!
9//! Enforcement scope:
10//! - Host matching: literal host, exact match or `*.suffix` wildcard.
11//! - Scheme / methods / ports matching.
12//! - IP literals in URI: matched against `cidr` entries at HTTP-layer.
13//! - **DNS-resolved IPs against both allow and deny CIDRs**: enforced in
14//!   the `PolicyDnsResolver` (`runtime::http_client`). The
15//!   resolver runs once per request, filters denied IPs, and in
16//!   `Allowlist` mode additionally requires allow-CIDR coverage when the
17//!   hostname doesn't match any host-anchored allow rule. Named-host URIs
18//!   with only allow-CIDR rules defer their verdict from the HTTP layer
19//!   to the resolver. The single resolve pins the addresses for the
20//!   subsequent connect, closing the DNS-rebinding window.
21//! - Redirect re-decision: each hop re-evaluated via the redirect predicate
22//!   hook (see `http_client::build_redirect_policy`).
23
24use std::future::Future;
25use std::sync::Arc;
26
27use http::Uri;
28use wasmtime_wasi_http::{Error as HttpError, RequestOptions, WasiBody};
29
30use act_policy::Decision;
31use act_policy::consent::{ConsentAsk, ConsentPrompter, DecisionCache};
32use act_policy::provider::{CompiledCeiling, ResourceOp};
33
34use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
35use crate::http_client::ActHttpClient;
36
37/// The capability gate for `wasi:http`, as one `WasiHttpHooks` covering both
38/// wasip2 and wasip3 — wasmtime 48 routes them through the same hook.
39pub struct PolicyHttpHooks {
40    ceiling: Arc<dyn CompiledCeiling>,
41    client: Arc<crate::http_client::ActHttpClient>,
42    prompter: Arc<dyn ConsentPrompter>,
43    cache: Arc<DecisionCache>,
44}
45
46impl PolicyHttpHooks {
47    pub fn new(
48        ceiling: Arc<dyn CompiledCeiling>,
49        client: Arc<crate::http_client::ActHttpClient>,
50        prompter: Arc<dyn ConsentPrompter>,
51        cache: Arc<DecisionCache>,
52    ) -> Self {
53        Self {
54            ceiling,
55            client,
56            prompter,
57            cache,
58        }
59    }
60
61    /// Build the `ConsentAsk` for an outgoing request: cache key is
62    /// `host:port`, summary names the method + URI.
63    fn http_ask(method: Option<&str>, uri: &Uri) -> ConsentAsk {
64        let host = uri.host().unwrap_or("");
65        let scheme = uri.scheme_str();
66        let port = uri
67            .port_u16()
68            .unwrap_or(if scheme == Some("https") { 443 } else { 80 });
69        ConsentAsk {
70            cap_id: act_types::constants::CAP_HTTP.to_string(),
71            key: format!("{host}:{port}"),
72            summary: format!("HTTP {} {}", method.unwrap_or("?"), uri),
73        }
74    }
75
76    /// Decide an HTTP request against the ceiling. Emits an audit record for
77    /// `Allow`/`Deny`; `Ask` is deliberately silent here — the verdict does
78    /// not exist yet. It is emitted where the ask path actually resolves
79    /// (the `Decision::Ask` arms of `send_request` below), mirroring
80    /// `fs_policy::resolve_ask`.
81    fn decide_uri(&self, method: Option<&str>, uri: &Uri) -> Decision {
82        let host = uri.host().unwrap_or("");
83        let scheme = uri.scheme_str().unwrap_or("https");
84        let port = uri
85            .port_u16()
86            .unwrap_or(if scheme == "https" { 443 } else { 80 });
87        let op = ResourceOp {
88            cap_id: act_types::constants::CAP_HTTP.to_string(),
89            key: format!("{host}:{port}"),
90            action: method.unwrap_or("").to_string(),
91            attrs: serde_json::json!({"scheme": scheme}),
92        };
93        let explained = self.ceiling.classify_explained(&op);
94        let mode = self.ceiling.effective_mode().to_string();
95        match explained.decision {
96            Decision::Allow => {
97                emit_cap_decision(&CapDecisionRecord::statik(
98                    act_types::constants::CAP_HTTP,
99                    &op.key,
100                    &op.action,
101                    Decision4::Allow,
102                    &mode,
103                    explained.rule,
104                ));
105            }
106            Decision::Deny => {
107                emit_cap_decision(&CapDecisionRecord::statik(
108                    act_types::constants::CAP_HTTP,
109                    &op.key,
110                    &op.action,
111                    Decision4::Deny,
112                    &mode,
113                    explained.rule,
114                ));
115            }
116            Decision::Ask => {}
117        }
118        explained.decision
119    }
120}
121
122fn deny_reason(method: Option<&str>, uri: &Uri) -> String {
123    format!("blocked by ACT policy: {} {}", method.unwrap_or("?"), uri)
124}
125
126/// Resolve an `Ask`-mode HTTP decision via the interactive prompter (cached
127/// per `host:port`), and emit the resulting `ask-allow`/`ask-deny` record.
128/// Mirrors `fs_policy::resolve_ask`; used by the one hook covering both
129/// wasip2 and wasip3, so
130/// there is exactly one place either arm can call to reach a verdict, and no
131/// way for them to drift from each other. Free function over owned data so
132/// the returned future is `Send` and usable from a spawned task.
133async fn resolve_http_ask(
134    cache: Arc<DecisionCache>,
135    prompter: Arc<dyn ConsentPrompter>,
136    ask: ConsentAsk,
137) -> bool {
138    let key = ask.key.clone();
139    let has_channel = prompter.has_channel();
140    let allowed = cache.decide_cached(&*prompter, ask).await;
141    emit_cap_decision(&CapDecisionRecord::answered(
142        act_types::constants::CAP_HTTP,
143        &key,
144        allowed,
145        has_channel,
146    ));
147    allowed
148}
149
150// ── the hook ──────────────────────────────────────────────────────────────
151//
152// One implementation since wasmtime 48, which routes both wasip2 and wasip3
153// outgoing requests through a single `WasiHttpHooks::send_request`. Before
154// that there were two hooks with two error enums and two body types, and the
155// gate had to be written — and kept in step — twice.
156
157impl wasmtime_wasi_http::WasiHttpHooks for PolicyHttpHooks {
158    fn send_request(
159        &mut self,
160        request: http::Request<WasiBody>,
161        options: Option<RequestOptions>,
162        fut: Box<dyn Future<Output = Result<(), HttpError>> + Send>,
163    ) -> Box<
164        dyn Future<
165                Output = Result<
166                    (
167                        http::Response<WasiBody>,
168                        Box<dyn Future<Output = Result<(), HttpError>> + Send>,
169                    ),
170                    HttpError,
171                >,
172            > + Send,
173    > {
174        // `fut` reports a *response*-processing error back to the guest. The
175        // gate has no opinion on one: by the time a response is being consumed
176        // the request was already allowed.
177        let _ = fut;
178
179        let method = Some(request.method().as_str().to_string());
180        let uri = request.uri().clone();
181        let decision = self.decide_uri(method.as_deref(), &uri);
182        let client = self.client.clone();
183
184        match decision {
185            Decision::Allow => {
186                tracing::debug!(?method, %uri, "http policy allow");
187                Box::new(async move { send(client, request, options).await })
188            }
189            Decision::Ask => {
190                let cache = self.cache.clone();
191                let prompter = self.prompter.clone();
192                let ask = Self::http_ask(method.as_deref(), &uri);
193                let log_uri = uri;
194                Box::new(async move {
195                    if !resolve_http_ask(cache, prompter, ask).await {
196                        tracing::warn!(%log_uri, "http policy ask denied");
197                        return Err(HttpError::HttpRequestDenied);
198                    }
199                    tracing::debug!(%log_uri, "http policy ask allowed");
200                    send(client, request, options).await
201                })
202            }
203            Decision::Deny => {
204                tracing::warn!(?method, %uri, "{}", deny_reason(method.as_deref(), &uri));
205                Box::new(async move { Err(HttpError::HttpRequestDenied) })
206            }
207        }
208    }
209}
210
211/// Hand an allowed request to the policy-aware client, in the box-of-futures
212/// shape the hook must return.
213async fn send(
214    client: Arc<ActHttpClient>,
215    request: http::Request<WasiBody>,
216    options: Option<RequestOptions>,
217) -> Result<
218    (
219        http::Response<WasiBody>,
220        Box<dyn Future<Output = Result<(), HttpError>> + Send>,
221    ),
222    HttpError,
223> {
224    match client.send(request, options).await {
225        Ok((resp, io)) => {
226            let io: Box<dyn Future<Output = Result<(), HttpError>> + Send> = Box::new(io);
227            Ok((resp, io))
228        }
229        Err(code) => Err(code),
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use act_policy::grant::{CapabilityGrant, PolicyMode};
237    use act_policy::provider::CapabilityProvider;
238    use act_policy::providers::http::HttpProvider;
239    use serde_json::json;
240
241    fn uri(s: &str) -> Uri {
242        s.parse().unwrap()
243    }
244
245    /// Build a `PolicyHttpHooks` from a `CapabilityGrant` and declared constraints.
246    /// `declared` mirrors what a component would declare in its `act.toml`
247    /// (`[std.capabilities."wasi:http"]` allow array).
248    fn hooks_from(declared: Vec<serde_json::Value>, grant: CapabilityGrant) -> PolicyHttpHooks {
249        // Use the same mode for the http client
250        let mode = grant.mode;
251        // `resolve` is async (the trait is), but HttpProvider does no I/O in it
252        // — drive it to completion on a throwaway runtime so this sync test
253        // helper stays sync and its many `#[test]` callers are untouched.
254        let ceiling_box = tokio::runtime::Builder::new_current_thread()
255            .build()
256            .unwrap()
257            .block_on(HttpProvider.resolve("wasi:http", Some(&declared), &grant))
258            .expect("HttpProvider::resolve");
259        let ceiling: Arc<dyn act_policy::provider::CompiledCeiling> = Arc::from(ceiling_box);
260        let http_cfg = act_policy::grant::HttpConfig {
261            mode,
262            ..Default::default()
263        };
264        let client =
265            Arc::new(crate::http_client::ActHttpClient::new(http_cfg).expect("client builds"));
266        PolicyHttpHooks::new(
267            ceiling,
268            client,
269            Arc::new(act_policy::consent::DenyPrompter),
270            Arc::new(act_policy::consent::DecisionCache::new()),
271        )
272    }
273
274    #[test]
275    fn mode_deny_blocks_everything() {
276        // Deny mode: no declared cap needed — ceiling hard-denies.
277        let h = hooks_from(
278            vec![json!({"host": "api.openai.com"})],
279            CapabilityGrant {
280                mode: PolicyMode::Deny,
281                ..Default::default()
282            },
283        );
284        assert_eq!(
285            h.decide_uri(Some("GET"), &uri("https://api.openai.com/v1/chat")),
286            Decision::Deny
287        );
288    }
289
290    #[test]
291    fn mode_open_allows_everything() {
292        // Open mode: declared cap means component is OK with HTTP.
293        let h = hooks_from(
294            vec![json!({"host": "api.openai.com"})],
295            CapabilityGrant {
296                mode: PolicyMode::Open,
297                ..Default::default()
298            },
299        );
300        assert_eq!(
301            h.decide_uri(Some("GET"), &uri("https://api.openai.com/v1/chat")),
302            Decision::Allow
303        );
304    }
305
306    #[test]
307    fn ask_mode_is_bounded_by_allow_ceiling() {
308        // mode=Ask with a declared ceiling of api.openai.com/https: in-ceiling → Ask,
309        // out-of-ceiling → Deny (no prompt).
310        let h = hooks_from(
311            vec![json!({"host": "api.openai.com", "scheme": "https"})],
312            CapabilityGrant {
313                mode: PolicyMode::Ask,
314                allow: vec![json!({"host": "api.openai.com", "scheme": "https"})],
315                ..Default::default()
316            },
317        );
318        assert_eq!(
319            h.decide_uri(Some("POST"), &uri("https://api.openai.com/v1/chat")),
320            Decision::Ask
321        );
322        assert_eq!(
323            h.decide_uri(Some("GET"), &uri("https://evil.com/")),
324            Decision::Deny
325        );
326    }
327
328    #[test]
329    fn ask_mode_deny_rule_beats_ceiling() {
330        let h = hooks_from(
331            vec![json!({"host": "*.example.com"})],
332            CapabilityGrant {
333                mode: PolicyMode::Ask,
334                allow: vec![json!({"host": "*.example.com"})],
335                deny: vec![json!({"host": "admin.example.com"})],
336            },
337        );
338        assert_eq!(
339            h.decide_uri(Some("GET"), &uri("https://api.example.com/")),
340            Decision::Ask
341        );
342        assert_eq!(
343            h.decide_uri(Some("GET"), &uri("https://admin.example.com/")),
344            Decision::Deny
345        );
346    }
347
348    #[test]
349    fn allowlist_host_allow() {
350        let h = hooks_from(
351            vec![json!({"host": "api.openai.com", "scheme": "https"})],
352            CapabilityGrant {
353                mode: PolicyMode::Allowlist,
354                allow: vec![json!({"host": "api.openai.com", "scheme": "https"})],
355                ..Default::default()
356            },
357        );
358        assert_eq!(
359            h.decide_uri(Some("POST"), &uri("https://api.openai.com/v1/chat")),
360            Decision::Allow
361        );
362        // Different scheme → deny
363        assert_eq!(
364            h.decide_uri(Some("GET"), &uri("http://api.openai.com/")),
365            Decision::Deny
366        );
367        // Different host → deny
368        assert_eq!(
369            h.decide_uri(Some("GET"), &uri("https://evil.com/")),
370            Decision::Deny
371        );
372    }
373
374    #[test]
375    fn allowlist_wildcard_host() {
376        let h = hooks_from(
377            vec![json!({"host": "*.github.com", "scheme": "https"})],
378            CapabilityGrant {
379                mode: PolicyMode::Allowlist,
380                allow: vec![json!({"host": "*.github.com", "scheme": "https"})],
381                ..Default::default()
382            },
383        );
384        assert_eq!(
385            h.decide_uri(Some("GET"), &uri("https://api.github.com/")),
386            Decision::Allow
387        );
388        assert_eq!(
389            h.decide_uri(Some("GET"), &uri("https://github.com/")),
390            Decision::Allow
391        );
392        assert_eq!(
393            h.decide_uri(Some("GET"), &uri("https://github.com.evil.com/")),
394            Decision::Deny
395        );
396    }
397
398    #[test]
399    fn deny_rule_beats_allow() {
400        let h = hooks_from(
401            vec![json!({"host": "*.example.com"})],
402            CapabilityGrant {
403                mode: PolicyMode::Allowlist,
404                allow: vec![json!({"host": "*.example.com"})],
405                deny: vec![json!({"host": "admin.example.com"})],
406            },
407        );
408        assert_eq!(
409            h.decide_uri(Some("GET"), &uri("https://api.example.com/")),
410            Decision::Allow
411        );
412        assert_eq!(
413            h.decide_uri(Some("GET"), &uri("https://admin.example.com/")),
414            Decision::Deny
415        );
416    }
417
418    #[test]
419    fn method_filter() {
420        let h = hooks_from(
421            vec![json!({"host": "api.example.com", "methods": ["GET", "POST"]})],
422            CapabilityGrant {
423                mode: PolicyMode::Allowlist,
424                allow: vec![json!({"host": "api.example.com"})],
425                ..Default::default()
426            },
427        );
428        assert_eq!(
429            h.decide_uri(Some("get"), &uri("https://api.example.com/")),
430            Decision::Allow
431        );
432        assert_eq!(
433            h.decide_uri(Some("DELETE"), &uri("https://api.example.com/")),
434            Decision::Deny
435        );
436    }
437
438    #[test]
439    fn undeclared_cap_denies_all() {
440        // Component didn't declare wasi:http at all → ceiling always Deny.
441        let h = hooks_from(
442            vec![], // no declared constraints
443            CapabilityGrant {
444                mode: PolicyMode::Open, // user would allow, but declaration gates it
445                ..Default::default()
446            },
447        );
448        assert_eq!(
449            h.decide_uri(Some("GET"), &uri("https://example.com/")),
450            Decision::Deny
451        );
452    }
453
454    #[test]
455    fn http_key_is_host_colon_port_and_action_is_the_method() {
456        let r = crate::audit::CapDecisionRecord::statik(
457            act_types::constants::CAP_HTTP,
458            "api.example.com:443",
459            "GET",
460            crate::audit::Decision4::Deny,
461            "ask",
462            None,
463        );
464        assert_eq!(r.key, "api.example.com:443");
465        assert_eq!(r.action, "GET");
466        assert_eq!(r.reason.as_deref(), Some("outside ceiling"));
467    }
468
469    #[test]
470    fn a_missing_http_method_becomes_an_empty_action() {
471        // `decide_uri` takes Option<&str>; the record must not invent a verb.
472        let r = crate::audit::CapDecisionRecord::statik(
473            act_types::constants::CAP_HTTP,
474            "api.example.com:443",
475            "",
476            crate::audit::Decision4::Allow,
477            "allowlist",
478            Some("*.example.com".into()),
479        );
480        assert_eq!(r.action, "");
481        assert_eq!(r.rule.as_deref(), Some("*.example.com"));
482        assert!(r.reason.is_none());
483    }
484
485    /// Drives `send_request`'s `Decision::Ask` arm through the real
486    /// `WasiHttpHooks` trait method, the same way `decide_uri` is driven
487    /// directly by the tests above, and captures the audit trail through a
488    /// real `AuditLayer` rather than the record constructors — so the
489    /// assertion is on the actual emission, not on `resolve_http_ask`'s
490    /// return value, which would still pass with the `emit_cap_decision`
491    /// call inside it deleted.
492    ///
493    /// This test was written when there were two hooks and the p2 one was
494    /// unreached by any fixture (every component driving outbound HTTP goes
495    /// through `wasi-fetch`, which imports `wasip3::http::*` exclusively).
496    /// wasmtime 48 collapsed both onto one hook, so that gap is gone and
497    /// this is now a unit-level companion to `tests/audit_cli.rs`'s
498    /// `http_ask_resolution_reaches_the_audit_trail`, which exercises the
499    /// same arm end to end through the real binary.
500    #[tokio::test(flavor = "current_thread")]
501    async fn the_ask_arm_resolves_and_audits_the_denial() {
502        use crate::audit::layer::AuditWriter;
503        use http_body_util::{BodyExt, Empty};
504        use std::sync::Mutex;
505        use tracing_subscriber::prelude::*;
506        use wasmtime_wasi_http::WasiHttpHooks as _;
507
508        #[derive(Clone, Default)]
509        struct CapturingWriter(Arc<Mutex<Vec<String>>>);
510        impl AuditWriter for CapturingWriter {
511            fn write_line(&self, line: &str) {
512                self.0.lock().unwrap().push(line.to_string());
513            }
514        }
515
516        // Not `hooks_from`: it spins up its own throwaway current-thread
517        // runtime via `block_on` to resolve the (actually synchronous)
518        // `HttpProvider::resolve`, which is fine from a plain `#[test]` but
519        // panics ("Cannot start a runtime from within a runtime") called
520        // from inside this test's own `#[tokio::test]` runtime. `.await` it
521        // directly instead — we're already in an async context.
522        let grant = CapabilityGrant {
523            mode: PolicyMode::Ask,
524            allow: vec![json!({"host": "api.example.com"})],
525            ..Default::default()
526        };
527        let ceiling_box = act_policy::providers::http::HttpProvider
528            .resolve(
529                "wasi:http",
530                Some(&[json!({"host": "api.example.com"})]),
531                &grant,
532            )
533            .await
534            .expect("HttpProvider::resolve");
535        let ceiling: Arc<dyn CompiledCeiling> = Arc::from(ceiling_box);
536        let http_cfg = act_policy::grant::HttpConfig {
537            mode: grant.mode,
538            ..Default::default()
539        };
540        let client =
541            Arc::new(crate::http_client::ActHttpClient::new(http_cfg).expect("client builds"));
542        let mut h = PolicyHttpHooks::new(
543            ceiling,
544            client,
545            Arc::new(act_policy::consent::DenyPrompter),
546            Arc::new(act_policy::consent::DecisionCache::new()),
547        );
548
549        let body: WasiBody = Empty::<bytes::Bytes>::new()
550            .map_err(|_| unreachable!())
551            .boxed_unsync();
552        let request = http::Request::builder()
553            .method("GET")
554            .uri("https://api.example.com/")
555            .body(body)
556            .unwrap();
557        let options = RequestOptions {
558            connect_timeout: Some(std::time::Duration::from_secs(5)),
559            first_byte_timeout: Some(std::time::Duration::from_secs(5)),
560            between_bytes_timeout: Some(std::time::Duration::from_secs(5)),
561        };
562
563        let writer = CapturingWriter::default();
564        let sink = writer.0.clone();
565        let sub = tracing_subscriber::registry().with(crate::audit::AuditLayer::new(
566            writer,
567            crate::audit::Detail::Rollup,
568        ));
569        // A guard, not `with_default`'s closure form: the audit emission
570        // happens inside a task spawned via `wasmtime_wasi::runtime::spawn`,
571        // driven to completion below by `.await`ing its handle — the guard
572        // needs to stay live across that await point. Sound only because
573        // this test runs on `flavor = "current_thread"`: `spawn` finds an
574        // ambient tokio runtime already current and reuses it rather than
575        // its own multi-threaded static one, so the spawned task runs on
576        // this same OS thread and observes this thread-local default.
577        let _guard = tracing::subscriber::set_default(sub);
578
579        // The hook hands back a boxed future; the consent resolution and the
580        // audit emission both happen inside it, so it has to be driven to
581        // completion under the guard installed above.
582        let resolved =
583            std::pin::Pin::from(h.send_request(request, Some(options), Box::new(async { Ok(()) })))
584                .await;
585
586        drop(_guard);
587
588        // `hooks_from` wires up `DenyPrompter` (no interactive channel) —
589        // every ask degrades to deny deterministically, same as a headless
590        // `act call` with stdin closed.
591        assert!(
592            matches!(resolved, Err(HttpError::HttpRequestDenied)),
593            "expected the ask to degrade to a denied response"
594        );
595
596        let lines = sink.lock().unwrap().clone();
597        let ask_line = lines
598            .iter()
599            .find(|l| l.contains("ask-deny"))
600            .unwrap_or_else(|| panic!("no ask-deny audit line reached the trail, got {lines:?}"));
601        assert!(ask_line.contains("wasi:http"), "got {ask_line}");
602        // `hooks_from` wires up `DenyPrompter`, which has no channel at all —
603        // M1: this must not be recorded as if a human had actually answered.
604        assert!(ask_line.contains("no prompt channel"), "got {ask_line}");
605    }
606}