Stream: git-wasmtime

Topic: wasmtime / issue #7681 WASI: Fine-grained network policies


view this post on Zulip Wasmtime GitHub notifications bot (Dec 13 2023 at 15:40):

badeend opened issue #7681:

#7662 is a good first step towards letting users of the wasmtime library customize network behavior. After that change, library users have two options: either use inherit_network which grants access to everything, or use socket_addr_check which allows the user to define arbitrarily complex rules. The upside of that last one is also its downside: the user _must_ define everything themself. On the command-line, the available choices are quite limiting: everything or nothing at all. Guess which one users will pick :P

I would like to add a middle ground option for both library & CLI users. This option should provide a default "good enough" option for the majority of users, while still allowing to fall back to fully customized control. My objective is to be able to declare network policies based on:

I tried to capture the gist of it in pseudo code:

// A single network permission
enum Grant {

    // Allow TCP sockets to connect to remote_host/port, optionally using a specific local_interface/port
    TcpOutbound {
        remote_host: RemoteHostPattern,
        remote_port: RemotePortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
        local_port: LocalPortPattern, // default = LocalPortPattern::Ephemeral
    },

    // Allow TCP sockets to listen on a local port, optionally using a specific local_interface too
    TcpInbound {
        local_port: LocalPortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
    },

    // Allow UDP sockets to initiate flows to remote_host/port, optionally using a specific local_interface/port
    UdpOutbound {
        remote_host: RemoteHostPattern,
        remote_port: RemotePortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
        local_port: LocalPortPattern, // default = LocalPortPattern::Ephemeral
    },

    // Allow UDP sockets to handle incoming flows on a local port, optionally using a specific local_interface too
    UdpInbound {
        local_port: LocalPortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
    },

    // Allows the component to make outgoing HTTP connections
    HttpOutbound {
        scheme: Http | Https,
        host: RemoteHostPattern,
        port: RemotePortPattern,
    },
}


// "*"             -> RemoteHostPattern::Any
// "localhost"     -> RemoteHostPattern::Loopback
// "example.com"   -> RemoteHostPattern::Domain(DomainPattern::Single("example.com"))
// "*.example.com" -> RemoteHostPattern::Domain(DomainPattern::Wildcard("example.com"))
// "192.0.2.0"     -> RemoteHostPattern::Ip(IpRange("192.0.2.0".into())
// "192.0.2.0/24"  -> RemoteHostPattern::Ip(IpRange("192.0.2.0/24".into())
enum RemoteHostPattern {
    Any,
    Loopback, // Effectively `127.0.0.0/8` and `::1`, but without committing to a specific address family.
    Ip(IpRange),
    Domain(DomainPattern),
}


// "*"           -> RemotePortPattern::Range(1..=u16::MAX))
// "0"           -> invalid
// "80"          -> RemotePortPattern::Range(80..=80))
// "35000-35999" -> RemotePortPattern::Range(35000..=35999))
enum RemotePortPattern {
    Range(PortRange),
}


// "*"         -> LocalInterfacePattern::Any
// "localhost" -> LocalInterfacePattern::Loopback
// "192.0.2.0" -> LocalInterfacePattern::Ip(IpRange("192.0.2.0".into())
// "::"        -> LocalInterfacePattern::Ip(IpRange("::".into())
enum LocalInterfacePattern {
    Any, // Effectively `0.0.0.0` and `::`, but without committing to a specific address family.
    Loopback, // Effectively `127.0.0.0/8` and `::1`, but without committing to a specific address family.
    Ip(IpRange),
}


// "*"           -> LocalPortPattern::Range(0..=u16::MAX))
// "0"           -> LocalPortPattern::Ephemeral
// "80"          -> LocalPortPattern::Range(80..=80))
// "35000-35999" -> LocalPortPattern::Range(35000..=35999))
enum LocalPortPattern {
    Ephemeral,
    Range(PortRange),
}



type PortRange = RangeInclusive<u16>; // Can also represent single ports by storing an identical `start` and `end` port.

struct IpRange(ipnet::IpNet); // Can also represent a single address

struct Domain(String);

enum DomainPattern {
    Single(Domain),
    Wildcard(Domain), // Allows the domain itself, along with every subdomain.
}

Domain-based policy strategy

