Skip to content

Harden automation content ingestion: SSRF, import hijack, governance, observability#47

Open
oratis wants to merge 4 commits into
mainfrom
claude/automation-mechanism-review-739127
Open

Harden automation content ingestion: SSRF, import hijack, governance, observability#47
oratis wants to merge 4 commits into
mainfrom
claude/automation-mechanism-review-739127

Conversation

@oratis

@oratis oratis commented Jul 11, 2026

Copy link
Copy Markdown
Owner

What & why

A full review of every path that auto-adds content to the registry (GitHub project scrape, hosted A2A import, user submit, health probe), plus fixes for the confirmed issues. Each finding was verified to a reproducible level and argued for/against before fixing — see docs/05-automation-ingestion-review.md.

Two first-pass claims were retracted after code-level verification (honesty over confirmation): the classifyScenarios "recompiles regex every call" claim was false (MATCHERS is module-level), and "cron scrape almost certainly times out" was overstated (~50s at defaults) — reframed to the real defects.

Confirmed issues fixed

# Severity Confirmed problem Fix
1 🔴 import-hosted upserts by slugify(card.name) with no guards on the update path → a community-registry PR whose name collides with an existing entry silently overwrites its endpoint/cardUrl and keeps APPROVED. Can also flip a PROJECT entry. Ownership + same-origin guards; cross-origin endpoint change drops to PENDING for re-review.
2 🔴 scrape-agents forces status:APPROVED on refresh and re-creates deleted rows as APPROVED → a scheduled run undoes admin REJECT/DELETE (root cause of junk repos resurfacing atop /registry). Pre-load REJECTED/DISABLED slugs and skip them; stop touching status on update.
4 🟠 AgentCard fetch used redirect:"follow" + string-only host check → SSRF to 169.254.169.254 via redirect, or DNS pointing at an internal IP. Reachable from the public /api/agents/submit. Manual per-hop redirect revalidation (max 3) + assertPublicHost DNS/IP check (private/loopback/link-local/CGNAT/benchmarking).
3 / 6 🟡 Cron runs have no timing telemetry; health has no time limit and swallows write errors via .catch(()=>{}). durationMs on all three cron responses; health gets maxDuration=120 + a writeErrors count.
7 🟠 Cron schedules/params live only in Cloud Scheduler — no config in the repo. New docs/06-cron-schedule.md: authoritative job list + idempotent gcloud setup.

Verification

  • npx tsc --noEmit clean; eslint clean on changed files. (The stale-Prisma-client errors on first run vanish after prisma generate and are unrelated to this diff.)
  • AgentCard end-to-end regression (local server, 6/6): normal fetch ✓, same-origin redirect followed ✓, redirect loop bounded ✓, prod-mode metadata/loopback IP blocked ✓, DNS→private IP blocked ✓. DNS predicate separately asserted (127.0.0.1→block, 8.8.8.8→allow, 169.254.169.254→block).

Notes

  • SSRF fix is prod-gated (matches the existing host guard) so localhost cards still work in dev. Residual connect-level TOCTOU is documented for a socket-level follow-up.
  • No behavior change to the happy path of any ingestion route; guards only add skips/re-review.

🤖 Generated with Claude Code

oratis and others added 4 commits July 11, 2026 22:27
import-hosted upserted by slugify(card.name) with no guards on the update
path, so a card in the community registry whose name collided with an
existing entry silently overwrote that entry's endpoint/cardUrl while
keeping its APPROVED status — a takeover vector via a single registry PR.
Now guard: skip rows owned by another publisher or of kind PROJECT, and
drop cross-origin endpoint changes back to PENDING for admin re-review.

scrape-agents forced status=APPROVED on every refresh (and re-created
deleted rows as APPROVED), so a scheduled run undid admin REJECT/DELETE
— the root cause behind junk repos resurfacing at the top of /registry.
Now pre-load REJECTED/DISABLED slugs and skip them, and no longer touch
status on update; only newly discovered repos land APPROVED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fetchOne used redirect: "follow" and only checked the initial URL string,
so a public URL could 302 into 169.254.169.254 (cloud metadata) or a name
could resolve to an internal IP unchecked. This path is reachable from the
public /api/agents/submit endpoint, not just cron.

