Skip to main content

spin_capabilities/
deny.rs

1use crate::{
2    AI_MODELS, ALLOWED_OUTBOUND_HOSTS, CAPABILITY_SETS, ENVIRONMENT, FILES, InheritConfiguration,
3    KEY_VALUE_STORES, SQLITE_DATABASES, VARIABLES,
4};
5use wac_graph::types::{ItemKind, SubtypeChecker, are_semver_compatible};
6use wac_graph::{CompositionGraph, types::Package};
7
8const SPIN_DENY_ADAPTER_BYTES: &[u8] = include_bytes!("../deny_adapter.wasm");
9
10/// Composes a deny adapter into a Wasm component to block host capabilities that
11/// are not explicitly inherited.
12///
13/// Given the raw bytes of a Wasm component (`source`) and an [`InheritConfiguration`]
14/// describing which capability sets should remain accessible, this function uses
15/// `wac-graph` to wire a bundled deny-all adapter into the component's imports.
16/// Interfaces on the same semver track as an entry in the allow set (derived from
17/// `inherits`) are left untouched so the host can satisfy them at runtime. All other
18/// matching imports are fulfilled by the deny adapter, which refuses every call
19/// (typically with an access-denied error).
20///
21/// Imports are matched on the interface they implement, so a named import such as
22/// `primary (implements spin:key-value/key-value@3.0.0)` is treated the same as a
23/// plain import of that interface; the label itself is irrelevant.
24///
25/// If the deny adapter has no exports that match the component's imports (i.e. no
26/// plugging is needed), the original `source` bytes are returned unchanged.
27pub fn apply_deny_adapter(
28    source: &[u8],
29    inherits: InheritConfiguration,
30) -> anyhow::Result<Vec<u8>> {
31    let allow = allow_list(inherits);
32
33    let mut graph = CompositionGraph::new();
34
35    let dependency_package = Package::from_bytes("dependency", None, source, graph.types_mut())?;
36
37    let dependency_id = graph.register_package(dependency_package)?;
38
39    let deny_adapter_package = Package::from_bytes(
40        "spin-deny-all-adapter",
41        None,
42        SPIN_DENY_ADAPTER_BYTES,
43        graph.types_mut(),
44    )?;
45
46    let deny_adapter_id = graph.register_package(deny_adapter_package)?;
47
48    // Selective plug: wire up only imports NOT in the allow list.
49    let socket_instantiation = graph.instantiate(dependency_id);
50
51    let types = graph.types();
52    let adapter_exports = &types[graph[deny_adapter_id].ty()].exports;
53
54    let mut plug_exports: Vec<(String, String)> = Vec::new();
55    let mut cache = Default::default();
56    let mut checker = SubtypeChecker::new(&mut cache);
57    for (import_name, socket_ty) in &types[graph[dependency_id].ty()].imports {
58        let ItemKind::Instance(iface) = socket_ty else {
59            continue;
60        };
61
62        // Named imports resolve to the interface they implement; plain imports to their own name.
63        let iface_name = types[*iface].id.as_deref().unwrap_or(import_name);
64
65        // Skip interfaces that should be allowed (inherited from host).
66        if allow
67            .iter()
68            .any(|allowed| are_semver_compatible(allowed, iface_name))
69        {
70            continue;
71        }
72
73        let matching_export = adapter_exports
74            .get(iface_name)
75            .map(|ty| (iface_name, ty))
76            .or_else(|| {
77                adapter_exports
78                    .iter()
79                    .find(|(export_name, _)| are_semver_compatible(export_name, iface_name))
80                    .map(|(export_name, ty)| (export_name.as_str(), ty))
81            });
82
83        if let Some((plug_name, plug_ty)) = matching_export
84            && checker
85                .is_subtype(*plug_ty, types, *socket_ty, types)
86                .is_ok()
87        {
88            plug_exports.push((plug_name.to_owned(), import_name.clone()));
89        }
90    }
91
92    if plug_exports.is_empty() {
93        // No plugging needed — return the original source as-is.
94        return Ok(source.to_vec());
95    }
96
97    let plug_instantiation = graph.instantiate(deny_adapter_id);
98    for (plug_name, socket_name) in plug_exports {
99        let export = graph.alias_instance_export(plug_instantiation, &plug_name)?;
100        graph.set_instantiation_argument(socket_instantiation, &socket_name, export)?;
101    }
102
103    // Export all exports from the socket (dependency) component.
104    for name in graph.types()[graph[dependency_id].ty()]
105        .exports
106        .keys()
107        .cloned()
108        .collect::<Vec<_>>()
109    {
110        let export = graph.alias_instance_export(socket_instantiation, &name)?;
111        graph.export(export, &name)?;
112    }
113
114    let bytes = graph.encode(Default::default())?;
115    Ok(bytes)
116}
117
118fn allow_list(inherits: InheritConfiguration) -> Vec<&'static str> {
119    let mut allow = vec![];
120
121    match inherits {
122        InheritConfiguration::All => {
123            for (_, capability_set) in CAPABILITY_SETS {
124                allow.extend_from_slice(capability_set);
125            }
126        }
127        InheritConfiguration::Some(inherits) => {
128            for config in inherits {
129                match config.as_str() {
130                    "ai_models" => allow.extend_from_slice(AI_MODELS),
131                    "allowed_outbound_hosts" => allow.extend_from_slice(ALLOWED_OUTBOUND_HOSTS),
132                    "environment" => allow.extend_from_slice(ENVIRONMENT),
133                    "files" => allow.extend_from_slice(FILES),
134                    "key_value_stores" => allow.extend_from_slice(KEY_VALUE_STORES),
135                    "sqlite_databases" => allow.extend_from_slice(SQLITE_DATABASES),
136                    "variables" => allow.extend_from_slice(VARIABLES),
137                    _ => {}
138                }
139            }
140        }
141        InheritConfiguration::None => {}
142    }
143
144    allow
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use std::collections::HashSet;
151    use wac_graph::types::Types;
152
153    const KV: &str = "spin:key-value/key-value@3.0.0";
154    const ENV: &str = "wasi:cli/environment@0.2.6";
155    // Versions that real toolchains emit on the tracks the capability lists pin:
156    // Rust's `wasm32-wasip2` target imports WASI 0.2.9 where the lists carry 0.2.6,
157    // and spin-sdk 5.2.0 imports `spin:postgres/postgres@4.0.0` where the list
158    // carries 4.2.0.
159    const ENV_NEWER: &str = "wasi:cli/environment@0.2.9";
160    const SDK_PG4: &str = "spin:postgres/postgres@4.0.0";
161
162    // Imports use an empty instance type, which any adapter export trivially satisfies.
163    fn component(imports: &[(&str, Option<&str>)]) -> Vec<u8> {
164        let mut wat = String::from("(component\n");
165        for (name, implements) in imports {
166            match implements {
167                Some(iface) => wat.push_str(&format!(
168                    "  (import \"{name}\" (implements \"{iface}\") (instance))\n"
169                )),
170                None => wat.push_str(&format!("  (import \"{name}\" (instance))\n")),
171            }
172        }
173        wat.push(')');
174        wat::parse_str(&wat).expect("valid WAT")
175    }
176
177    // The composed component also carries the deny adapter's own imports (wasi:io etc.),
178    // so tests check for specific names rather than an empty import set.
179    fn remaining_imports(bytes: &[u8]) -> Vec<String> {
180        let mut types = Types::default();
181        let package = Package::from_bytes("out", None, bytes, &mut types).expect("valid component");
182        types[package.ty()].imports.keys().cloned().collect()
183    }
184
185    fn assert_denied(bytes: &[u8], names: &[&str]) {
186        let remaining = remaining_imports(bytes);
187        for name in names {
188            assert!(
189                !remaining.iter().any(|r| r == name),
190                "`{name}` should have been denied"
191            );
192        }
193    }
194
195    fn assert_retained(bytes: &[u8], names: &[&str]) {
196        let remaining = remaining_imports(bytes);
197        for name in names {
198            assert!(
199                remaining.iter().any(|r| r == name),
200                "`{name}` should have been retained"
201            );
202        }
203    }
204
205    fn some(sets: &[&str]) -> InheritConfiguration {
206        InheritConfiguration::Some(sets.iter().map(|s| s.to_string()).collect())
207    }
208
209    #[test]
210    fn plain_import_is_denied() {
211        let source = component(&[(KV, None)]);
212        let out = apply_deny_adapter(&source, InheritConfiguration::None).unwrap();
213        assert_denied(&out, &[KV]);
214    }
215
216    #[test]
217    fn named_import_is_denied() {
218        let source = component(&[("primary", Some(KV))]);
219        let out = apply_deny_adapter(&source, InheritConfiguration::None).unwrap();
220        assert_denied(&out, &["primary"]);
221    }
222
223    #[test]
224    fn named_import_is_allowed_when_inherited() {
225        let source = component(&[("primary", Some(KV))]);
226        let out = apply_deny_adapter(&source, some(&["key_value_stores"])).unwrap();
227        assert_eq!(out, source);
228    }
229
230    #[test]
231    fn multiple_labels_of_same_interface_are_all_denied() {
232        let source = component(&[("primary", Some(KV)), ("backup", Some(KV))]);
233        let out = apply_deny_adapter(&source, InheritConfiguration::None).unwrap();
234        assert_denied(&out, &["primary", "backup"]);
235    }
236
237    #[test]
238    fn mixed_named_imports_are_filtered_by_interface() {
239        let source = component(&[("primary", Some(KV)), ("env", Some(ENV))]);
240        let out = apply_deny_adapter(&source, some(&["key_value_stores"])).unwrap();
241        assert_retained(&out, &["primary"]);
242        assert_denied(&out, &["env"]);
243    }
244
245    #[test]
246    fn plain_and_named_imports_of_same_interface_are_both_denied() {
247        let source = component(&[(KV, None), ("secondary", Some(KV))]);
248        let out = apply_deny_adapter(&source, InheritConfiguration::None).unwrap();
249        assert_denied(&out, &[KV, "secondary"]);
250    }
251
252    #[test]
253    fn unknown_interface_is_left_untouched() {
254        let source = component(&[("thing", Some("example:unknown/iface@1.0.0"))]);
255        let out = apply_deny_adapter(&source, InheritConfiguration::None).unwrap();
256        assert_eq!(out, source);
257    }
258
259    // Such an import rewires the export's resources to the host's, breaking composition.
260    #[test]
261    fn adapter_does_not_import_what_it_exports() {
262        let mut types = Types::default();
263        let package = Package::from_bytes("adapter", None, SPIN_DENY_ADAPTER_BYTES, &mut types)
264            .expect("valid deny adapter");
265        let world = &types[package.ty()];
266        let conflicts: Vec<_> = world
267            .imports
268            .keys()
269            .filter(|import| {
270                world
271                    .exports
272                    .keys()
273                    .any(|export| are_semver_compatible(import, export))
274            })
275            .collect();
276        assert!(
277            conflicts.is_empty(),
278            "deny adapter imports interfaces it also exports: {conflicts:?}"
279        );
280    }
281
282    // Anything else is a capability the adapter could reach, e.g. std's WASI imports.
283    #[test]
284    fn adapter_imports_only_types_used_by_its_exports() {
285        let mut types = Types::default();
286        let package = Package::from_bytes("adapter", None, SPIN_DENY_ADAPTER_BYTES, &mut types)
287            .expect("valid deny adapter");
288        let world = &types[package.ty()];
289
290        let mut used = HashSet::new();
291        let mut pending: Vec<_> = world
292            .exports
293            .values()
294            .filter_map(|kind| match kind {
295                ItemKind::Instance(id) => Some(*id),
296                _ => None,
297            })
298            .collect();
299        while let Some(id) = pending.pop() {
300            for used_type in types[id].uses.values() {
301                if let Some(name) = &types[used_type.interface].id
302                    && used.insert(name.as_str())
303                {
304                    pending.push(used_type.interface);
305                }
306            }
307        }
308
309        let unused: Vec<_> = world
310            .imports
311            .keys()
312            .filter(|import| !used.contains(import.as_str()))
313            .collect();
314        assert!(
315            unused.is_empty(),
316            "deny adapter imports interfaces its exports don't use: {unused:?}"
317        );
318    }
319
320    #[test]
321    fn inherit_all_is_passthrough() {
322        let source = component(&[(KV, None), ("primary", Some(KV)), ("env", Some(ENV))]);
323        let out = apply_deny_adapter(&source, InheritConfiguration::All).unwrap();
324        assert_eq!(out, source);
325    }
326
327    // Inheritance must match by semver track, exactly as the deny side already does.
328    #[test]
329    fn plain_import_at_compatible_version_is_allowed_when_inherited() {
330        let source = component(&[(SDK_PG4, None)]);
331        let out = apply_deny_adapter(&source, some(&["allowed_outbound_hosts"])).unwrap();
332        assert_eq!(out, source);
333    }
334
335    #[test]
336    fn named_import_at_compatible_version_is_allowed_when_inherited() {
337        let source = component(&[("env", Some(ENV_NEWER))]);
338        let out = apply_deny_adapter(&source, some(&["environment"])).unwrap();
339        assert_eq!(out, source);
340    }
341
342    // Semver matching is per interface: an inherited set must not admit a
343    // compatible version of an interface that belongs to another set.
344    #[test]
345    fn compatible_version_from_another_set_is_still_denied() {
346        let source = component(&[("env", Some(ENV_NEWER))]);
347        let out = apply_deny_adapter(&source, some(&["key_value_stores"])).unwrap();
348        assert_denied(&out, &["env"]);
349    }
350}