THe IP and Port-based patterns above should speak for themselves. The domain name policies might need to explanation: the idea is to hook into ip-name-lookup::resolve-addresses to keep track of which IP address belongs to which domain names at runtime:
- In ip-name-lookup::resolve-addresses:
- Before making the syscall: validate that any TcpOutbound or UdpOutbound grant exists with a Any or matching Domain host pattern.
- After making the syscall: if the previous step matched any Domain-based grants: register the resolved addresses in DynamicPolicy::resolved_names (see below)
- In tcp-socket::bind: validate that any TcpOutbound or TcpInbound grant exists with a matching local_interface and local_port
- In tcp-socket::connect, validate that any TcpOutbound grant matches the local_interface & local_port. Also match the remote_host & remote_port:
- first by the IP address passed to the connect call. If none found:
- then by all resolved_names for that IP.

pub struct DynamicPolicyConfig { // Shared across many component instances.
    grants: Vec<Grant>,
}

pub struct DynamicPolicy { // Instantiated once per component
    // Reference to the "static" rules:
    config: Arc<DynamicPolicyConfig>,

    // Mapping between resolved IP addresses and the queried domain names.
    resolved_names: LruCache<IpAddr, Vec<Domain>>,

    // (Recently) active UDP flows
    udp_flows: LruCache<(/*local*/SocketAddr, /*remote*/SocketAddr), ()>
}

UDP directionality

UDP has no traditional notion of "client" and "server". However, in practice many UDP applications do fit that model. I've modeled the grant types above based on what stateful firewall do. In order to know the directionality (inbound vs outbound) we need to keep track of "who talked first".

CLI syntax

Inbound syntax, inspired by Docker:

--expose 80                      // Grant::TcpInbound(...) & Grant::UdpInbound(...)
--expose 80/tcp                  // Grant::TcpInbound(...)
--expose 80/udp                  // Grant::UdpInbound(...)
--expose 127.0.0.1:80/udp        // Grant::UdpInbound(..., local_interface: "127.0.0.1")

Outbound syntax:

--connect tcp://example.com:80    // Grant::TcpOutbound(...)
--connect udp://192.168.0.1:80    // Grant::TcpOutbound(...)
--connect http://*.example.com/   // Grant::HttpOutbound(...)
--connect https://example.com/    // Grant::HttpOutbound(...)

Let me know what you think.

view this post on Zulip Wasmtime GitHub notifications bot (Dec 13 2023 at 15:45):

badeend edited issue #7681:

#7662 is a good first step towards letting users of the wasmtime library customize network behavior. After that change, library users have two options: either use inherit_network which grants access to everything, or use socket_addr_check which allows the user to define arbitrarily complex rules. The upside of that last one is also its downside: the user _must_ define everything themself. On the command-line, the available choices are quite limited: everything or nothing at all. Guess which one users will pick :P

I would like to add a middle ground option for both library & CLI users. This option should provide a default "good enough" option for the majority of users, while still allowing to fall back to fully customized control. My objective is to be able to declare network policies based on:

I tried to capture the gist of it in pseudo code:

// A single network permission
enum Grant {

    // Allow TCP sockets to connect to remote_host/port, optionally using a specific local_interface/port
    TcpOutbound {
        remote_host: RemoteHostPattern,
        remote_port: RemotePortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
        local_port: LocalPortPattern, // default = LocalPortPattern::Ephemeral
    },

    // Allow TCP sockets to listen on a local port, optionally using a specific local_interface too
    TcpInbound {
        local_port: LocalPortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
    },

    // Allow UDP sockets to initiate flows to remote_host/port, optionally using a specific local_interface/port
    UdpOutbound {
        remote_host: RemoteHostPattern,
        remote_port: RemotePortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
        local_port: LocalPortPattern, // default = LocalPortPattern::Ephemeral
    },

    // Allow UDP sockets to handle incoming flows on a local port, optionally using a specific local_interface too
    UdpInbound {
        local_port: LocalPortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
    },

    // Allows the component to make outgoing HTTP connections
    HttpOutbound {
        scheme: Http | Https,
        host: RemoteHostPattern,
        port: RemotePortPattern,
    },
}


