Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ jobs:

sandboxes:
name: sandboxes
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 17 additions & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ APPENDIX: How to apply the Apache License to your work.
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright (c) 2026 Akamai Technologies
Copyright (c) The Spin Framework Contributors. All Rights Reserved.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -199,3 +199,19 @@ distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

--- LLVM Exceptions to the Apache 2.0 License ----

As an exception, if, as a result of your compiling your source code, portions
of this Software are embedded into an Object form of such source code, you
may redistribute such embedded portions in such Object form without complying
with the conditions of Sections 4(a), 4(b) and 4(d) of the License.

In addition, if you combine or link compiled forms of this Software with
software that is licensed under the GPLv2 ("Combined Software") and if a
court of competent jurisdiction determines that the patent provision (Section
3), the indemnity provision (Section 9) or other Section of the License
conflicts with the conditions of the GPLv2, you may retroactively and
prospectively choose to deem waived or otherwise exclude such Section(s) of
the License, but only in their entirety and only with respect to the
Combined Software.
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,18 @@ POST this envelope to `/ts/...` or `/py/...`:
{
"code": "print(6 * 7)",
"sandboxId": "demo",
"allowedHosts": ["api.example.com"],
"allowedHosts": ["https://api.example.com"],
"kvEnabled": true,
"input": { "method": "GET", "path": "/" }
}
```

`code` and `sandboxId` are required; `sandboxId` must be non-empty and at most 256
bytes. `allowedHosts` narrows egress for outbound HTTP;
missing or empty denies it. `kvEnabled` is `false` by default; set it to `true` to
missing or empty denies it. Entries use `scheme://host[:port]`; `*.example.com`
matches subdomains only and `:*` allows any port. CIDR and port ranges are not
supported, wildcard hosts grant nothing, and malformed entries return `400`.
`kvEnabled` is `false` by default; set it to `true` to
allow access to the scoped key-value store. `input` creates the request delivered to
an HTTP handler.

Expand Down Expand Up @@ -142,6 +145,8 @@ In production, run sandboxes as private components and reach them through Spin s
chaining or the [`@spinframework/sandbox`](sdk) client library. The
[AI code executor](examples/ai-code-executor) shows the complete wiring.

Sandbox endpoints do not authenticate callers: any component allowed to reach a private route can choose an ID and policy. Grant access only to trusted callers, or authenticate public routes. The application must map requests to sandbox IDs and set `allowedHosts` and `kvEnabled` itself; never accept these from model output or unchecked users.

## Testing

Run unit tests with:
Expand Down Expand Up @@ -170,6 +175,8 @@ template updates.
built-in caps are on size: code over 5 MB (or an oversized request body) is rejected
with `413`, and captured stdout/stderr is truncated at 256 KB.
- **Stateless.** Each `run` is single-shot. Only the scoped KV store persists.
- **KV lifetime and quotas.** KV has no per-sandbox quota or automatic cleanup. Once an ID is retired, remove its data with `Interpreter.javascript(...).kv(id).clear()`; coordinate cleanup with writers.
- **Secrets.** Code can read any secrets provided in its code, input, or component environment. Do not configure sensitive environment variables on sandbox components; there is no host-side credential injection for outbound requests.
- **Fixed capabilities.** No filesystem, shell, or subprocess, and no `pip`/`npm`
install at runtime. The pre-bundled dependency set (plus, for JS, whatever the bundler can package) is the whole toolbox.
- **Egress DNS.** Private IP literals and local/internal names are denied by the
Expand Down
76 changes: 76 additions & 0 deletions crates/allowed_hosts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
use url::Url;

#[derive(Debug)]
#[allow(dead_code)]
pub struct HostPattern {
pub scheme: String,
pub host: String,
pub subdomains_only: bool,
pub port: Option<String>,
}

pub fn parse_pattern(value: &str) -> Option<HostPattern> {
let value = value.trim();
let (scheme, authority) = value.split_once("://")?;
if scheme.is_empty() || (scheme != "*" && !scheme.chars().all(char::is_alphabetic)) {
return None;
}
let authority = authority.strip_suffix('/').unwrap_or(authority);
if authority.is_empty() || authority.contains(['/', '?', '#', '@', '%', '\\']) || authority.chars().any(char::is_whitespace) {
return None;
}
let (host, port) = if authority.starts_with('[') {
let end = authority.find(']')?;
let tail = &authority[end + 1..];
(&authority[..=end], if tail.is_empty() { None } else { Some(tail.strip_prefix(':')?) })
} else if let Some((host, port)) = authority.rsplit_once(':') {
(host, Some(port))
} else {
(authority, None)
};
if !host.starts_with('[') && host.contains(':') {
return None;
}
let port = port.filter(|port| !port.is_empty());
if let Some(port) = port {
if port != "*" && (!port.bytes().all(|byte| byte.is_ascii_digit()) || port.parse::<u16>().is_err()) {
return None;
}
} else if !matches!(scheme, "http" | "https" | "postgres" | "mysql" | "redis" | "mqtt") {
return None;
}
let (subdomains_only, host) = match host.strip_prefix("*.") {
Some(host) => (true, host),
None => (false, host),
};
if host == "*" && !subdomains_only {
return Some(HostPattern { scheme: scheme.to_string(), host: host.to_string(), subdomains_only, port: port.map(str::to_string) });
}
if host.contains('*') || (subdomains_only && host.starts_with('[')) {
return None;
}
let url = Url::parse(&format!("http://{host}")).ok()?;
if url.username() != "" || url.password().is_some() || url.port().is_some() || url.path() != "/" {
return None;
}
if subdomains_only && !matches!(url.host(), Some(url::Host::Domain(_))) {
return None;
}
let host = url.host_str()?.to_string();
Some(HostPattern { scheme: scheme.to_string(), host, subdomains_only, port: port.map(str::to_string) })
}

