GitHub Security Advisory: GHSA-fpw4-p57j-hqmq
Package: @paperclipai/ui (NPM)
Severity: MODERATE (CVSS 5.4)
CWE: CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Vulnerable versions: < 2026.416.0
Patched in: 2026.416.0

Summary: Paperclip: Stored XSS via javascript: URLs in MarkdownBody — urlTransform override disables react-markdown sanitization

Details:
## 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. Rend