// "*"             -> RemoteHostPattern::Any
// "localhost"     -> RemoteHostPattern::Loopback
// "example.com"   -> RemoteHostPattern::Domain(DomainPattern::Single("example.com"))
// "*.example.com" -> RemoteHostPattern::Domain(DomainPattern::Wildcard("example.com"))
// "192.0.2.0"     -> RemoteHostPattern::Ip(IpRange("192.0.2.0".into())
// "192.0.2.0/24"  -> RemoteHostPattern::Ip(IpRange("192.0.2.0/24".into())
enum RemoteHostPattern {
    Any,
    Loopback, // Effectively `127.0.0.0/8` and `::1`, but without committing to a specific address family.
    Ip(IpRange),
    Domain(DomainPattern),
}


// "*"           -> RemotePortPattern::Range(1..=u16::MAX))
// "0"           -> invalid
// "80"          -> RemotePortPattern::Range(80..=80))
// "35000-35999" -> RemotePortPattern::Range(35000..=35999))
enum RemotePortPattern {
    Range(PortRange),
}


// "*"         -> LocalInterfacePattern::Any
// "localhost" -> LocalInterfacePattern::Loopback
// "192.0.2.0" -> LocalInterfacePattern::Ip(IpRange("192.0.2.0".into())
// "::"        -> LocalInterfacePattern::Ip(IpRange("::".into())
enum LocalInterfacePattern {
    Any, // Effectively `0.0.0.0` and `::`, but without committing to a specific address family.
    Loopback, // Effectively `127.0.0.0/8` and `::1`, but without committing to a specific address family.
    Ip(IpRange),
}


// "*"           -> LocalPortPattern::Range(0..=u16::MAX))
// "0"           -> LocalPortPattern::Ephemeral
// "80"          -> LocalPortPattern::Range(80..=80))
// "35000-35999" -> LocalPortPattern::Range(35000..=35999))
enum LocalPortPattern {
    Ephemeral,
    Range(PortRange),
}



type PortRange = RangeInclusive<u16>; // Can also represent single ports by storing an identical `start` and `end` port.

struct IpRange(ipnet::IpNet); // Can also represent a single address

struct Domain(String);

enum DomainPattern {
    Single(Domain),
    Wildcard(Domain), // Allows the domain itself, along with every subdomain.
}

Domain-based policy strategy

THe IP and Port-based patterns above should speak for themselves. The domain name policies might need to explanation: the idea is to hook into ip-name-lookup::resolve-addresses to keep track of which IP address belongs to which domain names at runtime:
- In ip-name-lookup::resolve-addresses:
- Before making the syscall: validate that any TcpOutbound or UdpOutbound grant exists with a Any or matching Domain host pattern.
- After making the syscall: if the previous step matched any Domain-based grants: register the resolved addresses in DynamicPolicy::resolved_names (see below)
- In tcp-socket::bind: validate that any TcpOutbound or TcpInbound grant exists with a matching local_interface and local_port
- In tcp-socket::connect, validate that any TcpOutbound grant matches the local_interface & local_port. Also match the remote_host & remote_port:
- first by the IP address passed to the connect call. If none found:
- then by all resolved_names for that IP.

pub struct DynamicPolicyConfig { // Shared across many component instances.
    grants: Vec<Grant>,
}

pub struct DynamicPolicy { // Instantiated once per component
    // Reference to the "static" rules:
    config: Arc<DynamicPolicyConfig>,

    // Mapping between resolved IP addresses and the queried domain names.
    resolved_names: LruCache<IpAddr, Vec<Domain>>,

    // (Recently) active UDP flows
    udp_flows: LruCache<(/*local*/SocketAddr, /*remote*/SocketAddr), ()>
}

UDP directionality

UDP has no traditional notion of "client" and "server". However, in practice many UDP applications do fit that model. I've modeled the grant types above based on what stateful firewall do. In order to know the directionality (inbound vs outbound) we need to keep track of "who talked first".

CLI syntax

Inbound syntax, inspired by Docker:

--expose 80                      // Grant::TcpInbound(...) & Grant::UdpInbound(...)
--expose 80/tcp                  // Grant::TcpInbound(...)
--expose 80/udp                  // Grant::UdpInbound(...)
--expose 127.0.0.1:80/udp        // Grant::UdpInbound(..., local_interface: "127.0.0.1")

Outbound syntax:

--connect tcp://example.com:80    // Grant::TcpOutbound(...)
--connect udp://192.168.0.1:80    // Grant::TcpOutbound(...)
--connect http://*.example.com/   // Grant::HttpOutbound(...)
--connect https://example.com/    // Grant::HttpOutbound(...)

Let me know what you think.

view this post on Zulip Wasmtime GitHub notifications bot (Dec 13 2023 at 15:49):