#[cfg(test)]
mod tests {
use super::parse_pattern;

#[test]
fn validates_patterns() {
for value in ["https://example.com", "https://example.com:", "https://example.com/", "*://*:*", "*://*.example.com:*", "http://[2600::1]:8080", "https://93.184.216.34"] {
assert!(parse_pattern(value).is_some(), "{value}");
}
for value in ["*", "example.com", "", "https://", "*://example.com", "https://example.com/path", "https://example.com?x", "https://user@example.com", "http://example.com:80:90", "https://example.com:1-10", "https://example.com:65536", "https://example.com/24", "https://*.127.0.0.1", "https://a*b.com", "https://[::1]junk"] {
assert!(parse_pattern(value).is_none(), "{value}");
}
}
}
2 changes: 1 addition & 1 deletion crates/guard-io/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "guard-io"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
license = "Apache-2.0 WITH LLVM-exception"
publish = false

[lib]
Expand Down
82 changes: 31 additions & 51 deletions crates/guard-io/src/egress.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Per-request egress allow-list layered on Spin's host policy, with an SSRF backstop.

use crate::allowed_hosts::parse_pattern;
use ip_network::IpNetwork;
use std::net::IpAddr;
use url::Url;
Expand Down Expand Up @@ -36,21 +37,6 @@ fn parse_authority(scheme: &str, authority: &str) -> Option<(String, Option<u16>
Some((url.host_str()?.to_string(), url.port()))
}

/// Splits the host part of an allow-list pattern (`host` or `host:port-spec`,
/// with an IPv6 host in brackets) into its host and the raw, not-yet-parsed
/// port spec — unlike `parse_authority`, the port half may also be `*`.
fn split_pattern(pat: &str) -> (&str, Option<&str>) {
if let Some(rest) = pat.strip_prefix('[') {
if let Some((host, tail)) = rest.split_once(']') {
return (host, tail.strip_prefix(':'));
}
}
match pat.rsplit_once(':') {
Some((host, port)) => (host, Some(port)),
None => (pat, None),
}
}

