Expose artifacts as MCP resources and add sandbox egress policy

Expose a user's artifacts through the MCP server as readable resources: each is
listed under an artifact:// URI with its mime type and read on demand as inline
text or a base64 blob, bounded by a size cap and scoped strictly to the owning
principal resolved from the request's API key, so no artifact is served across
tenants. Ship the network-level egress controls the sandbox runner needs but
cannot self-apply: a Kubernetes NetworkPolicy that allows public egress while
denying RFC1918, link-local, and cloud-metadata ranges, an optional
docker-compose egress overlay, and runner network-hardening docs.
This commit is contained in:
Alex committed 2026-06-24 13:57:39 +01:00
1 parent d26a973189
commit 30397a1905
9 files changed
+862 -8

No files matched your search

@@ -0,0 +1,71 @@
# docsgpt-sandbox runner (Jupyter Kernel Gateway). Sessions are in-process
# kernels, never child containers; the Docker socket is NOT mounted. Network
# egress is constrained by the NetworkPolicy under
# deployment/k8s/network-policies/sandbox-egress-policy.yaml -- apply both.
#
# On Linux prod, schedule this onto a gVisor `runsc` RuntimeClass for kernel
# isolation (uncomment `runtimeClassName` once the node has it installed).
apiVersion: apps/v1
kind: Deployment
metadata:
name: docsgpt-sandbox
spec:
replicas: 1
selector:
matchLabels:
app: docsgpt-sandbox
template:
metadata:
labels:
app: docsgpt-sandbox
spec:
# runtimeClassName: gvisor
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: docsgpt-sandbox
image: arc53/docsgpt-sandbox
ports:
- containerPort: 8888
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
env:
- name: JUPYTER_RUNTIME_DIR
value: /tmp/jupyter-runtime
- name: JUPYTER_DATA_DIR
value: /tmp/jupyter-data
resources:
limits:
memory: "1Gi"
cpu: "1"
requests:
memory: "256Mi"
cpu: "250m"
volumeMounts:
# Per-session workspaces + Jupyter runtime files on an emptyDir;
# the root FS stays read-only everywhere else.
- name: scratch
mountPath: /tmp
volumes:
- name: scratch
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: docsgpt-sandbox
spec:
selector:
app: docsgpt-sandbox
ports:
- protocol: TCP
port: 8888
targetPort: 8888
# Cluster-internal only: the runner must never be exposed outside the cluster.
type: ClusterIP
@@ -0,0 +1,85 @@
# Egress / SSRF NetworkPolicy for the docsgpt-sandbox runner.
#
# The sandbox executes arbitrary LLM-authored code, so app-level URL checks
# cannot contain it -- the code opens its own sockets. The hardened container
# runs without NET_ADMIN and cannot self-apply iptables, so egress is enforced
# here at the network layer: broad public-internet egress is allowed, but all
# private / link-local / metadata ranges are DENIED. Ingress is restricted to
# the backend and worker pods only.
#
# Requires a NetworkPolicy-enforcing CNI (Calico, Cilium, etc.). The default
# kube-proxy / flannel setups do NOT enforce these rules. Apply with:
# kubectl apply -f deployment/k8s/network-policies/sandbox-egress-policy.yaml
#
# Pods this selects must carry the label `app: docsgpt-sandbox`.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: docsgpt-sandbox-egress
labels:
app: docsgpt-sandbox
spec:
podSelector:
matchLabels:
app: docsgpt-sandbox
policyTypes:
- Ingress
- Egress
ingress:
# Only the backend/worker may reach the sandbox gateway (TCP 8888).
- from:
- podSelector:
matchLabels:
app: docsgpt-api
- podSelector:
matchLabels:
app: docsgpt-worker
ports:
- protocol: TCP
port: 8888
egress:
# DNS to cluster resolvers (kube-dns). Restricted to UDP/TCP 53 so the
# broad-egress rule below does not need to whitelist the resolver IP.
- to:
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Broad public-internet egress, with every private / link-local / ULA /
# carrier-grade-NAT / documentation range carved out via `except`. The
# cloud metadata IP 169.254.169.254 is inside the 169.254.0.0/16 hole, so
# it is unreachable. SSRF to internal services is therefore blocked while
# legitimate outbound calls (package installs, public APIs) still work.
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 0.0.0.0/8 # "this network" (RFC1122)
- 10.0.0.0/8 # RFC1918 private
- 172.16.0.0/12 # RFC1918 private
- 192.168.0.0/16 # RFC1918 private
- 169.254.0.0/16 # link-local incl. 169.254.169.254 metadata
- 100.64.0.0/10 # RFC6598 carrier-grade NAT
- 127.0.0.0/8 # loopback
- 192.0.0.0/24 # IETF protocol assignments
- 192.0.2.0/24 # TEST-NET-1 (documentation)
- 198.18.0.0/15 # benchmarking
- 198.51.100.0/24 # TEST-NET-2 (documentation)
- 203.0.113.0/24 # TEST-NET-3 (documentation)
- 224.0.0.0/4 # multicast
- 240.0.0.0/4 # reserved
- 255.255.255.255/32 # limited broadcast (RFC1122)
# IPv6 public egress with private / ULA / link-local ranges carved out.
# The IPv6 metadata endpoint (fd00:ec2::254) sits inside the fc00::/7 ULA
# hole and is unreachable. Drop this block on IPv4-only clusters.
- to:
- ipBlock:
cidr: "::/0"
except:
- "::1/128" # loopback
- "fc00::/7" # unique local (ULA) incl. fd00:ec2::254
- "fe80::/10" # link-local
- "ff00::/8" # multicast
- "2001:db8::/32" # documentation
@@ -0,0 +1,71 @@
# Optional egress-firewall overlay for the docsgpt-sandbox runner (compose).
#
# Docker Compose cannot express L3 egress filtering the way a Kubernetes
# NetworkPolicy can, so SSRF containment in compose deployments is delivered by
# routing the sandbox's outbound traffic through an egress-gateway sidecar that
# DENIES private / link-local / metadata ranges and ALLOWS the public internet.
#
# Two viable approaches; pick one:
#
# (1) Host / cloud firewall (simplest, recommended for single-host compose):
# Drop egress to RFC1918 (10/8, 172.16/12, 192.168/16), link-local
# (169.254/16, incl. the 169.254.169.254 metadata IP), and ULA on the
# docsgpt-sandbox container's interface using iptables/nftables on the
# Docker host (Docker does not do this for you). Example (host root):
# SBX=$(docker inspect -f '{{.NetworkSettings.Networks.docsgpt_sandbox-net.IPAddress}}' docsgpt-sandbox)
# iptables -I DOCKER-USER -s "$SBX" -d 169.254.0.0/16 -j DROP
# iptables -I DOCKER-USER -s "$SBX" -d 10.0.0.0/8 -j DROP
# iptables -I DOCKER-USER -s "$SBX" -d 172.16.0.0/12 -j DROP
# iptables -I DOCKER-USER -s "$SBX" -d 192.168.0.0/16 -j DROP
# (Allow established/return traffic and DNS as needed for your setup.)
#
# (2) Egress-gateway sidecar (this overlay): pin the sandbox to an
# internal-only network with NO direct internet route, and force all of
# its outbound traffic through a small forward proxy that blocks private
# destinations. The proxy is the ONLY container on both the internal and
# the egress network, so the sandbox cannot reach internal services
# directly. Configure the runner's outbound HTTP(S) via the proxy env
# vars below; lock down non-HTTP egress with the proxy's own rules.
#
# Apply alongside the base stack:
# docker compose -f deployment/docker-compose.yaml \
# -f deployment/optional/docker-compose.optional.sandbox-egress.yaml up -d
#
# NOTE: a forward proxy only constrains traffic that honors the proxy env vars.
# Truly arbitrary sandbox code can ignore them, so on a multi-tenant or
# untrusted deployment prefer approach (1) (or the Kubernetes NetworkPolicy)
# which enforces at the network layer regardless of what the code does.
version: "3.8"
services:
# Forward proxy that denies private/link-local/metadata destinations and
# allows the public internet. Replace the image/command with your proxy of
# choice (tinyproxy, squid, mitmproxy with a deny rule, etc.); the ACL must
# DENY 10/8, 172.16/12, 192.168/16, 169.254/16, 127/8 and ALLOW the rest.
sandbox-egress-proxy:
image: ghcr.io/example/egress-deny-private:latest # replace with your proxy image
restart: unless-stopped
networks:
- sandbox-net # reachable by the sandbox
- sandbox-egress # the only container with an internet route
docsgpt-sandbox:
# Cut the sandbox off from the default (internet) bridge; it keeps only the
# internal sandbox-net and must egress via the proxy.
networks:
- sandbox-net
environment:
- HTTP_PROXY=http://sandbox-egress-proxy:8080
- HTTPS_PROXY=http://sandbox-egress-proxy:8080
- NO_PROXY=localhost,127.0.0.1
networks:
# Internal-only network shared by the sandbox and the proxy. ``internal:
# true`` removes the default gateway so the sandbox has NO direct internet
# route -- its only path out is through the proxy on the egress network.
sandbox-net:
driver: bridge
internal: true
# Internet-facing network for the proxy only.
sandbox-egress:
driver: bridge
+49 -4
View File
@@ -62,8 +62,53 @@ Docling uses its own permissive PDF backend; **PyMuPDF (AGPL) is intentionally n
installed.** If the base (non-extract) image is used, `extract_document` returns a
clean "docling is not available in the sandbox runner" error rather than crashing.
## Hardening (separate slice — not in this image)
## Network egress / SSRF
Egress/SSRF blocks (drop RFC1918, link-local, `169.254.169.254`), the gVisor
`runsc` runtime, seccomp, read-only root FS, and cgroup CPU/mem/PID caps wired
from `SANDBOX_MEMORY` / `SANDBOX_CPUS` land in the hardening slice.
The runner allows **broad outbound egress** (so sandboxed code can `pip install`
and call public APIs) but private, link-local, and cloud-metadata ranges **MUST
be blocked at the network layer**. This is not optional: the sandbox executes
arbitrary LLM-authored code, which opens its own sockets — app-level URL checks
(the `mcp_tool.py` approach) cannot contain it. Without a network-layer block,
sandbox code can reach `169.254.169.254` (cloud instance metadata / credentials)
and internal services on the private network.
The hardened container runs **without `NET_ADMIN`**, so it cannot self-apply
`iptables`. Enforcement therefore lives in deployment config:
- **Kubernetes** — apply
[`deployment/k8s/network-policies/sandbox-egress-policy.yaml`](../k8s/network-policies/sandbox-egress-policy.yaml).
It allows `0.0.0.0/0` egress with `except` carve-outs for RFC1918
(`10/8`, `172.16/12`, `192.168/16`), link-local (`169.254/16`, which contains
`169.254.169.254`), loopback, CGNAT, documentation/test ranges, and the IPv6
ULA/link-local equivalents — and restricts ingress to the API/worker pods on
TCP 8888. It requires a policy-enforcing CNI (Calico, Cilium, …); plain
flannel/kube-proxy will silently not enforce it. The matching sandbox pod is
[`deployment/k8s/deployments/sandbox-deploy.yaml`](../k8s/deployments/sandbox-deploy.yaml)
(label `app: docsgpt-sandbox`).
```bash
kubectl apply -f deployment/k8s/deployments/sandbox-deploy.yaml
kubectl apply -f deployment/k8s/network-policies/sandbox-egress-policy.yaml
```
- **docker-compose** — compose cannot express L3 egress filtering natively. The
base stack puts the runner on an `internal: true` network (no host port), but
that does not by itself block the metadata IP or RFC1918 reachable via the
default bridge. Add a **host/cloud firewall rule** (drop the four private
ranges on the sandbox container's interface) **or** route egress through an
**egress-gateway proxy** sidecar. Both are documented in
[`deployment/optional/docker-compose.optional.sandbox-egress.yaml`](../optional/docker-compose.optional.sandbox-egress.yaml).
On untrusted/multi-tenant hosts prefer the host-firewall rule — a forward
proxy only constrains code that honors `HTTP(S)_PROXY`.
## Other hardening (deployment-level)
The gVisor `runsc` runtime (kernel isolation for untrusted code), seccomp
profile, read-only root FS, non-root, and cgroup CPU/mem/PID caps (wired from
`SANDBOX_MEMORY` / `SANDBOX_CPUS`) are deployment-level concerns. The compose
service in `deployment/docker-compose.yaml` already sets `read_only`,
`mem_limit`, `cpus`, and `pids_limit`; the k8s `sandbox-deploy.yaml` sets the
equivalent `securityContext` + resource limits and has a commented
`runtimeClassName: gvisor` to enable on nodes with the `runsc` RuntimeClass
installed. These complement — they do not replace — the network egress policy
above.