badeend edited issue #7681:

#7662 is a good first step towards letting users of the wasmtime library customize network behavior. After that change, library users have two options: either use inherit_network which grants access to everything, or use socket_addr_check which allows the user to define arbitrarily complex rules. The upside of that last one is also its downside: the user _must_ define everything themself. On the command-line, the available choices are quite limited: everything or nothing at all. Guess which one users will pick :P

I would like to add a middle ground option for both library & CLI users. This option should provide a default "good enough" option for the majority of users, while still allowing to fall back to fully customized control. My objective is to be able to declare network policies based on:

I tried to capture the gist of it in pseudo code:

// A single network permission
enum Grant {

    // Allow TCP sockets to connect to remote_host/port, optionally using a specific local_interface/port
    TcpOutbound {
        remote_host: RemoteHostPattern,
        remote_port: RemotePortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
        local_port: LocalPortPattern, // default = LocalPortPattern::Ephemeral
    },

    // Allow TCP sockets to listen on a local port, optionally using a specific local_interface too
    TcpInbound {
        local_port: LocalPortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
    },

    // Allow UDP sockets to initiate flows to remote_host/port, optionally using a specific local_interface/port
    UdpOutbound {
        remote_host: RemoteHostPattern,
        remote_port: RemotePortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
        local_port: LocalPortPattern, // default = LocalPortPattern::Ephemeral
    },

    // Allow UDP sockets to handle incoming flows on a local port, optionally using a specific local_interface too
    UdpInbound {
        local_port: LocalPortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
    },

    // Allows the component to make outgoing HTTP connections
    HttpOutbound {
        scheme: Http | Https,
        host: RemoteHostPattern,
        port: RemotePortPattern,
    },
}


// "*"             -> RemoteHostPattern::Any
// "localhost"     -> RemoteHostPattern::Loopback
// "example.com"   -> RemoteHostPattern::Domain(DomainPattern::Single("example.com"))
// "*.example.com" -> RemoteHostPattern::Domain(DomainPattern::Wildcard("example.com"))
// "192.0.2.0"     -> RemoteHostPattern::Ip(IpRange("192.0.2.0".into())
// "192.0.2.0/24"  -> RemoteHostPattern::Ip(IpRange("192.0.2.0/24".into())
enum RemoteHostPattern {
    Any,
    Loopback, // Effectively `127.0.0.0/8` and `::1`, but without committing to a specific address family.
    Ip(IpRange),
    Domain(DomainPattern),
}


// "*"           -> RemotePortPattern::Range(1..=u16::MAX))
// "0"           -> invalid
// "80"          -> RemotePortPattern::Range(80..=80))
// "35000-35999" -> RemotePortPattern::Range(35000..=35999))
enum RemotePortPattern {
    Range(PortRange),
}


// "*"         -> LocalInterfacePattern::Any
// "localhost" -> LocalInterfacePattern::Loopback
// "192.0.2.0" -> LocalInterfacePattern::Ip(IpRange("192.0.2.0".into())
// "::"        -> LocalInterfacePattern::Ip(IpRange("::".into())
enum LocalInterfacePattern {
    Any, // Effectively `0.0.0.0` and `::`, but without committing to a specific address family.
    Loopback, // Effectively `127.0.0.0/8` and `::1`, but without committing to a specific address family.
    Ip(IpRange),
}


// "*"           -> LocalPortPattern::Range(0..=u16::MAX))
// "0"           -> LocalPortPattern::Ephemeral
// "80"          -> LocalPortPattern::Range(80..=80))
// "35000-35999" -> LocalPortPattern::Range(35000..=35999))
enum LocalPortPattern {
    Ephemeral,
    Range(PortRange),
}



type PortRange = RangeInclusive<u16>; // Can also represent single ports by storing an identical `start` and `end` port.

struct IpRange(ipnet::IpNet); // Can also represent a single address

struct Domain(String);

enum DomainPattern {
    Single(Domain),
    Wildcard(Domain), // Allows the domain itself, along with every subdomain.
}

Domain-based policy strategy