/// The well-known default port for a URL scheme, used when an allow-list
/// entry omits a port entirely.
fn well_known_port(scheme: &str) -> Option<u16> {
Expand All @@ -77,8 +63,8 @@ fn port_matches(pat_port: Option<&str>, scheme: &str, port: Option<u16>) -> bool
/// Returns true if `scheme://authority` (the outbound request's scheme and
/// `host[:port]`) is allowed by the per-request `allowed` list. An allow-list
/// entry with an explicit scheme (`https://example.com`) only matches that
/// scheme; an entry without one, or with a `*` scheme (`*://example.com`),
/// matches any scheme. An entry with no port matches only the request
/// scheme; an entry with a `*` scheme (`*://example.com`) matches any scheme.
/// An entry with no port matches only the request
/// scheme's default port (80/http, 443/https); `:*` allows any port and
/// `:8080` allows exactly that port.
pub fn host_allowed(scheme: &str, authority: &str, allowed: &[String]) -> bool {
Expand All @@ -93,28 +79,16 @@ pub fn host_allowed(scheme: &str, authority: &str, allowed: &[String]) -> bool {
return false; // deny internal targets unconditionally
}
allowed.iter().any(|raw| {
let raw = raw.trim();
if raw == "*" {
return false; // a bare "*" from the per-request list must not allow-all
let Some(pattern) = parse_pattern(raw) else { return false };
if matches!(pattern.host.as_str(), "*" | "self" | "self.alt") || (pattern.scheme != "*" && pattern.scheme != scheme_lower) {
return false;
}
let (pat_scheme, rest) = match raw.split_once("://") {
Some((s, rest)) => (Some(s.to_ascii_lowercase()), rest),
None => (None, raw),
};
if let Some(ps) = &pat_scheme {
if ps != "*" && *ps != scheme_lower {
return false;
}
}
let rest = rest.split('/').next().unwrap_or(rest);
let (pat_host, pat_port) = split_pattern(rest);
let pat_host = pat_host.to_ascii_lowercase();
let host_matches = if let Some(base) = pat_host.strip_prefix("*.") {
host_lower == base || host_lower.ends_with(&format!(".{base}"))
let host_matches = if pattern.subdomains_only {
host_lower.ends_with(&format!(".{}", pattern.host))
} else {
host_lower == pat_host
host_lower == pattern.host
};
host_matches && port_matches(pat_port, &scheme_lower, port)
host_matches && port_matches(pattern.port.as_deref(), &scheme_lower, port)
})
}

Expand All @@ -131,7 +105,7 @@ mod tests {
fn allows_an_exact_host_match() {
// No port in the pattern defaults to the request scheme's well-known port,
// mirroring Spin's own allowed_outbound_hosts port mechanics.
let allowed = vec!["example.com".to_string()];
let allowed = vec!["https://example.com".to_string()];
assert!(host_allowed("https", "example.com", &allowed));
assert!(host_allowed("https", "example.com:443", &allowed));
assert!(!host_allowed("https", "example.com:8080", &allowed));
Expand All @@ -140,15 +114,15 @@ mod tests {

#[test]
fn allows_a_host_port_pattern_only_on_that_port() {
let allowed = vec!["example.com:8080".to_string()];
let allowed = vec!["https://example.com:8080".to_string()];
assert!(host_allowed("https", "example.com:8080", &allowed));
assert!(!host_allowed("https", "example.com:9090", &allowed));
assert!(!host_allowed("https", "example.com", &allowed));
}

#[test]
fn a_wildcard_port_allows_any_port() {
let allowed = vec!["example.com:*".to_string()];
let allowed = vec!["https://example.com:*".to_string()];
assert!(host_allowed("https", "example.com", &allowed));
assert!(host_allowed("https", "example.com:8080", &allowed));
assert!(host_allowed("https", "example.com:65535", &allowed));
Expand All @@ -173,32 +147,38 @@ mod tests {

#[test]
fn a_star_scheme_pattern_matches_any_scheme() {
let allowed = vec!["*://example.com".to_string()];
let allowed = vec!["*://example.com:*".to_string()];
assert!(host_allowed("http", "example.com", &allowed));
assert!(host_allowed("https", "example.com", &allowed));
}

#[test]
fn a_scheme_less_pattern_matches_any_scheme() {
fn a_scheme_less_pattern_does_not_match() {
let allowed = vec!["example.com".to_string()];
assert!(host_allowed("http", "example.com", &allowed));
assert!(host_allowed("https", "example.com", &allowed));
assert!(!host_allowed("http", "example.com", &allowed));
assert!(!host_allowed("https", "example.com", &allowed));
}

#[test]
fn wildcard_subdomain_matches_any_depth_and_the_bare_domain() {
// By design, "*.example.com" also covers the bare "example.com", not just
// strict subdomains.
let allowed = vec!["*.example.com".to_string()];
fn wildcard_subdomain_matches_any_depth_but_not_the_bare_domain() {
let allowed = vec!["https://*.example.com".to_string()];
assert!(host_allowed("https", "a.example.com", &allowed));
assert!(host_allowed("https", "a.b.example.com", &allowed));
assert!(host_allowed("https", "example.com", &allowed));
assert!(!host_allowed("https", "example.com", &allowed));
assert!(!host_allowed("https", "notexample.com", &allowed));
}

#[test]
fn a_bare_star_does_not_allow_all() {
assert!(!host_allowed("https", "example.com", &["*".to_string()]));
assert!(!host_allowed("https", "example.com", &["*://*:*".to_string()]));
}

#[test]
fn spin_self_patterns_do_not_match_outbound_hosts() {
for host in ["self", "self.alt"] {
assert!(!host_allowed("https", host, &[format!("https://{host}")]));
}
}

#[test]
Expand All @@ -216,13 +196,13 @@ mod tests {
"[fc00::1]",
"[::ffff:169.254.169.254]", // IPv4-mapped IPv6
] {
assert!(!host_allowed("https", host, &[host.trim_start_matches('[').trim_end_matches(']').to_string()]), "{host}");
assert!(!host_allowed("https", host, &[format!("https://{host}")]), "{host}");
}
}

#[test]
fn allows_public_ip_literals() {
let allowed = vec!["93.184.216.34".to_string()];
let allowed = vec!["https://93.184.216.34".to_string()];
assert!(host_allowed("https", "93.184.216.34", &allowed));
}

Expand All @@ -238,7 +218,7 @@ mod tests {

#[test]
fn matching_is_case_insensitive() {
let allowed = vec!["Example.COM".to_string()];
let allowed = vec!["https://Example.COM".to_string()];
assert!(host_allowed("https", "example.com", &allowed));
assert!(host_allowed("HTTPS", "EXAMPLE.com", &allowed));
}
Expand Down
2 changes: 2 additions & 0 deletions crates/guard-io/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
//! This stays separate from `guard` to avoid a component-instantiation cycle.

mod bindings;
#[path = "../../allowed_hosts.rs"]
mod allowed_hosts;
mod capabilities;
mod context;
mod egress;
Expand Down
3 changes: 2 additions & 1 deletion crates/guard/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "guard"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
license = "Apache-2.0 WITH LLVM-exception"
publish = false

[lib]
Expand All @@ -15,3 +15,4 @@ anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
futures = "0.3"
url = "2"
Loading
Loading