Summary
When uploadfs is configured with the Azure backend and sas: true (a supported configuration introduced in uploadfs 1.25.0), createContainer() builds the BlobServiceClient from a URL that embeds the full SAS query string, and Azure SDK v12 derived clients inherit that query string. As a result, getUrl() returns a base URL carrying the complete account/container SAS signature (?sv=...&sig=...), and Apostrophe's attachment.url() prepends it to every public attachment link — broadcasting a container-level SAS credential (commonly sp=rwdlac, full read/write/delete/list) to every site visitor, including anonymous ones. Anyone who can see an attachment URL can read, overwrite, delete, and list every blob in the container for the SAS lifetime. A companion functional bug (blob path appended after the query string, producing broken URLs) shows this code path was never exercised end to end. Shared-key mode (default) and non-Azure backends are not affected.
Verified at commit 5a3746aaed49761e171c2cbfe793267c959829fd (uploadfs 1.27.0 in the apostrophe monorepo).
Details
Root cause: a credential-bearing client URL is reused as the public base URL. The chain:
- Credential-embedded client: azure.js#L123-L125 — new BlobServiceClient("https://<account>.blob.core.windows.net?<SAS query string>") where cluster.key is the full SAS string including sig - Leak point: azure.js#L513-L520 — getUrl() takes getContainerClient(...).getBlobClient('').url as baseUrl; SDK v12 derived clients inherit the SAS query string, so baseUrl already contains ?sv=...&sig=... - Public exposure: attachment/index.js#L694 — attachment.url() = uploadfs.getUrl() + path for every rendered attachment link; attachment/index.js#L1503 — getBrowserData() also ships uploadsUrl to every logged-in editor session - Companion bug: the blob path is appended after the query string (/container/?sv=...&sig=XXX/attachments/id-name), so these URLs are also broken — evidence the 1.25.0 code path was never tested
Attack surface: every public page rendering attachment URLs (images, download links), plus the uploadsUrl delivered to editor sessions. The leak is passive and unconditional — no action required from the victim beyond serving a page. The credential further propagates through HTML source, logs, Referer headers, and CDN/proxy access logs.
Suggested remediation: 1. Strip the query string before building public URLs: const u = new URL(blob.svc.getContainerClient(...).getBlobClient('').url); u.search = ''; (or compose accountUrl + '/' + container manually), and append the blob path to the pathname, not after the query. 2. Document that SAS mode must use least privilege (read-only, ideally sr=b per-blob), and prefer generating short-lived per-blob read-only signed URLs via generateBlobSASQueryParameters instead of embedding a service-level SAS in permanent public URLs.
PoC
Full PoC posted as the first comment below (poc.js + README; offline, no Azure account needed). It replicates the library's exact two steps with the declared dependency @azure/storage-blob@12:
1. npm install @azure/storage-blob@12 && node poc.js 2. The script constructs new BlobServiceClient("https://myaccount.blob.core.windows.net?sv=...&sig=FAKESIGNATURE") exactly like createContainer(), then evaluates the getUrl() logic (getContainerClient('uploads-container').getBlobClient('').url) and asserts the resulting base URL still contains the sig parameter. 3. Observed output (exit code 0):
baseUrl: https://myaccount.blob.core.windows.net/uploads-container/?sv=2021-06-08&ss=b&srt=co&sp=rwdlac&se=2099-01-01&sig=FAKESIGNATURE attachment URL as served to visitors: https://myaccount.blob.core.windows.net/uploads-container/?sv=2021-06-08&ss=b&srt=co&sp=rwdlac&se=2099-01-01&sig=FAKESIGNATURE/attachments/abc123-photo.jpg
The produced URL both leaks the token (sv…sig) and demonstrates the broken path placement. Cross-checking attachment/index.js confirms this URL reaches public pages unmodified.
Impact
- What it is: insufficiently protected credentials (CWE-522) leading to information exposure (CWE-200). A container-level SAS token — commonly granted sp=rwdlac (read/write/delete/list/ACL) — is disclosed to every site visitor including anonymous users, valid for the SAS lifetime (often years). Holders can read, overwrite, delete, and enumerate every attachment in the container; the library applies no narrowing. - Who is impacted: Apostrophe sites using uploadfs with the Azure backend and sas: true (introduced in 1.25.0; present through 1.27.0). Default shared-key mode and other backends are unaffected. When the SAS is provisioned read-only, the impact reduces to unauthenticated read/download of all attachments.
---
Reported by Kimi Security Team (bug-report@moonshot.ai).
Summary The uploadfs library's local storage backend does not validate or sanitize user-supplied paths. The prefixPath() helper only strips double slashes (//) and the backend performs direct string concatenation (uploadsPath + path). An attacker can inject ../ sequences to escape the intended uploads directory, resulting in arbitrary file read, write, and delete on the host filesystem.
---
Details
Root Cause packages/uploadfs/uploadfs.js:652-655
https://github.com/apostrophecms/apostrophe/blob/main/packages/uploadfs/uploadfs.js#L652-L655
javascript function prefixPath(path) { // Resolve any double // that results from the prefix return (self.prefix + path).replace(/\/\//g, '/'); }
packages/uploadfs/lib/storage/local.js
https://github.com/apostrophecms/apostrophe/blob/main/packages/uploadfs/lib/storage/local.js#L92-L95 https://github.com/apostrophecms/apostrophe/blob/main/packages/uploadfs/lib/storage/local.js#L102-L104 https://github.com/apostrophecms/apostrophe/blob/main/packages/uploadfs/lib/storage/local.js#L106-L108
javascript copyIn: function(localPath, path, options, callback) { const uploadPath = uploadsPath + path; // [VULNERABLE] return copyFile(localPath, uploadPath, callback); }, streamOut: function(path, options) { return fs.createReadStream(uploadsPath + path); // [VULNERABLE] }, remove: function(path, callback) { const uploadPath = uploadsPath + path; // [VULNERABLE] fs.unlink(uploadPath, callback);
- prefixPath() does not normalize paths or reject ../. - The local backend concatenates the path directly into the filesystem call. - Node.js resolves /app/uploads/../../../tmp/pwned.txt → /tmp/pwned.txt.
---
Proof of Concept (PoC)
Here is the poc files https://drive.proton.me/urls/AKY4V5S0JR#Vw6XJdVCZafk
Build and run bash docker load < apostrophe-uploadfs-poc.tar docker run -d -p 3000:3000 --name uploadfs-poc apostrophe-uploadfs-poc python3 exploit.py
Expected output text [UPLOAD] Status: 200 | Body: Uploaded to ../../../tmp/pwned.txt [READ] Status: 200| Body: You have been pwned! [+] Vulnerability confirmed: arbitrary file write/read/delete via path traversal.
<img width="885" height="171" alt="Screenshot 2026-07-22 at 08 57 46" src="https://github.com/user-attachments/assets/1b3ab5d5-43e4-4a81-8d19-c2270da072ae" />
Manual reproduction bash Write outside uploads directory curl -X POST http://localhost:3000/upload \ -F "path=../../../tmp/backdoor.txt" \ -F "file=@-;filename=backdoor.txt" <<< "malicious content"
Read it back curl "http://localhost:3000/read?path=../../../tmp/backdoor.txt"
Delete it curl -X DELETE "http://localhost:3000/remove?path=../../../tmp/backdoor.txt"
<img width="1004" height="114" alt="Screenshot 2026-07-22 at 08 58 36" src="https://github.com/user-attachments/assets/26b59a98-1834-4f00-8e18-c869812ddd58" />
---
Impact
| Capability | Risk | |------------|------| | Arbitrary File Write | Write to /etc/cron.d/, .ssh/authorizedkeys, or webroot files → Remote Code Execution | | Arbitrary File Read | Exfiltrate /etc/shadow, application configs, database credentials | | Arbitrary File Delete | Destroy system or application files → Denial of Service |
---
Remediation
Add path containment validation inside uploadfs (and every storage backend) before any filesystem operation:
javascript const path = require('path'); function safePath(base, target) { const resolved = path.resolve(base, target); if (!resolved.startsWith(path.resolve(base) + path.sep)) { throw new Error('Path traversal blocked: target is outside the intended directory'); } return resolved; }