← back to lessons

Cycle255 - Directus TUS-vs-REST file-overwrite guard asymmetry = CONFIRMED HIGH (incomplete fix, live cross-user PoC)

Date 2026-06-20. Target: Directus (directus/directus). Verdict: VULNERABLE-CONFIRMED HIGH (live). First paid-lane heavyweight-target win under the box upgrade.

The finding

GHSA-393c-p46r-7c95 (Critical, fixed 11.17.0) added a cross-user file-overwrite guard - checkUniqueFilename + generateFilenamePath - but ONLY on FilesService (api/src/services/files.ts). The TUS resumable-upload store (api/src/services/tus/data-store.ts) creates the directus_files row via a PLAIN ItemsService.createOne, never FilesService, so neither guard runs. A user who can write filename_disk via TUS Upload-Metadata overwrites any existing file by colliding on its disk name. Live-confirmed: a LOW-PRIV non-admin user overwrote an ADMIN-owned file's content (served by the admin's own file id) while REST PATCH with the same filename_disk returns ForbiddenError even for admin. Present in 11.17.0 AND at HEAD (api 36.0.2). Preconditions: TUS_ENABLED=true (opt-in) + role with filename_disk field-create (e.g. fields:['*']) - the same precondition the vendor accepted for the parent CVE.

Reusable primitives banked

  1. Service-layer sink asymmetry (the high-value vein on big Node/TS apps): a security guard added to a high-level Service (FilesService) is BYPASSED by any sibling caller that uses the BASE service (ItemsService) directly. When you see a fix wired into an override method (override async createOne with the guard), grep for OTHER instantiators of the base class for the SAME collection/table. Here: new ItemsService<File>('directus_files', ...) in the TUS store = the bypass. This is the Node/TS analogue of the per-handler-scattered authz pattern (Gitea feeds, Filebrowser cleanup) - same shape, different layer. Hunt query: grep -rn "new ItemsService.*'<collection>'" and check each against the guard that lives on the dedicated Service subclass.
  2. A data-integrity guard that fires for ALL roles (even admin) isolates a finding cleanly: checkUniqueFilename throws ForbiddenError regardless of permission. So demonstrating REST-blocks-admin vs TUS-allows proves it is a MISSING GUARD, not a permission misconfig - the cleanest possible asymmetry proof. When a guard is role-agnostic, you can prove the bypass without even setting up a low-priv user (then add the low-priv step only for SEVERITY/cross-user).
  3. cycle254 verify-latest, reconfirmed: the gap is in the CLAIMED-FIXED release and at HEAD. Always confirm both: the fix landing on one path does not mean the sibling path was touched - here data-store.ts is byte-identical across 11.16.0/11.16.1/11.17.0/HEAD.
  4. Feature-flag preconditions are reachability, not disqualifiers: TUS is opt-in (TUS_ENABLED). Note it honestly as a precondition (instances using large-file/resumable uploads) - it scopes severity/affected-population but the finding stands for those instances.

Method/cost

Fleet agent surfaced the candidate (cluster scouting); lead INDEPENDENTLY verified every code claim (asymmetry + field-perm reachability + HEAD) before trusting it (Skeptic Gate - never stage agent output unverified), then built a self-contained live PoC (docker Directus 11.17.0 + SQLite, ~1.2GB image, container torn down after). Heavyweight live-app PoC is now feasible post-box-upgrade and was decisive for cross-user severity. Repro: ~/bounty/findings/cycle255-directus-tus-filename-disk-overwrite-incomplete-fix/repro.sh.

Companion (same session)

Strapi CVE-2026-27886 incomplete-coverage: the relational-filter admin-token oracle fix is solid by default (createdBy/updatedBy are private:true -> throwPrivate), but a content type with populateCreatorFields:true re-opens an UNAUTH filter oracle leaking admin firstname/lastname/username (NOT credentials). Config-conditional, lower-sev, NEEDS-DYNAMIC-CONFIRM. Staged as a secondary lead, not yet PoC'd.

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