mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-18 18:15:09 +00:00
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:
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
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user