All vulnerabilities
GHSA-pv88-7863-5hwq

projectsend/projectsend: Orphan file path alias canonicalization flaw permits tracked-file deletion and duplicate adoption

projectsend/projectsend
CVSS
n/aMedium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in2.7.0Fixed versionGitHub
Published7 Oct 2026Updated 13 Oct 2026GitHub

What fixes it

  • 2.7.0projectsend/projectsend, composerGitHubcomposer require "projectsend/projectsend:^2.7.0"

A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.

Description

Summary

An authenticated staff member with ProjectSend's orphan-management permission can supply a non-canonical alias such as ./audit/client-b.txt for a tracked file whose canonical path is audit/client-b.txt. ProjectSend's raw-string check classifies the alias as an orphan even though Flysystem normalizes it to the tracked file's storage location, allowing the staff member either to delete that file's bytes while leaving its database row intact or to create a duplicate staff-owned database row for the same bytes.

The test account did not have delete_others_files; an ordinary deletion of client B's file returned HTTP 403. No storage-root escape, cross-tenant read gain, or code execution was demonstrated.

Distinctness from CVE-2026-4044

This finding affects the v2 codebase, which is a ground-up rewrite and is not a descendant of the legacy codebase covered by CVE-2026-4044. Full, non-shallow histories were examined for v2 at commit a9b17ddc1e784ccb8885aecf0a29a7b13c362f72 and the legacy repository through r1945; they have distinct root commits and no merge base, including when both object databases are available to Git.

The mechanisms also differ. CVE-2026-4044 concerns the legacy import-orphans.php path, where an attacker-controlled files[] value containing ../ is appended to the upload directory and then deleted. This v2 finding uses a ./ alias: OrphanFileScanner.php compares the raw path string against tracked paths, while Flysystem normalizes that alias to the tracked object's storage path before the import or deletion operation.

This distinction is provided proactively and in good faith to assist duplicate triage; it is not an attempt to relitigate CVE-2026-4044.

Details / Root cause

In app/Modules/Files/OrphanFileScanner.php:109-120, the orphan check receives the submitted path and checks storage existence, then compares the raw submitted value against the known-path list at line 119. The known path audit/client-b.txt therefore does not equal the submitted alias ./audit/client-b.txt at this comparison.

Flysystem's WhitespacePathNormalizer.php:22-52 removes the . segment at lines 34-36. Storage operations consequently resolve both strings to the same object even though ProjectSend's raw-string exclusion treats them as different paths.

The import path in app/Modules/Files/Http/Controllers/OrphanFilesController.php:136-166 stores the submitted alias in a new file record. The deletion path in app/Modules/Files/Http/Controllers/OrphanFilesController.php:185-200 deletes the storage object resolved from that submitted alias. The routes at routes/web.php:126-129 require a staff account with import_orphans; they do not require delete_others_files.

Proof of Concept

These steps change and delete file state. Perform them only on a disposable local installation and restore the database and storage snapshot before replaying the test.

  1. Check out ProjectSend v2.6.0 at commit a9b17ddc1e784ccb8885aecf0a29a7b13c362f72 and complete a local installation.
  1. Create two client-owned tracked files on the files disk:
  • client A file record: audit/client-a.txt
  • client B file record with ID 2: audit/client-b.txt

Confirm that both database records and both storage objects exist.

  1. Create a staff account named Mallory with the legacy Uploader role. Confirm that Mallory has import_orphans=true and delete_others_files=false. Sign in as Mallory and retain the normal session cookie and CSRF token required by ProjectSend.
  1. Snapshot both the database and file storage.
  1. Establish the canonical import baseline by sending this authenticated request:

POST /files/orphans/import Content-Type: application/json X-XSRF-TOKEN: <Mallory CSRF token>

{"items":[{"disk":"files","path":"audit/client-a.txt"}]}

The verified response was HTTP 302. Confirm that no additional file row was created.

  1. Establish the canonical deletion baseline by sending:

POST /files/orphans/delete Content-Type: application/json X-XSRF-TOKEN: <Mallory CSRF token>

{"items":[{"disk":"files","path":"audit/client-b.txt"}]}

The verified response was HTTP 302. Confirm that client B's storage bytes remain present.

  1. Establish the authorization negative control by sending:

DELETE /files/2 X-XSRF-TOKEN: <Mallory CSRF token>

The verified response was HTTP 403. Confirm again that client B's storage bytes remain present.

  1. Apply the single path-alias mutation to the import request:

POST /files/orphans/import Content-Type: application/json X-XSRF-TOKEN: <Mallory CSRF token>

{"items":[{"disk":"files","path":"./audit/client-a.txt"}]}

The verified response was HTTP 302. Confirm that a third file row now exists, that its uploaded_by value identifies Mallory, and that its stored path remains ./audit/client-a.txt. Confirm that Flysystem resolves this row to the same stored bytes as audit/client-a.txt.

  1. Apply the same controlled mutation to the deletion request:

POST /files/orphans/delete Content-Type: application/json X-XSRF-TOKEN: <Mallory CSRF token>

{"items":[{"disk":"files","path":"./audit/client-b.txt"}]}

The verified response was HTTP 302. Confirm that file row ID 2 still exists while the storage bytes for audit/client-b.txt are gone.

  1. Restore the database and storage snapshot from step 4, repeat steps 5 through 9, and compare the resulting status codes and state changes. A second clean replay produced the same results: both canonical orphan actions left the tracked objects unchanged, ordinary cross-owner deletion returned HTTP 403, alias import created the duplicate Mallory-owned row, and alias deletion removed client B's bytes while retaining its row.

Impact

A staff member who has the orphan-management permission and knows the exact path of a tracked file can delete that file's stored bytes through the orphan-deletion route even when ordinary deletion of the file is denied. The database record remains, leaving ProjectSend's database and storage inconsistent and making the selected file unavailable.

The same path alias can be imported to create a duplicate database row owned by the staff member while both rows resolve to the same stored bytes. The assessment did not demonstrate a storage-root escape, disclosure of cross-tenant file contents, access to any additional cross-tenant data, code execution, or deletion without knowledge of the target's exact storage path.

Patches / Workarounds

No patch is available yet. Proposed remediation:

Canonicalize every submitted storage path once, before performing exclusion checks, the storage-existence check, the known-path comparison, or any import or deletion. Use that same canonical value for all subsequent comparisons and persistence so that audit/client-b.txt and ./audit/client-b.txt cannot receive different classifications. If ProjectSend has no need to accept aliases, reject a request when its submitted path is not already in canonical form.

Immediately before import or deletion, verify using the canonical disk-and-path pair that the object is still absent from the tracked-file set. Add regression tests covering canonical paths and aliases containing . segments, and assert that neither import nor deletion can act on a tracked object through an alternate spelling.

Coordinated disclosure

ProjectSend's SECURITY.md directs vulnerability reports through GitHub's private vulnerability-reporting channel and does not specify a disclosure deadline. I propose coordinated disclosure within 90 days of receipt, or earlier after a fix is released and users have had a reasonable opportunity to update. I am available to validate a proposed fix before release.

Credit

  • Researcher: WHS4th Team TokenManiZo
  • Contact: afjss678@gmail.com

Text from GitHub Advisory Database

Affected versions 1

  • projectsend/projectsend, composer>= 2.0.0, <= 2.6.0GitHub