THe IP and Port-based patterns above should speak for themselves. The domain name policies might need to explanation: the idea is to hook into ip-name-lookup::resolve-addresses to keep track of which IP address belongs to which domain names at runtime:
- In ip-name-lookup::resolve-addresses:
- Before making the syscall: validate that any TcpOutbound or UdpOutbound grant exists with a Any or matching Domain host pattern.
- After making the syscall: if the previous step matched any Domain-based grants: register the resolved addresses in DynamicPolicy::resolved_names (see below)
- In tcp-socket::bind: validate that any TcpOutbound or TcpInbound grant exists with a matching local_interface and local_port
- In tcp-socket::connect, validate that any TcpOutbound grant matches the local_interface & local_port. Also match the remote_host & remote_port:
- first by the IP address passed to the connect call. If none found:
- then by all resolved_names for that IP.

pub struct DynamicPolicyConfig { // Shared across many component instances.
    grants: Vec<Grant>,
}

pub struct DynamicPolicy { // Instantiated once per component
    // Reference to the "static" rules:
    config: Arc<DynamicPolicyConfig>,

    // Mapping between resolved IP addresses and the queried domain names.
    resolved_names: LruCache<IpAddr, Vec<Domain>>,

    // (Recently) active UDP flows
    udp_flows: LruCache<(/*local*/SocketAddr, /*remote*/SocketAddr), ()>
}

UDP directionality

UDP has no traditional notion of "client" and "server". However, in practice many UDP applications do fit that model. I've modeled the grant types above based on what stateful firewall do. In order to know the directionality (inbound vs outbound) we need to keep track of "who talked first".

CLI syntax

Inbound syntax, inspired by Docker:

--expose 80                      // Grant::TcpInbound(...) & Grant::UdpInbound(...)
--expose 80/tcp                  // Grant::TcpInbound(...)
--expose 80/udp                  // Grant::UdpInbound(...)
--expose 127.0.0.1:80/udp        // Grant::UdpInbound(..., local_interface: "127.0.0.1")

Outbound syntax:

--connect tcp://example.com:80    // Grant::TcpOutbound(...) & Grant::HttpOutbound(...)
--connect udp://192.168.0.1:80    // Grant::TcpOutbound(...)
--connect http://*.example.com/   // Grant::HttpOutbound(...)
--connect https://example.com/    // Grant::HttpOutbound(...)

Let me know what you think.

view this post on Zulip Wasmtime GitHub notifications bot (Dec 13 2023 at 15:54):

badeend edited issue #7681:

#7662 is a good first step towards letting users of the wasmtime library customize network behavior. After that change, library users have two options: either use inherit_network which grants access to everything, or use socket_addr_check which allows the user to define arbitrarily complex rules. The upside of that last one is also its downside: the user _must_ define everything themself. On the command-line, the available choices are quite limited: everything or nothing at all. Guess which one users will pick :P

I would like to add a middle ground option for both library & CLI users. This option should provide a default "good enough" option for the majority of users, while still allowing to fall back to fully customized control. My objective is to be able to declare network policies based on:

I tried to capture the gist of it in pseudo code:

// A single network permission
enum Grant {

    // Allow TCP sockets to connect to remote_host/port, optionally using a specific local_interface/port
    TcpOutbound {
        remote_host: RemoteHostPattern,
        remote_port: RemotePortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
        local_port: LocalPortPattern, // default = LocalPortPattern::Ephemeral
    },

    // Allow TCP sockets to listen on a local port, optionally using a specific local_interface too
    TcpInbound {
        local_port: LocalPortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
    },

    // Allow UDP sockets to initiate flows to remote_host/port, optionally using a specific local_interface/port
    UdpOutbound {
        remote_host: RemoteHostPattern,
        remote_port: RemotePortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
        local_port: LocalPortPattern, // default = LocalPortPattern::Ephemeral
    },

    // Allow UDP sockets to handle incoming flows on a local port, optionally using a specific local_interface too
    UdpInbound {
        local_port: LocalPortPattern,
        local_interface: LocalInterfacePattern, // default = LocalInterfacePattern::Any
    },

    // Allows the component to make outgoing HTTP connections
    HttpOutbound {
        scheme: Http | Https,
        host: RemoteHostPattern,
        port: RemotePortPattern,
    },
}


