Skip to content

[FEATURE] Option to disable network access, and RFC 6598 shared address space (100.64.0.0/10) in the SSRF blocklist #107

Description

@robertkern

Binding

Shell core / Not binding-specific

Problem Statement

The SSRF guard works as documented: curl refuses RFC 1918, link-local,
loopback and IMDS addresses and lets everything else through, and allowed_urls
adds exceptions to that list. Two things are hard to do with that model when the
shell is embedded in a service running inside a cloud VPC:

  1. There is no way to say "no network at all". allowed_urls only widens
    access; there is no deny-list, no network: false, and no way to leave
    curl out of the command table. An embedder who wants a filesystem plus
    jq/lua for data processing, with no egress, cannot express that.

  2. Addresses in 100.64.0.0/10 are treated as public. RFC 6598 "shared
    address space" (carrier-grade NAT) is not routable on the public internet,
    and it is common as a Kubernetes pod or secondary VPC CIDR precisely because
    RFC 1918 space runs out: kOps used it as the default pod network, AWS
    documents it as the secondary CIDR for EKS custom networking, and Tailscale
    uses it too. Internal load balancers in such clusters resolve into it, so
    from a pod inside the cluster curl can reach every internal service fronted
    by one of those addresses, unauthenticated where the service trusts the
    network perimeter.

import strands_shell

s = strands_shell.Shell(timeout=5)
print(s.run("curl -s http://10.0.0.1/").stderr)     # "curl: access denied: 10.0.0.1", exit 1
print(s.run("curl -s http://[fc00::1]/").stderr)    # "curl: access denied: fc00::1", exit 1
print(s.run("curl -s http://100.64.0.1/").status)   # exit 6: connect attempted, not refused

s = strands_shell.Shell(timeout=5, allowed_urls=["https://example.com"])
print(s.run("curl -s http://100.64.0.1/").status)   # exit 6: the allowlist restricts nothing

Observed on 0.3.3 (Python, macOS arm64 and Linux x86_64); main at b4290ca has
the same is_ip_blocked.

Proposed Solution

  • A builder option to disable network access, e.g. .network(false) in Rust,
    network_enabled=False / networkEnabled: false in the Python and Node
    bindings and a top-level network = false in the TOML config. It would make
    check_url refuse every URL (or leave curl out of the command table) and be
    reported in Shell.config.

  • Add RFC 6598 to is_ip_blocked in src/vfs_kernel.rs:

    // RFC 6598 shared address space (CGNAT), 100.64.0.0/10
    || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64)

    (Ipv4Addr::is_shared() is the same test but may still be behind the ip
    feature gate.) The README bullet would then read "RFC 1918, RFC 6598,
    link-local, loopback, IMDS".

  • Optionally, a denied_urls list mirroring allowed_urls, for embedders who
    need public access but want to fence off specific internal zones (an IPv6 VPC
    prefix, an internal DNS suffix) that no generic blocklist can know about.

Use Case

An agent platform runs Strands Shell in-process inside a Kubernetes pod that
legitimately needs the cluster's internal APIs and a model provider. The shell is
given to LLM agents as a data-processing tool: results are staged into the VFS and
reduced with jq/lua. Those agents read untrusted text (issue trackers, merge
request descriptions, web pages), so a prompt-injected curl -X POST http://<internal-lb>/... is a realistic path, and the pod's network reach is
exactly what the agent inherits.

With network_enabled=False the platform could grant the shell without granting
egress. With RFC 6598 in the blocklist, the documented "private ranges" promise
would hold on the clusters described above even for embedders who keep public
curl.

Alternatives Solutions

Things an embedder can try today, and why they do not hold:

  • Shadow curl with a shell function. Catches direct and indirect calls in
    the shell, but is undone by unset -f curl and does not apply to Lua's
    os.execute / io.popen, which run outside the function table.
  • Remove /bin/curl. It is a builtin; the VFS entry is read-only even via
    the host-side remove_file, and alias is never expanded.
  • Reject commands containing curl before run(). Defeated by string
    construction (c=cu; d=rl; $c$d, or the same in Lua).
  • Point the client at a black-hole proxy. The reqwest client is built per
    request from the process environment, so HTTPS_PROXY would also redirect
    the host process's own HTTP clients.
  • Network policy. The shell shares the pod's network namespace, and the pod
    needs the same internal endpoints, so policy cannot separate the two.
  • Run each shell in a container or microVM, as the README recommends for
    adversarial workloads. Correct, but it gives up the in-process embedding that
    is the library's main appeal; a network switch would cover the common case
    without it.

Additional Context

The IPv6 side has the same shape and no blocklist answer: pods in an IPv6
cluster carry global unicast addresses (e.g. 2600::/12 on AWS), which no "is it
private?" test can recognise as internal. That is the main reason to prefer a
hard off switch over an ever-longer list.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Language

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions