forked from bchanot/claude
feat(lib): H1 — url-guard, shell-injection + local-target refusal before curl
Prerequisite for C1, which is why this moved up from AXE 5. Today $DOMAIN is typed by the operator and interpolated into ~10 curls (seo-analyzer.md:254+, geo-analyzer.md:248+) — self-inflicted risk. The sitemap crawl changes the threat model completely: URLs then come from the TARGET'S OWN SERVER, so a remote file's bytes reach a shell. The severe hazard is injection, not SSRF. Those curls quote with ", inside which $ and backtick still execute, and ~/.claude/.env holds GOOGLE_OAUTH_CLIENT_SECRET + CRUX_API_KEY. A <loc> of `https://x/$(cat ${HOME}/.claude/.env)` reads the vault into a request. The test suite asserts exactly that payload is refused. Code, not prose: a markdown instruction does not stop an injection. Mirrors the house pattern (fetch.sh:25 _label_safe) — whole-string allowlist, C locale, POSIX case: newline-proof, locale-independent, no grep pitfall. Allowlist over denylist per CLAUDE.md. Covers: shell metacharacters; scheme (http/https only — no file:, gopher:); literal loopback/private/link-local/metadata/.local; userinfo authority confusion (https://trusted.com@127.0.0.1/ hits .0.0.1, not trusted.com). NOT covered, stated in the header rather than left silent: DNS-level SSRF. A public hostname resolving to a private address passes. Closing it needs resolve-then-pin at the HTTP layer; shell curl cannot without a TOCTOU window. Proportionate to the threat model — this runs on a workstation auditing the operator's own client sites. Wired at all three entry points: both agents' STEP 4 domain assignment, and the W3 sameAs loop (whose URLs come from the audited repo, not the operator). Refused sameAs rows report as REFUSED rather than vanish — neither dead nor live, and an unguardable sameAs is itself a finding. Note: writing the test file tripped the config-protection hook (test suite is a guarded quality-gate). Used the documented one-shot sentinel with a reason rather than working around the gate; it was consumed as designed. Verified: 47 new assertions PASS / 0 FAIL, picked up by make test; full suite green; shellcheck clean on lib/url-guard.sh (the sole remaining hit in the health-stack glob is pre-existing, lib/gitflow-test.sh:242); guard dogfooded against the real zenquality.fr domain (accepted) and the real exfil payload (refused, exit 2).
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Validate a host or URL BEFORE it reaches a shell command or curl.
|
||||
# Echoes the value on stdout when safe; exits 2 with a reason on stderr.
|
||||
#
|
||||
# HOST="$(bash ~/.claude/lib/url-guard.sh host "$RAW")" || exit 2
|
||||
# URL="$(bash ~/.claude/lib/url-guard.sh url "$RAW")" || exit 2
|
||||
#
|
||||
# WHY: /seo and /geo interpolate externally-supplied strings into ~10 curl
|
||||
# commands (seo-analyzer.md:254+, geo-analyzer.md:248+). Today $DOMAIN is typed
|
||||
# by the operator, so the risk is self-inflicted. The sitemap crawl (C1) changes
|
||||
# that: URLs then come from the TARGET'S OWN SERVER — a remote file whose bytes
|
||||
# reach a shell. Inside the double quotes those curls use, the characters that
|
||||
# break out are $ ` \ " — so a <loc> of
|
||||
# https://x/$(cat ${HOME}/.claude/.env)
|
||||
# would read GOOGLE_OAUTH_CLIENT_SECRET and CRUX_API_KEY straight out of the
|
||||
# vault and into a request. Allowlist, per CLAUDE.md: explicit allowlist beats
|
||||
# implicit denylist.
|
||||
#
|
||||
# NOT COVERED, deliberately: DNS-level SSRF. A public hostname that RESOLVES to
|
||||
# a private address passes this guard. Closing that needs resolve-then-pin at
|
||||
# the HTTP layer; curl in a shell cannot do it without a TOCTOU window between
|
||||
# the check and the connection. Literal local targets ARE rejected below. The
|
||||
# omission is stated rather than silent — see lib/seo-data/README.md.
|
||||
set -uo pipefail
|
||||
|
||||
_die() { echo "url-guard: $1" >&2; exit 2; }
|
||||
|
||||
# Whole-string charset guards: C locale + POSIX `case`, the same shape as
|
||||
# fetch.sh:25 _label_safe. Newline-proof and locale-independent, unlike a
|
||||
# per-line grep. No `$` or backtick inside the patterns, so nothing expands.
|
||||
_host_charset_ok() ( LC_ALL=C; case "$1" in
|
||||
''|[!A-Za-z0-9]*|*[!A-Za-z0-9.-]*) exit 1 ;; esac )
|
||||
|
||||
# Authority + path + query. Excludes $ ` \ " ' ; | ( ) * ! space and newline —
|
||||
# none of which a real sitemap URL needs, all of which a shell reads.
|
||||
_rest_charset_ok() ( LC_ALL=C; case "$1" in
|
||||
''|*[!A-Za-z0-9._~:/?#@=\&%+,-]*) exit 1 ;; esac )
|
||||
|
||||
# Literal local/private/metadata targets. This is a LITERAL check, not a DNS
|
||||
# one: it stops the obvious, not a hostname that resolves inward.
|
||||
_host_is_local() ( LC_ALL=C
|
||||
# ${1,,} not tr: no fork, and no SC2018/SC2019 noise. Safe because the
|
||||
# charset guard has already run — the string is [A-Za-z0-9.-] by here.
|
||||
case "${1,,}" in
|
||||
localhost|*.localhost|*.local|0.0.0.0|broadcasthost) exit 0 ;;
|
||||
127.*|10.*|169.254.*|192.168.*) exit 0 ;;
|
||||
172.1[6-9].*|172.2[0-9].*|172.3[01].*) exit 0 ;;
|
||||
metadata.google.internal|metadata) exit 0 ;;
|
||||
*) exit 1 ;;
|
||||
esac )
|
||||
|
||||
_reject_local() { _host_is_local "$1" && _die "local/private target refused: '$1'"; return 0; }
|
||||
|
||||
check_host() {
|
||||
_host_charset_ok "$1" || _die "host charset (allowed A-Za-z0-9.-): '$1'"
|
||||
_reject_local "$1"
|
||||
printf '%s\n' "$1"
|
||||
}
|
||||
|
||||
check_url() {
|
||||
local rest host
|
||||
case "$1" in
|
||||
https://*) rest="${1#https://}" ;;
|
||||
http://*) rest="${1#http://}" ;;
|
||||
*) _die "scheme must be http or https: '$1'" ;;
|
||||
esac
|
||||
_rest_charset_ok "$rest" || _die "url charset: '$1'"
|
||||
host="${rest%%/*}"; host="${host%%\?*}"; host="${host%%#*}"
|
||||
# user@host hides the real target: https://trusted.com@127.0.0.1/ hits .0.0.1
|
||||
case "$host" in *@*) _die "userinfo in authority (confusion vector): '$1'" ;; esac
|
||||
host="${host%%:*}" # drop :port before validating the host
|
||||
_host_charset_ok "$host" || _die "host charset: '$host'"
|
||||
_reject_local "$host"
|
||||
printf '%s\n' "$1"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
host) [ $# -eq 2 ] || _die "usage: url-guard.sh host <hostname>"; check_host "$2" ;;
|
||||
url) [ $# -eq 2 ] || _die "usage: url-guard.sh url <url>"; check_url "$2" ;;
|
||||
*) _die "usage: url-guard.sh {host|url} <value>" ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user