// "*"             -> RemoteHostPattern::Any
// "localhost"     -> RemoteHostPattern::Loopback
// "example.com"   -> RemoteHostPattern::Domain(DomainPattern::Single("example.com"))
// "*.example.com" -> RemoteHostPattern::Domain(DomainPattern::Wildcard("example.com"))
// "192.0.2.0"     -> RemoteHostPattern::Ip(IpRange("192.0.2.0".into())
// "192.0.2.0/24"  -> RemoteHostPattern::Ip(IpRange("192.0.2.0/24".into())
enum RemoteHostPattern {
    Any,
    Loopback, // Effectively `127.0.0.0/8` and `::1`, but without committing to a specific address family.
    Ip(IpRange),
    Domain(DomainPattern),
}


// "*"           -> RemotePortPattern::Range(1..=u16::MAX))
// "0"           -> invalid
// "80"          -> RemotePortPattern::Range(80..=80))
// "35000-35999" -> RemotePortPattern::Range(35000..=35999))
enum RemotePortPattern {
    Range(PortRange),
}


// "*"         -> LocalInterfacePattern::Any
// "localhost" -> LocalInterfacePattern::Loopback
// "192.0.2.0" -> LocalInterfacePattern::Ip(IpRange("192.0.2.0".into())
// "::"        -> LocalInterfacePattern::Ip(IpRange("::".into())
enum LocalInterfacePattern {
    Any, // Effectively `0.0.0.0` and `::`, but without committing to a specific address family.
    Loopback, // Effectively `127.0.0.0/8` and `::1`, but without committing to a specific address family.
    Ip(IpRange),
}


// "*"           -> LocalPortPattern::Range(0..=u16::MAX))
// "0"           -> LocalPortPattern::Ephemeral
// "80"          -> LocalPortPattern::Range(80..=80))
// "35000-35999" -> LocalPortPattern::Range(35000..=35999))
enum LocalPortPattern {
    Ephemeral,
    Range(PortRange),
}



type PortRange = RangeInclusive<u16>; // Can also represent single ports by storing an identical `start` and `end` port.

struct IpRange(ipnet::IpNet); // Can also represent a single address

struct Domain(String);

enum DomainPattern {
    Single(Domain),
    Wildcard(Domain), // Allows the domain itself, along with every subdomain.
}

Domain-based policy strategy

THe IP and Port-based patterns above should speak for themselves. The domain name policies might need to explanation: the idea is to hook into ip-name-lookup::resolve-addresses to keep track of which IP address belongs to which domain names at runtime:
- In ip-name-lookup::resolve-addresses:
- Before making the syscall: validate that any TcpOutbound or UdpOutbound grant exists with a Any or matching Domain host pattern.
- After making the syscall: if the previous step matched any Domain-based grants: register the resolved addresses in DynamicPolicy::resolved_names (see below)
- In tcp-socket::bind: validate that any TcpOutbound or TcpInbound grant exists with a matching local_interface and local_port
- In tcp-socket::connect, validate that any TcpOutbound grant matches the local_interface & local_port. Also match the remote_host & remote_port:
- first by the IP address passed to the connect call. If none found:
- then by all resolved_names for that IP.

pub struct DynamicPolicyConfig { // Shared across many component instances.
    grants: Vec<Grant>,
}

pub struct DynamicPolicy { // Instantiated once per component
    // Reference to the "static" rules:
    config: Arc<DynamicPolicyConfig>,

    // Mapping between resolved IP addresses and the queried domain names.
    resolved_names: LruCache<IpAddr, Vec<Domain>>,

    // (Recently) active UDP flows
    udp_flows: LruCache<(/*local*/SocketAddr, /*remote*/SocketAddr), ()>
}

UDP directionality

UDP has no traditional notion of "client" and "server". However, in practice many UDP applications do fit that model. I've modeled the grant types above based on what stateful firewall do. In order to know the directionality (inbound vs outbound) we need to keep track of "who talked first".

CLI syntax

Inbound syntax:

--expose 80                      // Grant::TcpInbound(...) & Grant::UdpInbound(...) // Shorthand inspired by Docker
--expose 127.0.0.1:80            // Grant::TcpInbound(..., local_interface: "127.0.0.1") & Grant::UdpInbound(..., local_interface: "127.0.0.1")
--expose udp://127.0.0.1:80      // Grant::UdpInbound(..., local_interface: "127.0.0.1")

Outbound syntax:

--connect tcp://example.com:80    // Grant::TcpOutbound(...) & Grant::HttpOutbound(...)
--connect udp://192.168.0.1:80    // Grant::TcpOutbound(...)
--connect http://*.example.com/   // Grant::HttpOutbound(...)
--connect https://example.com/    // Grant::HttpOutbound(...)

