PST-090: The catalogue wheel download names its temporary file from the release URL's last path segment #819

Closed
opened 2026-10-10 10:56:57 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 8 to 10 October 2026, as PST-090. Severity: Informational.

The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.


  • Severity: Informational
  • Category: path-handling / input from a signed third-party source
  • Location: src/postulo/plugins/catalogue.py (358,371)
  • Found by: injection and parsers

Who can do it, and what they need. The publisher of a plugin catalogue the administrator has configured (URL plus Ed25519 key), or somebody holding that publisher's signing key. Precondition: an administrator presses Install for a release of that catalogue.

How it works.

  1. Publish a signed index whose release url ends in /.. or /. (or has no path at all).
  2. download() computes target = into / (release.url.rstrip("/").rsplit("/", 1)[-1] or "plugin.whl"), so target is the parent of the fresh TemporaryDirectory (or the directory itself).
  3. target.open("wb") raises IsADirectoryError, which nothing catches on the install path (CatalogueError/InstallError are), so the page answers 500. Nothing is written outside the scratch directory because a directory cannot be opened for writing and the name has no separators; the checksum check still runs on whatever is written.

Impact. An unhandled 500 on an administrator action from a source that is already trusted to deliver code; no write outside the scratch directory was found. Recorded for completeness.

Verification (verdict: confirmed; severity after verification: Informational). Correction: 'or has no path at all' is wrong; https://wheels.example yields a file named 'wheels.example' (tested, no failure); the 'plugin.whl' fallback only applies to a URL that is empty after stripping slashes. The more useful framing is that catalogue.install has no handler around download() at all: an unreachable wheel host (httpx.ConnectError) and a refused destination (http.DestinationRefused) also produce a 500 on the same admin action (tested: both returned 500). Remediation: catch httpx.HTTPError, http.DestinationRefused and OSError in install()/download() and re-raise as CatalogueError; build the temp name from a fixed 'plugin.whl' or from the validated wheel filename.

Suggested remediation. Name the download yourself (e.g. a random token plus .whl, as the upload path does with secrets.token_urlsafe) rather than from the URL, and catch OSError around the write as a CatalogueError.

Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 8 to 10 October 2026, as **PST-090**. Severity: **Informational**. The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it. --- - **Severity:** Informational - **Category:** path-handling / input from a signed third-party source - **Location:** `src/postulo/plugins/catalogue.py` (358,371) - **Found by:** injection and parsers **Who can do it, and what they need.** The publisher of a plugin catalogue the administrator has configured (URL plus Ed25519 key), or somebody holding that publisher's signing key. Precondition: an administrator presses Install for a release of that catalogue. **How it works.** 1. Publish a signed index whose release url ends in `/..` or `/.` (or has no path at all). 2. download() computes target = into / (release.url.rstrip("/").rsplit("/", 1)[-1] or "plugin.whl"), so target is the parent of the fresh TemporaryDirectory (or the directory itself). 3. target.open("wb") raises IsADirectoryError, which nothing catches on the install path (CatalogueError/InstallError are), so the page answers 500. Nothing is written outside the scratch directory because a directory cannot be opened for writing and the name has no separators; the checksum check still runs on whatever is written. **Impact.** An unhandled 500 on an administrator action from a source that is already trusted to deliver code; no write outside the scratch directory was found. Recorded for completeness. **Verification** (verdict: confirmed; severity after verification: Informational). Correction: 'or has no path at all' is wrong; `https://wheels.example` yields a file named 'wheels.example' (tested, no failure); the 'plugin.whl' fallback only applies to a URL that is empty after stripping slashes. The more useful framing is that catalogue.install has no handler around download() at all: an unreachable wheel host (httpx.ConnectError) and a refused destination (http.DestinationRefused) also produce a 500 on the same admin action (tested: both returned 500). Remediation: catch httpx.HTTPError, http.DestinationRefused and OSError in install()/download() and re-raise as CatalogueError; build the temp name from a fixed 'plugin.whl' or from the validated wheel filename. **Suggested remediation.** Name the download yourself (e.g. a random token plus .whl, as the upload path does with secrets.token_urlsafe) rather than from the URL, and catch OSError around the write as a CatalogueError.
tiagoagueda added this to the 1.0.0 milestone 2026-10-10 10:58:19 +00:00
Author
Owner

Fixed in 12bef18cc on main.

Fixed in 12bef18cc on main.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#819
No description provided.