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).
Root cause: a credential-bearing client URL is reused as the public base URL. The chain:
new BlobServiceClient("https://<account>.blob.core.windows.net?<SAS query string>") where cluster.key is the full SAS string including siggetUrl() takes getContainerClient(...).getBlobClient('').url as baseUrl; SDK v12 derived clients inherit the SAS query string, so baseUrl already contains ?sv=...&sig=...attachment.url() = uploadfs.getUrl() + path for every rendered attachment link; attachment/index.js#L1503 — getBrowserData() also ships uploadsUrl to every logged-in editor session/container/?sv=...&sig=XXX/attachments/id-name), so these URLs are also broken — evidence the 1.25.0 code path was never testedAttack 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:
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.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.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:
npm install @azure/storage-blob@12 && node poc.jsnew 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.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.
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.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).
{
"cwe_ids": [
"CWE-200",
"CWE-522"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-20T18:20:35Z",
"nvd_published_at": null,
"severity": "MODERATE"
}