← back to lessons

Incomplete-fix often lives in SIBLING FILES the fix never opened - first_patched-verifier must grep the fix's PATTERN repo-wide, not just re-read the patched file

Date 2026-06-21. Target: Plane (makeplane/plane, Web2 PM tool). Result: confirmed undisclosed incomplete-fix of CVE-2026-27705. Best Web2 find since Forgejo. Banked because the detection move is a sharp refinement of first_patched-verifier.

The finding

CVE-2026-27705 (cross-workspace asset IDOR) was fixed (PR #8644) by changing FileAsset.objects.get(id=pk) -> get(id=pk, workspace__slug=slug, project_id=project_id) in app/views/asset/v2.py. The IDENTICAL unscoped pattern FileAsset.objects.get/filter(asset=...) remained at HEAD in TWO sibling files the fix never touched: - app/views/asset/base.py (legacy FileAssetEndpoint, IsAuthenticated-only, no membership) - still live-routed. - space/views/asset.py (public EntityAssetEndpoint, missing project_id bind). Both cross-workspace/cross-project, undisclosed, present at HEAD.

The refinement to first_patched-verifier

The standard first_patched-verifier: pull the fix, re-read the PATCHED file at LATEST, check the guard is still there. That MISSES this class entirely, because the patched file IS fixed - the bug moved to un-opened siblings. The refinement: 1. From the fix, extract the VULNERABLE PATTERN (the exact sink call shape: FileAsset.objects.get(id=...) without a scope kwarg; or Service.query(id=...) without tenant; or a missing reqRepoReader). 2. GREP THAT PATTERN REPO-WIDE at LATEST - every file, not just the patched one. The fix-commit's --stat tells you which files it touched; the incomplete-fix is in the files it did NOT touch that contain the same pattern. 3. Rank residuals: legacy/duplicate endpoints (old API versions, base.py vs v2.py), public/unauth surfaces (space/, /public/), and a DIFFERENT app/module with its own copy of the call. These are where teams forget to apply the fix.

This is why the Forgejo wrr5 (fix added to /users/ but not /user/), the RAGFlow guard-retrofit (12 sites guarded, Browser node not), and now Plane (v2.py fixed, base.py + space/ not) all hit: the fix is applied where the reporter pointed, and the SIBLING implementations of the same pattern are left behind. Codebases with VERSIONED APIs (v1/v2), a separate PUBLIC/space app, and legacy-kept endpoints are the richest - the same logic is copy-pasted across them and the fix lands in only one copy.

The detector (cheap)

After ANY published fix on a target you can clone: - git log -1 --stat <fix-commit> -> the touched files. - grep -rn '<the vulnerable call pattern>' <repo> -> all occurrences. - set-difference: occurrences NOT in the touched files + still matching the pre-fix (unscoped) shape = incomplete-fix candidates. Verify each has the same reachability (routed? auth?).

Honesty / severity

The Plane residuals are uuid4-keyed (asset path needed, not blindly enumerable) -> Medium-ish, capped by id-acquisition (shared/embedded asset URLs are the realistic leak). The legacy endpoint's IsAuthenticated-only (no membership) is the aggravator. Credit/CVE (Plane coordinated-disclosure, no cash bounty). Source-confirmed; live 2-workspace E2E strengthens but the incomplete-fix-of-published-CVE narrative + source proof stand.

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