Let me know what you think.

view this post on Zulip Wasmtime GitHub notifications bot (Dec 17 2023 at 18:42):

alexcrichton commented on issue #7681:

This all sounds like a great idea to me, thanks for writing this up @badeend!

One comment I would have is that I think it would be best to implement this in a way that's not baked-in to wasmtime-wasi itself, e.g. baking it into the WasiCtx. You've seen already (but for others reading this too) discussion at https://github.com/bytecodealliance/wasmtime/issues/7694 about ways we might achieve that, and I think it would be good if we could fit this model into that extension. Put another way this could be one implementation of socket_addr_check (more-or-less, I realize that single callback isn't enough) that embedders could opt-in to.

I think this is pretty reasonable syntax to add to the CLI though and I definitely agree it'd be best to have more than just an "everything on" switch!

view this post on Zulip Wasmtime GitHub notifications bot (Dec 18 2023 at 10:45):

rylev commented on issue #7681:

This all looks really great! Seems like we're headed in the right direction. A few small thoughts and questions:

view this post on Zulip Wasmtime GitHub notifications bot (Dec 18 2023 at 13:53):

badeend commented on issue #7681:

@alexcrichton

One comment I would have is that I think it would be best to implement this in a way that's not baked-in to wasmtime-wasi itself

I agree. Continuing on #7694 , I have the following in mind:

This example is for TCP Bind only, but you can imagine the same for other operations.

pub trait WasiTcpView: WasiView {
    /// Custom state maintained per socket instance.
    type Socket;

    /// Create new custom socket state. Guaranteed to be called at most once per WASI socket resource.
    fn new(&mut self) -> Self::Socket;

    /// Called at the moment the actual syscall would have been called. So _after_ the WASI-specific state & input validations.
    /// The `bind` parameter represents the actual sycall implementation that can be executed (or ignored) by the interceptor.
    /// With this general design, the interceptor:
    /// - has full control of what to execute before & after the syscall,
    /// - may conditionally execute or reject the syscall depending on the parameters,
    /// - can keep track of additional state per socket, that would otherwise not be maintained by wasmtime-wasi itself
    /// - (not in this example, but:) maybe manipulate parameters and return values. For example:
    ///   rewrite the address parameter from port 80 ("inside wasm") to 8080 ("outside wasm")
    fn intercept_bind(&mut self, socket: &mut Self::Socket, bind: TcpBind);
}



#[must_use="For clarity, explicitly drop it using one of the consuming methods."]
pub struct TcpBind<'a> {
    // ...
}
impl TcpBind<'_> {
    /// The address passed in by the user.
    pub fn requested_address(&self) -> &SocketAddr {}

    /// Consume self and perform the bind on the requested address. Returns the actually bound address.
    pub fn execute(mut self) -> std::io::Result<SocketAddr> {
        // Call rustix::bind etc.
    }

    /// Consume self and abort the bind with an error code.
    pub fn fail(self, error: TcpBindError) {
    }

    // The typical consuming methods will be `execute` and `fail`.
    // But `UdpSend` could have an `skip` method as well, that pretends to have sent the message, but actually it was dropped.
}

/// Subset of all wasi-sockets errors that are appropriate for `bind` to return.
#[non_exhaustive]
pub enum TcpBindError {
    AccessDenied,
    AddressInUse,
    AddressNotBindable,
}






// Example implementations
impl WasiTcpView for WasiCtx {
    type Socket = ();

    fn new(&mut self) {}

    // Example #1: allow everything
    fn intercept_bind(&mut self, socket: &mut Self::Socket, bind: TcpBind) {
        bind.execute()
    }

    // Example #2: deny everything
    fn intercept_bind(&mut self, socket: &mut Self::Socket, bind: TcpBind) {
        bind.fail(TcpBindError::AccessDenied)
    }

    // Example #3: arbitrary logic
    fn intercept_bind(&mut self, socket: &mut Self::Socket, bind: TcpBind) {
        if bind.requested_address().ip().is_loopback() {
            bind.execute()
        } else {
            bind.fail(TcpBindError::AccessDenied)
        }
    }
}

@rylev

