Add DNS allowlisting, IPv6 block, and network logging to sandbox
DNS resolution was previously unrestricted - only the resulting IP connections were firewalled - so a compromised session could still exfiltrate data by encoding it into query names to an attacker-controlled domain. dns-proxy.sh now runs a local dnsmasq with no fallback resolver; only allowlisted names (shared with init-firewall.sh via fixed-domains.sh) get forwarded, everything else is refused outright. IPv6 is blocked outright via ip6tables DROP-all, since there was no IPv6 equivalent of the IPv4 allowlist. If LOKI_URL is set, every DNS query and every new outbound connection attempt (allowed or rejected, via NFLOG + ulogd2) ships to Loki as JSON, for visibility in the existing Grafana/Prometheus setup. Also fixes two bugs found along the way: - init-firewall.sh's script-wide IFS (set for stricter splitting elsewhere) silently broke EXTRA_ALLOWED_DOMAINS whenever it held more than one space-separated entry, collapsing it into a single bogus token. - bin/claude-sandbox-allow interpolated the domain argument directly into a bash -c string executed via docker exec - a command injection path, since sandbox-context.sh tells a sandboxed agent to hand this exact command to the user to copy-paste. Fixed with upfront domain validation and positional-argument passing instead of string interpolation.
This commit is contained in:
@@ -36,6 +36,17 @@ if [ -z "$domain" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validated up front, not just passed as a positional arg below: this also
|
||||
# has to be a well-formed hostname to be a valid dnsmasq server=/domain/...
|
||||
# line (e.g. no embedded "/"), on top of ruling out shell metacharacters. A
|
||||
# sandboxed agent can suggest this exact command to the user to copy-paste
|
||||
# (see sandbox-context.sh), so a prompt-injected agent handing back a
|
||||
# malicious "domain" is a real path here, not just a theoretical one.
|
||||
if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
|
||||
echo "Not a valid domain: $domain" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$container" ]; then
|
||||
containers=$(docker ps --filter "label=com.docker.compose.project=claude-sandbox" --filter "status=running" --format '{{.Names}}')
|
||||
count=$(echo "$containers" | grep -c . || true)
|
||||
@@ -50,6 +61,22 @@ if [ -z "$container" ]; then
|
||||
container="$containers"
|
||||
fi
|
||||
|
||||
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
|
||||
# this has to happen *before* resolving the domain below - otherwise the dig
|
||||
# right after this would itself get blocked. Append a server= line to
|
||||
# dnsmasq's servers-file (idempotent - skip if already present from an
|
||||
# earlier call) and SIGHUP it to reload just that file, no restart needed.
|
||||
# $domain is passed as a positional arg to the inner script (`$1`, inside a
|
||||
# single-quoted heredoc) rather than interpolated into the script text - it's
|
||||
# validated above, but this keeps it that way even if the validation regex
|
||||
# above is ever loosened later.
|
||||
docker exec -u root "$container" bash -c '
|
||||
domain="$1"
|
||||
entry="server=/${domain}/127.0.0.11"
|
||||
grep -qxF "$entry" /etc/dnsmasq-sandbox-servers.conf 2>/dev/null || echo "$entry" >> /etc/dnsmasq-sandbox-servers.conf
|
||||
kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"
|
||||
' -- "$domain"
|
||||
|
||||
echo "Resolving $domain inside $container..."
|
||||
ips=$(docker exec "$container" dig +short A "$domain" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || true)
|
||||
if [ -z "$ips" ]; then
|
||||
|
||||
@@ -27,6 +27,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
dnsutils \
|
||||
aggregate \
|
||||
jq \
|
||||
dnsmasq \
|
||||
ulogd2 \
|
||||
ulogd2-json \
|
||||
nano \
|
||||
vim \
|
||||
ripgrep \
|
||||
@@ -139,17 +142,21 @@ RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
|
||||
# doesn't have.
|
||||
RUN npm install -g tree-sitter-cli@0.24.7
|
||||
|
||||
# Copy and set up firewall + entrypoint scripts, the gitea-pr helper from
|
||||
# this repo's bin/ directory, the claude wrapper that pins
|
||||
# Copy and set up firewall + DNS-proxy + entrypoint scripts, the gitea-pr
|
||||
# helper from this repo's bin/ directory, the claude wrapper that pins
|
||||
# --dangerously-skip-permissions, and this user's real nvim config.
|
||||
COPY docker/claude-sandbox/fixed-domains.sh /usr/local/bin/
|
||||
COPY docker/claude-sandbox/dns-proxy.sh /usr/local/bin/
|
||||
COPY docker/claude-sandbox/init-firewall.sh /usr/local/bin/
|
||||
COPY docker/claude-sandbox/log-shipper.sh /usr/local/bin/
|
||||
COPY docker/claude-sandbox/ulogd.conf /etc/ulogd-sandbox.conf
|
||||
COPY docker/claude-sandbox/entrypoint.sh /usr/local/bin/
|
||||
COPY docker/claude-sandbox/claude-wrapper.sh /usr/local/bin/claude
|
||||
COPY bin/gitea-pr /usr/local/bin/gitea-pr
|
||||
COPY config/nvim /home/node/.config/nvim
|
||||
|
||||
USER root
|
||||
RUN chmod +x /usr/local/bin/init-firewall.sh /usr/local/bin/entrypoint.sh /usr/local/bin/gitea-pr /usr/local/bin/claude && \
|
||||
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/gitea-pr /usr/local/bin/claude && \
|
||||
chown -R node:node /home/node/.config/nvim && \
|
||||
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ into the container at runtime from wherever it actually sits on disk.
|
||||
here — that's write-capable, which defeats the point (see "GitHub is
|
||||
read-only" below). Without it, public GitHub repos still clone/fetch fine
|
||||
anonymously over HTTPS; only private repos and `gh` API calls need it.
|
||||
- Optional: `LOKI_URL` (a base URL, e.g. `http://10.0.30.11:3100`) to ship
|
||||
DNS-query and firewall allow/reject logs to a Loki push endpoint - see
|
||||
"Network request logging" below. Logging is skipped entirely if unset.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -105,25 +108,46 @@ user's real working copy), then push the feature branch and run
|
||||
|
||||
On start, the container runs an allowlist-only `iptables`/`ipset` firewall
|
||||
(`init-firewall.sh`), matching the reference dev container. Allowed by
|
||||
default: DNS resolution via Docker's embedded resolver (loopback only, so
|
||||
never leaves the container), the Docker host itself (not its whole subnet),
|
||||
GitHub's published IP ranges (port 443 only - see "GitHub is read-only"
|
||||
below), `release-assets.githubusercontent.com` (GitHub release assets - e.g.
|
||||
mason's own registry index - redirect here, a separate CDN from github.com's
|
||||
own IPs), `registry.npmjs.org`, `pypi.org` and `files.pythonhosted.org`
|
||||
(mason's Python-based LSP tools - `ruff`, `basedpyright`, `debugpy` - install
|
||||
via pip), `api.anthropic.com`, and whatever hosts `GITEA_SSH_HOST`/
|
||||
`GITEA_API` resolve to — that last group isn't port-restricted, so
|
||||
git-over-ssh to Gitea works without a separate blanket "port 22 to anywhere"
|
||||
rule. Set `ALLOWED_DOMAINS` (space-separated) in the environment to allow
|
||||
more (e.g. `crates.io` for a Rust project needing more than what mason/npm
|
||||
already cover). Everything else is rejected. Set `ENABLE_FIREWALL=0` to
|
||||
disable it entirely (e.g. for debugging a blocked domain).
|
||||
default: DNS resolution via a local DNS proxy (see "DNS is allowlisted too"
|
||||
below), the Docker host itself (not its whole subnet), GitHub's published IP
|
||||
ranges (port 443 only - see "GitHub is read-only" below),
|
||||
`release-assets.githubusercontent.com` (GitHub release assets - e.g. mason's
|
||||
own registry index - redirect here, a separate CDN from github.com's own
|
||||
IPs), `registry.npmjs.org`, `pypi.org` and `files.pythonhosted.org` (mason's
|
||||
Python-based LSP tools - `ruff`, `basedpyright`, `debugpy` - install via
|
||||
pip), `api.anthropic.com`, and whatever hosts `GITEA_SSH_HOST`/`GITEA_API`
|
||||
resolve to — that last group isn't port-restricted, so git-over-ssh to Gitea
|
||||
works without a separate blanket "port 22 to anywhere" rule. Set
|
||||
`ALLOWED_DOMAINS` (space-separated) in the environment to allow more (e.g.
|
||||
`crates.io` for a Rust project needing more than what mason/npm already
|
||||
cover). Everything else is rejected. Set `ENABLE_FIREWALL=0` to disable it
|
||||
entirely (e.g. for debugging a blocked domain).
|
||||
|
||||
IPv6 is blocked outright (`ip6tables` policy DROP on INPUT/OUTPUT/FORWARD,
|
||||
and dnsmasq strips AAAA answers so nothing wastes a connection attempt on an
|
||||
address that could never be reached) - there's no IPv6 equivalent of the
|
||||
allowlist above, so leaving it open would bypass the entire firewall on any
|
||||
host with IPv6 egress enabled.
|
||||
|
||||
If your Gitea host is only resolvable via a LAN-local DNS server, make sure
|
||||
the Docker host itself can resolve it — containers inherit the host's
|
||||
resolver by default.
|
||||
|
||||
### DNS is allowlisted too
|
||||
|
||||
Resolution itself is restricted, not just the IP connections made
|
||||
afterward: `dns-proxy.sh` runs a local `dnsmasq` bound to 127.0.0.1 with no
|
||||
fallback resolver, and `/etc/resolv.conf` is repointed at it. Only names
|
||||
matching the same allowlist as above (plus `github.com`/
|
||||
`githubusercontent.com`/`githubassets.com`, needed since GitHub is
|
||||
allowlisted by IP range rather than by resolving those names - see
|
||||
`fixed-domains.sh`) get forwarded to Docker's real resolver; anything else
|
||||
gets no upstream configured at all and is refused outright. Without this, a
|
||||
compromised session could still exfiltrate data by encoding it into query
|
||||
names to an attacker-controlled domain even though it could never open a
|
||||
direct connection there - IP-based filtering alone doesn't stop that, since
|
||||
the DNS query itself was never restricted.
|
||||
|
||||
### Adding a domain to a running session
|
||||
|
||||
`ALLOWED_DOMAINS` only takes effect at container start. For a domain a
|
||||
@@ -135,18 +159,40 @@ bin/claude-sandbox-allow crates.io # this session only
|
||||
bin/claude-sandbox-allow crates.io --global # + persist in config/profile
|
||||
```
|
||||
|
||||
It resolves the domain inside the target container (so it sees what the
|
||||
container's own resolver sees) and adds the IP(s) directly to the running
|
||||
firewall's `ipset`, via `docker exec -u root`. `--global` also appends the
|
||||
domain to `ALLOWED_DOMAINS` in `config/profile` for future sessions; source
|
||||
your shell (or open a new one) for that part to take effect. If more than
|
||||
one claude-sandbox container is running, pass `--container <name>` to
|
||||
It first adds the domain to the running dnsmasq's allowlist (appends a
|
||||
`server=` line to its servers-file and sends `SIGHUP` to reload just that
|
||||
file - no restart), then resolves it through the now-unblocked container
|
||||
resolver and adds the IP(s) to the running firewall's `ipset`, all via
|
||||
`docker exec -u root`. `--global` also appends the domain to
|
||||
`ALLOWED_DOMAINS` in `config/profile` for future sessions; source your shell
|
||||
(or open a new one) for that part to take effect. If more than one
|
||||
claude-sandbox container is running, pass `--container <name>` to
|
||||
disambiguate (`docker ps` to find it).
|
||||
|
||||
The sandbox-context.sh `SessionStart` hook tells the agent to ask for this
|
||||
by name when a task is blocked on a single missing domain, rather than
|
||||
stopping the whole session over it.
|
||||
|
||||
## Network request logging
|
||||
|
||||
If `LOKI_URL` is set (see Prerequisites), every DNS query and every new
|
||||
outbound connection attempt - allowed or rejected - is shipped to that Loki
|
||||
endpoint. Two sources, both tailed and pushed by `log-shipper.sh`:
|
||||
|
||||
- `dns-proxy.sh`'s dnsmasq logs every query and whether it was forwarded
|
||||
(allowed) or refused (not on the allowlist) - labeled `job=claude-sandbox-dns`.
|
||||
- `init-firewall.sh` tags its terminal ACCEPT/REJECT rules with `NFLOG`
|
||||
(group 1 for allowed, group 2 for rejected), which `ulogd2`
|
||||
(`ulogd.conf`) turns into JSON lines with source/dest IP, port, and an
|
||||
`action` field - labeled `job=claude-sandbox-net`. Only new connection
|
||||
attempts are logged, not every packet: the firewall's existing
|
||||
ESTABLISHED,RELATED rule short-circuits the rest of a connection before it
|
||||
ever reaches the tagged rules.
|
||||
|
||||
This is visibility on top of the firewall/DNS-proxy enforcement above, not a
|
||||
substitute for it - nothing about logging changes what's actually allowed to
|
||||
resolve or connect.
|
||||
|
||||
## GitHub is read-only
|
||||
|
||||
The sandbox can read from GitHub (clone, fetch, `gh` API calls) but can
|
||||
@@ -185,13 +231,11 @@ GitHub itself (403), not just blocked locally.
|
||||
config (e.g. `mcpServers`, if ever set) and can't be split at the mount
|
||||
level, so it's left fully read-write - accepted as a residual risk rather
|
||||
than solved.
|
||||
- DNS resolution itself isn't filtered, only the IP connections made
|
||||
afterward - Docker's embedded resolver will still resolve arbitrary
|
||||
domains under lockdown (verified: `dig` on a non-allowlisted domain
|
||||
resolves fine, only the subsequent connect is blocked). A compromised
|
||||
session could still exfiltrate data by encoding it into DNS query names
|
||||
to an attacker-controlled domain, even though it can't open a direct
|
||||
connection anywhere off the allowlist. Not mitigated here.
|
||||
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" above),
|
||||
not just the IP connections made afterward, closing off exfiltration via
|
||||
encoding data into query names to an attacker-controlled domain - verified
|
||||
a non-allowlisted domain gets refused outright (`status: REFUSED`) rather
|
||||
than resolving and then only failing to connect.
|
||||
- Don't mount other host secrets (`~/.ssh` private keys, cloud credential
|
||||
files) into the container. Git push auth goes through the forwarded SSH
|
||||
agent instead, which never exposes the key material itself - though note
|
||||
@@ -214,8 +258,12 @@ GitHub itself (403), not just blocked locally.
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr` |
|
||||
| `init-firewall.sh` | Egress allowlist firewall, run at container start |
|
||||
| `entrypoint.sh` | Runs as root: sets up the firewall, drops to `node` via `gosu`, execs `nvim` |
|
||||
| `fixed-domains.sh` | Shared domain list sourced by both `init-firewall.sh` and `dns-proxy.sh` |
|
||||
| `dns-proxy.sh` | Starts the DNS-allowlisting `dnsmasq`, repoints `resolv.conf` at it |
|
||||
| `init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at container start |
|
||||
| `ulogd.conf` | `ulogd2` config: turns `init-firewall.sh`'s NFLOG-tagged packets into JSON |
|
||||
| `log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to `$LOKI_URL` |
|
||||
| `entrypoint.sh` | Runs as root: DNS proxy, firewall, logging, drops to `node` via `gosu`, execs `nvim` |
|
||||
| `claude-wrapper.sh` | Shadows `claude` on `PATH` to always add `--dangerously-skip-permissions` |
|
||||
| `docker-compose.yml` | Wires up mounts/env; invoked via `bin/claude-sandbox`, not directly |
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
# Restricts DNS resolution itself to an allowlist, rather than only
|
||||
# filtering the IP connections made afterward: without this, Docker's
|
||||
# embedded resolver at 127.0.0.11 will still resolve *any* domain even once
|
||||
# the firewall is locked down (verified: `dig` on a non-allowlisted domain
|
||||
# resolves fine, only the subsequent connect gets blocked), so a compromised
|
||||
# session could exfiltrate data by encoding it into query names to an
|
||||
# attacker-controlled domain. Runs a local dnsmasq bound to 127.0.0.1 with
|
||||
# no fallback resolver, forwarding only allowlisted names to Docker's real
|
||||
# resolver; everything else gets no upstream configured at all, so it fails
|
||||
# outright instead of leaking the query. Must run before init-firewall.sh so
|
||||
# that script's own dig calls (and everything afterward) go through it.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=fixed-domains.sh
|
||||
source "$SCRIPT_DIR/fixed-domains.sh"
|
||||
|
||||
CONF_FILE=/etc/dnsmasq-sandbox.conf
|
||||
SERVERS_FILE=/etc/dnsmasq-sandbox-servers.conf
|
||||
LOG_FILE=/var/log/dnsmasq.log
|
||||
|
||||
touch "$LOG_FILE"
|
||||
chown dnsmasq:dnsmasq "$LOG_FILE" 2>/dev/null || true
|
||||
|
||||
# server=/domain/upstream lines live in their own file (rather than the main
|
||||
# conf) since dnsmasq only re-reads a --servers-file on SIGHUP - lets
|
||||
# bin/claude-sandbox-allow add a domain to a running session by appending a
|
||||
# line here and signaling the container's dnsmasq, no restart needed.
|
||||
{
|
||||
for domain in "${FIXED_DOMAINS[@]}" "${GITHUB_DNS_SUFFIXES[@]}" ${EXTRA_ALLOWED_DOMAINS:-}; do
|
||||
# Literal IPs (e.g. a monitoring host given as an IP, not a hostname)
|
||||
# don't need a DNS entry - there's nothing to resolve.
|
||||
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "server=/$domain/127.0.0.11"
|
||||
done
|
||||
} > "$SERVERS_FILE"
|
||||
|
||||
cat > "$CONF_FILE" <<EOF
|
||||
no-resolv
|
||||
no-hosts
|
||||
bind-interfaces
|
||||
# IPv6 is blocked outright at the firewall (see init-firewall.sh's
|
||||
# ip6tables policies) - strip AAAA answers too so nothing wastes a
|
||||
# connection attempt on an address that can never actually be reached.
|
||||
filter-AAAA
|
||||
listen-address=127.0.0.1
|
||||
port=53
|
||||
servers-file=$SERVERS_FILE
|
||||
log-queries
|
||||
log-facility=$LOG_FILE
|
||||
user=dnsmasq
|
||||
pid-file=/var/run/dnsmasq-sandbox.pid
|
||||
EOF
|
||||
|
||||
dnsmasq --conf-file="$CONF_FILE"
|
||||
|
||||
# dnsmasq daemonizes and returns almost immediately, but give it a moment to
|
||||
# actually be answering before repointing resolv.conf at it.
|
||||
ready=0
|
||||
for _ in $(seq 1 20); do
|
||||
if dig +time=1 +tries=1 @127.0.0.1 api.anthropic.com >/dev/null 2>&1; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "ERROR: dnsmasq did not come up in time" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "nameserver 127.0.0.1" > /etc/resolv.conf
|
||||
@@ -23,6 +23,11 @@ services:
|
||||
# why that's the only path GitHub is reachable through at all.
|
||||
GH_TOKEN: ${GH_TOKEN:-}
|
||||
ALLOWED_DOMAINS: ${ALLOWED_DOMAINS:-}
|
||||
# Base URL of a Loki push endpoint (e.g. http://10.0.30.11:3100) to
|
||||
# ship DNS-query and firewall allow/reject logs to. Its host is
|
||||
# automatically firewall-allowlisted (see entrypoint.sh). Optional -
|
||||
# logging is skipped entirely if unset.
|
||||
LOKI_URL: ${LOKI_URL:-}
|
||||
SSH_AUTH_SOCK: /ssh-agent
|
||||
ENABLE_FIREWALL: ${ENABLE_FIREWALL:-1}
|
||||
# Docker's own tty allocation defaults to a generic TERM (e.g. plain
|
||||
|
||||
@@ -8,10 +8,28 @@ if [ -n "${GITEA_API:-}" ]; then
|
||||
gitea_api_host=$(echo "$GITEA_API" | sed -E 's#^[a-zA-Z]+://([^/]+).*#\1#')
|
||||
fi
|
||||
|
||||
extra_domains="${GITEA_SSH_HOST:-} ${gitea_api_host} ${ALLOWED_DOMAINS:-}"
|
||||
# Same for LOKI_URL (also a full URL) - Loki's host needs to be reachable
|
||||
# for log-shipper.sh to actually deliver anything.
|
||||
loki_host=""
|
||||
if [ -n "${LOKI_URL:-}" ]; then
|
||||
loki_host=$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')
|
||||
fi
|
||||
|
||||
extra_domains="${GITEA_SSH_HOST:-} ${gitea_api_host} ${loki_host} ${ALLOWED_DOMAINS:-}"
|
||||
|
||||
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
|
||||
# dns-proxy.sh must run first: it repoints /etc/resolv.conf at a local
|
||||
# dnsmasq that only resolves allowlisted names, so init-firewall.sh's own
|
||||
# dig calls (and everything afterward) go through the same restriction.
|
||||
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/dns-proxy.sh
|
||||
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/init-firewall.sh
|
||||
|
||||
# NFLOG groups only exist once init-firewall.sh has run, so ulogd2 (the
|
||||
# NFLOG consumer) and the Loki shipper start after it. Both are
|
||||
# best-effort visibility on top of the enforcement above, not required
|
||||
# for it - log-shipper.sh itself no-ops if LOKI_URL isn't set.
|
||||
/usr/sbin/ulogd -d -c /etc/ulogd-sandbox.conf
|
||||
/usr/local/bin/log-shipper.sh &
|
||||
fi
|
||||
|
||||
# A normal login/ssh session always chowns the allocated pty to the logging
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Sourced by both init-firewall.sh (IP-based ipset allowlist) and
|
||||
# dns-proxy.sh (name-based DNS allowlist) so the two can't drift apart.
|
||||
FIXED_DOMAINS=(
|
||||
"registry.npmjs.org"
|
||||
"api.anthropic.com"
|
||||
"gitlab.com"
|
||||
"pypi.org"
|
||||
"files.pythonhosted.org"
|
||||
"release-assets.githubusercontent.com"
|
||||
)
|
||||
|
||||
# GitHub itself is allowlisted by IP range (see github-domains ipset,
|
||||
# populated from api.github.com/meta), never by resolving these names to
|
||||
# specific IPs - but the container's own tools (git, gh, curl) still need to
|
||||
# resolve these names to *something* before connecting, so the DNS proxy
|
||||
# needs to know they're allowed even though init-firewall.sh never digs them.
|
||||
# Suffix matching means these also cover their actual subdomains in use
|
||||
# (api.github.com, codeload.github.com, objects.githubusercontent.com, etc.)
|
||||
# without enumerating each one.
|
||||
GITHUB_DNS_SUFFIXES=(
|
||||
"github.com"
|
||||
"githubusercontent.com"
|
||||
"githubassets.com"
|
||||
)
|
||||
@@ -4,6 +4,10 @@
|
||||
set -euo pipefail # Exit on error, undefined vars, and pipeline failures
|
||||
IFS=$'\n\t' # Stricter word splitting
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=fixed-domains.sh
|
||||
source "$SCRIPT_DIR/fixed-domains.sh"
|
||||
|
||||
# 1. Extract Docker DNS info BEFORE any flushing
|
||||
DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
|
||||
|
||||
@@ -17,6 +21,17 @@ iptables -t mangle -X
|
||||
ipset destroy allowed-domains 2>/dev/null || true
|
||||
ipset destroy github-domains 2>/dev/null || true
|
||||
|
||||
# Block IPv6 outright - there's no IPv6 equivalent of the allowlist below, so
|
||||
# without this, any host with IPv6 egress enabled would let a container
|
||||
# bypass the entire IPv4-based firewall via an AAAA-resolved address. `||
|
||||
# true` throughout since ip6tables may simply be unavailable on hosts with
|
||||
# IPv6 fully disabled at the kernel level, which is an equally fine outcome.
|
||||
ip6tables -F 2>/dev/null || true
|
||||
ip6tables -X 2>/dev/null || true
|
||||
ip6tables -P INPUT DROP 2>/dev/null || true
|
||||
ip6tables -P OUTPUT DROP 2>/dev/null || true
|
||||
ip6tables -P FORWARD DROP 2>/dev/null || true
|
||||
|
||||
# 2. Selectively restore ONLY internal Docker DNS resolution
|
||||
if [ -n "$DOCKER_DNS_RULES" ]; then
|
||||
echo "Restoring Docker DNS rules..."
|
||||
@@ -67,14 +82,9 @@ while read -r cidr; do
|
||||
ipset add github-domains "$cidr"
|
||||
done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
|
||||
|
||||
# Resolve and add other allowed domains
|
||||
for domain in \
|
||||
"registry.npmjs.org" \
|
||||
"api.anthropic.com" \
|
||||
"gitlab.com" \
|
||||
"pypi.org" \
|
||||
"files.pythonhosted.org" \
|
||||
"release-assets.githubusercontent.com"; do
|
||||
# Resolve and add other allowed domains (shared with dns-proxy.sh's name
|
||||
# allowlist via fixed-domains.sh, so the two can't drift apart)
|
||||
for domain in "${FIXED_DOMAINS[@]}"; do
|
||||
echo "Resolving $domain..."
|
||||
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
|
||||
if [ -z "$ips" ]; then
|
||||
@@ -101,7 +111,20 @@ done
|
||||
# be run without any Gitea config set, or a host may be transiently
|
||||
# unresolvable, and that shouldn't prevent the firewall from coming up.
|
||||
if [ -n "${EXTRA_ALLOWED_DOMAINS:-}" ]; then
|
||||
# The script-wide IFS above drops plain space to tighten word splitting
|
||||
# elsewhere, but EXTRA_ALLOWED_DOMAINS is a space-separated list by
|
||||
# construction (entrypoint.sh joins GITEA_SSH_HOST/GITEA_API host/
|
||||
# LOKI_URL host/ALLOWED_DOMAINS with spaces) - restore it just for this
|
||||
# loop, or a multi-entry value silently collapses into one bogus token.
|
||||
IFS=' '
|
||||
for domain in $EXTRA_ALLOWED_DOMAINS; do
|
||||
# A literal IP (e.g. a monitoring host given as an IP, not a
|
||||
# hostname) needs no resolution - add it directly.
|
||||
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
echo "Adding $domain (extra, literal IP)"
|
||||
ipset add allowed-domains "$domain" -exist
|
||||
continue
|
||||
fi
|
||||
echo "Resolving $domain (extra)..."
|
||||
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
|
||||
if [ -z "$ips" ]; then
|
||||
@@ -132,6 +155,7 @@ echo "Host detected as: $HOST_IP"
|
||||
# Only the host itself, not its whole subnet - other machines on the same
|
||||
# LAN/VPC have no reason to be reachable from this container.
|
||||
iptables -A INPUT -s "$HOST_IP" -j ACCEPT
|
||||
iptables -A OUTPUT -d "$HOST_IP" -j NFLOG --nflog-group 1 --nflog-prefix "allow-host"
|
||||
iptables -A OUTPUT -d "$HOST_IP" -j ACCEPT
|
||||
|
||||
# Set default policies to DROP first
|
||||
@@ -148,14 +172,24 @@ iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
# GITEA_SSH_HOST/GITEA_API, which are already in the ipset above - no
|
||||
# separate "TCP/22 to anywhere" rule needed (that would allow SSH, or any
|
||||
# raw TCP posing as it, to any host on the internet).
|
||||
#
|
||||
# Each terminal ACCEPT/REJECT decision below is also tagged with NFLOG so
|
||||
# ulogd2 can capture every new outbound connection attempt and its verdict
|
||||
# (log-shipper.sh ships these to Loki alongside dns-proxy.sh's query log).
|
||||
# Only new connections reach these rules at all - the ESTABLISHED,RELATED
|
||||
# rule above already short-circuits the rest of a connection's packets, so
|
||||
# this logs once per attempt, not once per packet.
|
||||
iptables -A OUTPUT -m set --match-set allowed-domains dst -j NFLOG --nflog-group 1 --nflog-prefix "allow"
|
||||
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
|
||||
|
||||
# GitHub gets port 443 only - read access (clone/fetch/gh over HTTPS) without
|
||||
# port 22, so SSH push there (which could use the forwarded agent's real
|
||||
# key) isn't just discouraged, it's unreachable.
|
||||
iptables -A OUTPUT -p tcp --dport 443 -m set --match-set github-domains dst -j NFLOG --nflog-group 1 --nflog-prefix "allow-gh"
|
||||
iptables -A OUTPUT -p tcp --dport 443 -m set --match-set github-domains dst -j ACCEPT
|
||||
|
||||
# Explicitly REJECT all other outbound traffic for immediate feedback
|
||||
iptables -A OUTPUT -j NFLOG --nflog-group 2 --nflog-prefix "reject"
|
||||
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
|
||||
|
||||
echo "Firewall configuration complete"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# Tails dns-proxy.sh's dnsmasq query log and ulogd2's NFLOG-derived JSON
|
||||
# output, shipping each line to Loki as a raw log stream. Lets DNS lookups
|
||||
# and firewall allow/reject decisions from inside the sandbox show up
|
||||
# alongside the rest of the Grafana/Loki setup, labeled by job so they're
|
||||
# easy to filter. No-op if LOKI_URL isn't set - logging is a bonus on top of
|
||||
# the firewall/DNS-proxy enforcement, not required for either to work.
|
||||
set -uo pipefail # not -e: one bad line or a Loki hiccup shouldn't kill the tailer
|
||||
|
||||
LOKI_URL="${LOKI_URL:-}"
|
||||
if [ -z "$LOKI_URL" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
push() {
|
||||
local job="$1" line="$2"
|
||||
local ts payload
|
||||
ts=$(date +%s%N)
|
||||
payload=$(jq -n --arg job "$job" --arg ts "$ts" --arg line "$line" \
|
||||
'{streams: [{stream: {job: $job, source: "claude-sandbox"}, values: [[$ts, $line]]}]}')
|
||||
curl -s -o /dev/null --max-time 3 -H "Content-Type: application/json" \
|
||||
-XPOST "$LOKI_URL/loki/api/v1/push" -d "$payload" || true
|
||||
}
|
||||
|
||||
tail -F -n0 /var/log/dnsmasq.log 2>/dev/null | while IFS= read -r line; do
|
||||
push "claude-sandbox-dns" "$line"
|
||||
done &
|
||||
|
||||
tail -F -n0 /var/log/ulogd.json 2>/dev/null | while IFS= read -r line; do
|
||||
push "claude-sandbox-net" "$line"
|
||||
done &
|
||||
|
||||
wait
|
||||
@@ -0,0 +1,31 @@
|
||||
# Consumes the NFLOG-tagged packets from init-firewall.sh's OUTPUT rules
|
||||
# (group 1: allowed connections, group 2: rejected ones) and writes each as
|
||||
# a JSON line that log-shipper.sh tails and forwards to Loki. Static config
|
||||
# (no per-run domain/env dependence, unlike dns-proxy.sh's generated
|
||||
# dnsmasq config), so this is copied into the image as-is.
|
||||
[global]
|
||||
logfile="/var/log/ulogd.log"
|
||||
loglevel=3
|
||||
|
||||
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_inppkt_NFLOG.so"
|
||||
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_raw2packet_BASE.so"
|
||||
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_filter_IP2STR.so"
|
||||
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_output_JSON.so"
|
||||
|
||||
stack=log1:NFLOG,base1:BASE,ip2str1:IP2STR,json1:JSON
|
||||
stack=log2:NFLOG,base1:BASE,ip2str1:IP2STR,json1:JSON
|
||||
|
||||
# Allowed connections (matches init-firewall.sh's --nflog-group 1 rules)
|
||||
[log1]
|
||||
group=1
|
||||
numeric_label=1
|
||||
|
||||
# Rejected connections (matches init-firewall.sh's --nflog-group 2 rule)
|
||||
[log2]
|
||||
group=2
|
||||
numeric_label=0
|
||||
|
||||
[json1]
|
||||
sync=1
|
||||
file="/var/log/ulogd.json"
|
||||
boolean_label=1
|
||||
Reference in New Issue
Block a user