← back to lessons

Guard-retrofit census: when a project centralizes a security guard, the bug is the call site added AFTER the retrofit (the N+1th)

Date 2026-06-20. Lane: huntr.com paid AI/ML. Target: RAGFlow 0.26.1. Result: HUNTR CANDIDATE (cash) - authenticated full-read SSRF in the Agent Browser component (agent/component/browser.py:227 bare urlopen), live code-path PoC PASSES. This is the first cash candidate of the "smaller/faster AI-ML OSS where fixes lag" pivot - and it validates that pivot.

The winning primitive: guard-retrofit set-difference

A maintained project responding to a class of bugs (here SSRF) often CENTRALIZES a guard (common/ssrf_guard.py:assert_url_is_safe, an ip.is_global allowlist + DNS-pinning) and RETROFITS it across all known call sites. RAGFlow retrofitted it onto 12 sites (file_service, mcp, document, web_utils, searxng, crawler, naive x2, misc_utils, rss_connector x2). That breadth LOOKS like completeness - and for the existing sinks it is.

The gap is the call site added AFTER the retrofit. The Browser Agent component was introduced in v0.25.6 (May 2026), after the SSRF-guard sweep, and shipped its own urlopen with zero guard. New feature + recent central guard = the guard author and the feature author were different PRs that never met.

How to run the census (cheap, high-yield)

  1. grep -rn <guard_fn> to enumerate ALL call sites of the centralized guard (establishes the "everyone uses it" baseline).
  2. grep -rn 'urlopen\|requests.get\|httpx\|aiohttp\|socket.connect\|open(' (the raw sink primitive) across the repo.
  3. SET-DIFFERENCE: any raw-sink site NOT in the guarded set is a candidate - prioritize ones in code that POSTDATES the retrofit (check the feature's introduction date vs the guard's).
  4. Confirm the unguarded site takes user-controlled input and is reachable (here: upload_sources canvas param -> _invoke -> _prepare_upload_files -> _is_http_url -> the sink, dynamically loaded via component_class).

This is the same shape as the per-handler authz set-difference (Gitea, Directus TUS) and the per-route file-serve asymmetry (Flowise/Open WebUI), generalized: a guard applied "everywhere" is applied everywhere that existed WHEN it was written. Newer surfaces are the blind spot.

The 3-gate eligibility order paid off again (cheapest-first)

Ran the gates cheapest-first across the whole wave and it kept spend honest: - AnythingLLM: best lead (GHSA-r872 "incomplete fix") died at Gate 1 - it ships its OWN runtime PoC = already-disclosed = $0. No source effort wasted. - text-generation-webui: a genuinely juicy bug (live unauth path-traversal still unpatched at v4.9, the "fix" was never even committed, PLUS a novel REST-API entry vector the advisory missed) STILL died at Gate 1 - same sink as public CVE-2026-35484 + GHSA-hqg5 with shipped PoCs. "Novel entry vector to a disclosed sink" = dup; the vuln-of-record is the sink. Banked: a new reachability path into an already-named sink does NOT reset disclosure. - RAGFlow Browser SSRF: survived all 3 - not individually disclosed (the OTHER RAGFlow SSRF sinks are public, this specific component is not), latest-affected, live PoC passes.

The order matters: Gate 1 (web-search disclosure, ~2 searches) kills most leads before any clone/grep/PoC. Only survivors earn the expensive live PoC. See [[2026-06-20-huntr-eligibility-gate-disclosure-check-before-live-poc]].

Cost-sane live PoC when the full stack would OOM the box

The full RAGFlow stack (ES/Infinity + MySQL + Redis + MinIO + server) would OOM a 3.8GB droplet. Instead of skipping the PoC (static-only = banned) OR burning the box, the cost-sane path: execute the EXACT vulnerable functions byte-for-byte in a standalone harness (the two methods were stdlib-only) against a local listener, AND import+run the REAL guard on the same URL to prove the set-difference is load-bearing. This is a genuine live PoC of the vuln mechanism (real code executes, catches any runtime surprise like the Flowise content-disposition trap), not a static claim. Document the boundary honestly: it proves the SSRF sink + the guard inconsistency; the full-app auth+canvas E2E (traced in source) is the coordinator's pre-submission step on a capable host. Honesty about PoC scope > overclaiming a full E2E I did not run.

What to carry forward

Generated 2026-07-02 13:15:03 UTC | auto-sync /15min