Follow redirects manually (max 3), revalidating every hop, and add
assertPublicHost() which resolves A/AAAA and rejects private / loopback /
link-local / CGNAT / benchmarking (198.18/15) targets. Prod-gated to match
the existing host guard so localhost cards still work in dev. Residual
connect-level TOCTOU noted for socket-level follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All three cron responses now include durationMs so the scheduler log can
spot runs approaching the timeout (a truncated run leaves partial,
non-transactional writes). health gains maxDuration=120 and counts
writeErrors instead of swallowing them via .catch(()=>{}). Clarified that
maxDuration is not the effective ceiling on Cloud Run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
05: full review of every auto-ingestion path with confirmed evidence,
pro/con debate, and verdict per finding. 06: authoritative cron job list
(frequency, params, auth) with idempotent gcloud setup — the schedules
were previously out-of-band knowledge with no config in the repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oratis

oratis commented Jul 11, 2026

Copy link
Copy Markdown
Owner Author

Review

整体方向和实现质量都很好:per-hop 重定向复验 + DNS 解析校验是 SSRF 的标准修法,墓碑集合用单查询避免了 N 次往返,import-hosted 的三条守卫规则设计合理,而且主动撤回两条误判(MATCHERS、超时)增加了整份 review 文档的可信度。我核对了 main 上的 assertSafeUrl,prod 门控确实与现有行为一致。以下是发现的问题,按重要性排序:

1. import-hosted 的跨域降级会复活被 REJECTED/DISABLED 的条目(与本 PR 自己修的 #2 同类问题)

src/lib/import-hosted.ts 的跨域分支无条件 status: "PENDING"。如果 admin 已把某个 hosted agent 标为 REJECTED/DISABLED,攻击者只需在社区注册表里给同名 card 换个域名,下次 cron 就会把它翻回 PENDING 重新塞进审核队列——自动化再次覆盖了人工裁决。建议与 scrape-agents 的墓碑逻辑保持一致:existing.status ∈ {REJECTED, DISABLED} 时直接 skip,不做任何更新。

同理,同源刷新分支虽然不改 status,但仍会刷新 REJECTED 条目的 name/description/endpoint 等字段——无害但没必要,一并 skip 更干净。

2. skip 被计入 failed++,指标语义混淆

所有权/kind 守卫触发时 failed++,会让 cron 响应里的 failed 同时包含"card 拉取失败"和"守卫拦截"。一旦有人恶意批量撞名,failed 会飙升但无法区分原因。建议加独立的 skipped 计数返回。

3. ipv4IsPrivate 少了几个保留段(低优先级)

未覆盖:192.0.0.0/24(IETF 保留,含部分云厂商 metadata 备用地址)、224.0.0.0/4(组播)、240.0.0.0/4 + 255.255.255.255。利用价值低,但既然 fail-closed 的框架已经搭好,补全成本很小。

4. 重定向响应体未 drain(nit)

fetchOne 循环里 3xx 分支直接 continue,res.body 未消费(res.body?.cancel()),undici 下会占住连接直到超时。3 跳上限内影响很小,顺手加一行即可。

5. 与 PR #39 的调度治理重叠

本 PR 的 docs/06-cron-schedule.md#39deploy-sync-job.sh/cloudbuild.sync.yaml 都在做"调度配置入库"这件事,且 #39 还会新增 check-github cron(不在 docs/06 清单里)。两个先合的那个,后合的要同步更新,避免出现两份"唯一权威来源"。

其他确认过的点

  • maxDuration 在 Cloud Run standalone 下无效的注释澄清:属实,写法妥当。
  • health.tswriteErrors 计数:并发批次里对 summary 的递增在 JS 单线程模型下安全。
  • MAX_BYTES 检查仍是先 res.text() 全量读入再比较——预存在行为,非本 PR 引入;既然已改手动重定向,后续可考虑用 content-length/流式截断真正限制内存。

#1 建议修掉再合,其余可随本 PR 或后续处理。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant