{"id":"CVE-2026-113652","kind":"vulnerability","aliases":["CVE-2026-113652","GHSA-hhph-8536-gfq5"],"related":[],"title":"ApostropheCMS uploadfs Azure backend leaks account-level SAS token into every public attachment URL (sas: true)","description":"## Summary\n\nWhen 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.\n\nVerified at commit `5a3746aaed49761e171c2cbfe793267c959829fd` (uploadfs 1.27.0 in the apostrophe monorepo).\n\n## Details\n\nRoot cause: a credential-bearing client URL is reused as the public base URL. The chain:\n\n- Credential-embedded client: [azure.js#L123-L125](https://github.com/apostrophecms/apostrophe/blob/5a3746aaed49761e171c2cbfe793267c959829fd/packages/uploadfs/lib/storage/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`\n- Leak point: [azure.js#L513-L520](https://github.com/apostrophecms/apostrophe/blob/5a3746aaed49761e171c2cbfe793267c959829fd/packages/uploadfs/lib/storage/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=...`\n- Public exposure: [attachment/index.js#L694](https://github.com/apostrophecms/apostrophe/blob/5a3746aaed49761e171c2cbfe793267c959829fd/packages/apostrophe/modules/@apostrophecms/attachment/index.js#L694) — `attachment.url()` = `uploadfs.getUrl() + path` for every rendered attachment link; [attachment/index.js#L1503](https://github.com/apostrophecms/apostrophe/blob/5a3746aaed49761e171c2cbfe793267c959829fd/packages/apostrophe/modules/@apostrophecms/attachment/index.js#L1503) — `getBrowserData()` also ships `uploadsUrl` to every logged-in editor session\n- 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\n\nAttack 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.\n\n**Suggested remediation:**\n1. 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.\n2. 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.\n\n## PoC\n\nFull 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`:\n\n1. `npm install @azure/storage-blob@12 && node poc.js`\n2. 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.\n3. Observed output (exit code 0):\n\n```\nbaseUrl: https://myaccount.blob.core.windows.net/uploads-container/?sv=2021-06-08&ss=b&srt=co&sp=rwdlac&se=2099-01-01&sig=FAKESIGNATURE\nattachment 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\n```\n\nThe 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.\n\n## Impact\n\n- **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.\n- **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.\n\n---\n\nReported by **Kimi Security Team** (bug-report@moonshot.ai).","summary":"uploadfs (the ApostropheCMS file storage library) with the Azure backend and sas: true embeds the full account-level SAS token, including its signature, into the public base URL used for every attachment link, so every site visitor receives the credential. An attacker who loads any page can read, overwrite, delete and list every blob in the container for the token's lifetime, or only read and download if the SAS was provisioned read-only.","severity":"medium","cvss":5.6,"epss":0.0026199999265372753,"exploited":false,"exploited_at":null,"products":[{"name":"uploadfs","fixed":["1.28.0"],"affected":">= 1.25.0, < 1.28.0","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["apostrophecms","azure","cwe-200","cwe-522","node","package","self-hosted","uploadfs"],"not_affected_if":["non-azure-backend","shared-key-mode","uploadfs-1.28.0-or-later","uploadfs-below-1.25.0"],"how_to_check":"Check the uploadfs version in package-lock.json or node_modules/uploadfs/package.json; affected range is >= 1.25.0 and < 1.28.0.\nCheck the uploadfs config for an Azure backend with sas: true.\nView page source on a public page with attachments and search for 'sig=' in attachment URLs.","fix":"Upgrade uploadfs to 1.28.0 or later.\nIf upgrading is not possible, switch to shared-key mode, or rotate the SAS token and reissue it as short-lived read-only per-blob tokens.","status":"open","published_at":"2026-10-20T17:17:36+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","triaged_at":"2026-10-23T19:58:09+00:00","updated_at":"2026-10-23T21:57:20+00:00","category":"package","confidence":0.85,"triage":{"at":"2026-10-23T19:58:09Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade uploadfs to 1.28.0 or later.\nIf upgrading is not possible, switch to shared-key mode, or rotate the SAS token and reissue it as short-lived read-only per-blob tokens.","summary":"uploadfs (the ApostropheCMS file storage library) with the Azure backend and sas: true embeds the full account-level SAS token, including its signature, into the public base URL used for every attachment link, so every site visitor receives the credential. An attacker who loads any page can read, overwrite, delete and list every blob in the container for the token's lifetime, or only read and download if the SAS was provisioned read-only.","category":"package","who_cares":["apostrophecms","uploadfs","azure","node","self-hosted"],"confidence":0.85,"analyst_note":"","how_to_check":"Check the uploadfs version in package-lock.json or node_modules/uploadfs/package.json; affected range is >= 1.25.0 and < 1.28.0.\nCheck the uploadfs config for an Azure backend with sas: true.\nView page source on a public page with attachments and search for 'sig=' in attachment URLs.","needs_analyst":false,"not_affected_if":["shared-key-mode","non-azure-backend","uploadfs-below-1.25.0","uploadfs-1.28.0-or-later"],"severity_if_unknown":"unknown"},"cost_usd":0.000654,"input_tokens":4194,"output_tokens":470,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-113652':20A,28A '-200':33B '-2026':19A,27A '-522':35B '/apostrophecms/apostrophe/blob/5a3746aaed49761e171c2cbfe793267c959829fd/packages/apostrophe/modules/@apostrophecms/attachment/index.js#l1503)':366C '/apostrophecms/apostrophe/blob/5a3746aaed49761e171c2cbfe793267c959829fd/packages/apostrophe/modules/@apostrophecms/attachment/index.js#l694)':353C '/apostrophecms/apostrophe/blob/5a3746aaed49761e171c2cbfe793267c959829fd/packages/uploadfs/lib/storage/azure.js#l123-l125)':303C '/apostrophecms/apostrophe/blob/5a3746aaed49761e171c2cbfe793267c959829fd/packages/uploadfs/lib/storage/azure.js#l513-l520)':324C '/container':389C '1':457C,567C '1.25.0':135C,401C '1.27.0':270C '12':566C,571C '2':491C,574C '5a3746aaed49761e171c2cbfe793267c959829fd':268C '8536':24A 'access':453C 'account':7A,59B,551C 'account-level':6A,58B 'account/container':171C 'accounturl':476C 'action':432C 'affect':264C 'alreadi':343C 'also':368C,397C 'anonym':204C 'anyon':206C 'apostroph':177C,273C 'apostrophecm':1A,30B,44B 'append':234C,384C,480C 'attach':14A,74B,185C,211C,360C,413C 'attachment.url':179C,354C 'attachment/index.js':349C,362C 'attack':84B,407C 'azur':3A,31B,50B,124C,151C,260C,550C 'azure.js':297C,318C 'azure/storage-blob':565C,570C 'b':505C 'backend':4A,51B,125C,261C 'base':69B,166C,289C 'baseurl':331C,342C 'bear':281C 'beyond':437C 'blob':96B,220C,232C,381C,482C,508C,517C 'blob.core.windows.net':306C 'blob.svc.getcontainerclient':470C 'blobservicecli':139C,305C,579C 'broadcast':187C 'broken':240C,398C 'bug':231C,379C 'build':137C,463C 'carri':168C 'caus':277C 'cdn/proxy':452C 'chain':292C 'client':155C,282C,296C,335C 'cluster.key':308C 'code':244C,402C 'comment':544C 'commit':267C 'common':194C 'companion':229C,378C 'complet':170C 'compos':475C 'configur':121C,131C 'const':466C 'construct':577C 'contain':99B,190C,223C,344C,477C 'container-level':189C 'createcontain':136C,586C 'credenti':82B,193C,280C,294C,442C 'credential-bear':279C 'credential-embed':293C 'cve':18A,26A 'cwe':32B,34B 'declar':563C 'default':256C 'delet':92B,216C 'deliv':421C 'depend':564C 'deriv':154C,334C 'detail':275C 'document':492C 'download':109B,416C 'editor':376C,423C 'emb':55B,144C 'embed':295C,527C 'end':249C,251C 'evalu':588C 'everi':12A,73B,77B,95B,183C,200C,219C,358C,372C,409C 'evid':399C 'exact':558C,584C 'exercis':248C 'exposur':348C 'fakesignatur':583C 'file':45B 'first':543C 'full':57B,146C,197C,311C,538C 'function':230C 'generat':511C 'generateblobsasqueryparamet':524C 'getblobcli':328C,471C 'getbrowserdata':367C 'getc':592C 'getcontainercli':327C 'geturl':163C,325C,590C 'gfq5':25A 'ghsa':22A 'ghsa-hhph':21A 'github.com':302C,323C,352C,365C 'github.com/apostrophecms/apostrophe/blob/5a3746aaed49761e171c2cbfe793267c959829fd/packages/apostrophe/modules/@apostrophecms/attachment/index.js#l1503)':364C 'github.com/apostrophecms/apostrophe/blob/5a3746aaed49761e171c2cbfe793267c959829fd/packages/apostrophe/modules/@apostrophecms/attachment/index.js#l694)':351C 'github.com/apostrophecms/apostrophe/blob/5a3746aaed49761e171c2cbfe793267c959829fd/packages/uploadfs/lib/storage/azure.js#l123-l125)':301C 'github.com/apostrophecms/apostrophe/blob/5a3746aaed49761e171c2cbfe793267c959829fd/packages/uploadfs/lib/storage/azure.js#l513-l520)':322C 'header':450C 'hhph':23A 'host':40B 'html':446C 'ideal':503C 'imag':415C 'includ':63B,203C,314C 'inherit':156C,336C 'instal':569C 'instead':525C 'introduc':132C 'key':254C 'l123':299C 'l123-l125':298C 'l125':300C 'l1503':363C 'l513':320C 'l513-l520':319C 'l520':321C 'l694':350C 'leak':5A,316C,426C 'least':498C 'level':8A,60B,191C,531C 'librari':47B,556C 'lifetim':104B,227C 'like':585C 'link':75B,186C,361C,417C 'list':94B,218C 'live':514C 'load':86B 'log':374C,448C,454C 'logged-in':373C 'logic':591C 'manual':478C 'mode':255C,495C 'monorepo':274C 'must':496C 'myaccount.blob.core.windows.net':580C 'need':552C 'never':247C,405C 'new':304C,468C,578C 'node':36B,572C 'non':259C 'non-azur':258C 'npm':568C 'offlin':548C 'one':205C 'overwrit':91B,215C 'packag':37B 'page':88B,411C,440C 'passiv':428C 'path':233C,245C,356C,382C,403C,483C 'pathnam':486C 'per':507C,516C 'per-blob':506C,515C 'perman':534C 'plus':418C 'poc':537C,539C 'poc.js':546C,573C 'point':317C 'post':540C 'prefer':510C 'prepend':180C 'privileg':499C 'produc':239C 'propag':444C 'provis':114B 'public':13A,68B,184C,288C,347C,410C,464C,535C 'queri':148C,158C,237C,339C,387C,460C,490C 'read':90B,107B,116B,214C,501C,519C 'read-on':115B,500C,518C 'read/write/delete/list':198C 'readm':547C 'receiv':80B 'refer':449C 'remedi':456C 'render':359C,412C 'replic':554C 'requir':433C 'result':162C 'return':164C 'reus':285C 'root':276C 'rwdlac':196C 'sas':9A,16A,53B,61B,112B,127C,147C,172C,192C,226C,312C,338C,494C,532C 'script':576C 'sdk':152C,332C 'see':209C 'self':39B 'self-host':38B 'serv':438C 'servic':530C 'service-level':529C 'session':377C,424C 'share':253C 'shared-key':252C 'ship':369C 'short':513C 'short-liv':512C 'show':242C 'sig':175C,315C,346C,391C,582C 'sign':521C 'signatur':65B,173C 'site':78B,201C 'sourc':447C 'sp':195C 'sr':504C 'step':560C 'storag':46B 'string':149C,159C,238C,313C,340C,388C,461C 'strip':458C 'suggest':455C 'summari':117C 'support':130C 'surfac':408C 'sv':174C,345C,390C,581C 'take':326C 'test':406C 'token':10A,62B,102B 'true':17A,54B,128C 'two':559C 'u':467C 'u.search':473C 'uncondit':430C 'uploadf':2A,41B,42B,119C,134C,269C 'uploadfs':29A 'uploadfs.geturl':355C 'uploadsurl':370C,420C 'url':15A,70B,142C,167C,212C,241C,283C,290C,329C,395C,414C,465C,469C,472C,522C,536C 'use':71B,497C 'v12':153C,333C 'verifi':265C 'via':523C 'victim':436C 'visitor':79B,202C 'xxx/attachments/id-name':392C","nicknames":[],"mitigations":[],"who_affected":null,"tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-20T17:17:36+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:L","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-hhph-8536-gfq5","url":"https://github.com/advisories/GHSA-hhph-8536-gfq5","severity":"medium","published_at":"2026-10-20T18:20:35+00:00","modified_at":"2026-10-20T18:20:36+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","fetched_at":"2026-10-23T19:11:32+00:00","note":null},{"source":"nvd","external_id":"CVE-2026-113652","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-113652","severity":null,"published_at":"2026-10-20T17:17:36+00:00","modified_at":"2026-10-21T16:17:23+00:00","first_seen_at":"2026-10-23T21:57:20+00:00","fetched_at":"2026-10-23T22:28:58+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}