Ah, yes. The classical "user experience" vs. "security" tradeoff. From an end-user's perspective I don't care how a specific Wasm module decided to implement their network requests. I.e. I want to say "you're allowed to fetch https://example.com/hi.txt" without knowing having to care whether that will be done using TCP/UDP directly or using a wasi-http client. To properly guard this off would effectively be a man-in-the-middle attack.
However, I think the other way around is perfectly reasonable, and more importantly: _feasible_ :stuck_out_tongue: . Allowing TCP traffic to a specific endpoint should also allow HTTP(1&2) traffic to that endpoint. Same for UDP and HTTP3.

That being said, none of this is really essential for to this issue, so I'm happy to postpone all "implying" to a future iteration.
I we allow wildcards in the protocol (e.g. *://example.com:443) that should be good enough for now.


I agree. At least for TCP and UDP.
For HTTP(S), the port _should_ be inferred IMO


A mix of "TBD" and "I don't care" :stuck_out_tongue:

view this post on Zulip Wasmtime GitHub notifications bot (Dec 18 2023 at 20:07):

badeend commented on issue #7681:

Coming back on my previous comment:

Even though the initial refactor may be more work, I think it's more straightforward to forget about "interceptors" completely and put the entire socket implementation behind traits whose implementations can be swapped out. My current train of thought is to create a "vanilla Rust" trait that loosely follows the wasi-sockets interface. With "vanilla" I mean:

#[async_trait]
pub trait TcpSocket {
    type InputStream: AsyncRead;
    type OutputStream: AsyncWrite;
    type AcceptStream: Stream<Item = io::Result<Self>>;

    fn new(addr: SocketAddr) -> io::Result<Self>;

    async fn bind(&mut self, addr: &SocketAddr) -> io::Result<()>;
    async fn connect(&mut self, addr: &SocketAddr) -> io::Result<(Self::InputStream, Self::OutputStream)>;
    async fn listen(&mut self) -> io::Result<Self::AcceptStream>;

    fn local_address(&self) -> io::Result<SocketAddr>;
    fn remote_address(&self) -> io::Result<SocketAddr>;

    fn keep_alive_enabled(&self) -> io::Result<bool>;
    fn set_keep_alive_enabled(&self, value: bool) -> io::Result<()>;

    // ...
}

Besides defining that trait, wasmtime_wasi should also provide a default implementation for it. Containing much of our current implementation. Custom implementations can then reuse that default implementation:

pub struct RestrictedTcpSocket {
    inner: SystemTcpSocket, // The default, native implementation
    // ...
}
#[async_trait]
impl TcpSocket for RestrictedTcpSocket {
    async fn bind(&mut self, addr: &SocketAddr) -> io::Result<()> {
        if addr.ip().is_loopback() {
            inner.bind(addr)
        } else {
            return Err(Error::new(ErrorKind::PermissionDenied, "Nope."));
        }
    }

    // ...
}

With that in place, impl<T: WasiView> crate::preview2::host::tcp::tcp::HostTcpSocket for T can focus fully on WASI stuff:
- Enforcing WASI-specific state invariants and parameter requirements
- Looking up resources from tables
- Mapping std errors into WASI error codes.
- Converting Rust's async & IO primitives into their WASI counterparts

WasiTcpView becomes a bit simpler too:

pub trait WasiTcpView {
    type Socket: TcpSocket;
}

view this post on Zulip Wasmtime GitHub notifications bot (Jan 16 2024 at 02:51):

Greensue commented on issue #7681:

@badeend hi, excuse me , do you have plan to achieve this fine-grained network plolices? Is there a specific timestone?

view this post on Zulip Wasmtime GitHub notifications bot (Jan 16 2024 at 08:57):

rylev commented on issue #7681:

This is being worked on in https://github.com/bytecodealliance/wasmtime/pull/7705. Hoping that it shouldn't be too much longer before this is ready to merge.

view this post on Zulip Wasmtime GitHub notifications bot (Jan 17 2024 at 01:56):

Greensue commented on issue #7681:

@rylev thanks, but I did not found a "grant" struct as badeend described use pseudo code。 would this be achieve in 7705 in the future? if there is a plan?for now, define a "socket_addr_check" func it's complexing。
![image](https://github.com/bytecodealliance/wasmtime/assets/23025897/9afdacb2-50cd-448a-9c8b-a2cd2554aa86)

view this post on Zulip Wasmtime GitHub notifications bot (Jan 18 2024 at 09:53):

badeend commented on issue #7681:

#7705 contains the preparational work discussed further in this issue. We haven't started on the design in the initial comment


Last updated: Oct 23 2024 at 20:03 UTC