Skip to content

feat(tldraw): add custom SVG sanitizer for external content#7896

Merged
MitjaBezensek merged 8 commits intomainfrom
mitja/sanitization-again
Feb 23, 2026
Merged

feat(tldraw): add custom SVG sanitizer for external content#7896
MitjaBezensek merged 8 commits intomainfrom
mitja/sanitization-again

Conversation

@MitjaBezensek
Copy link
Copy Markdown
Contributor

@MitjaBezensek MitjaBezensek commented Feb 12, 2026

Closes #7876

After reverting DOMPurify-based sanitization (#7886) because it stripped <foreignObject> (needed for text) and <style> (needed for fonts), the SDK had no sanitization of pasted/dropped SVGs. This PR adds a custom allowlist-based sanitizer (~643 lines, <3KB gzip) that blocks attack vectors while preserving tldraw's own SVG output. The tag/attribute allowlists and URI sanitization approach are derived from DOMPurify (MIT license).

What it does

Root element validation: Rejects documents whose root is not <svg>.

Mode-switching sanitizer: Walks the SVG DOM tree, switching between SVG and HTML sanitization modes at <foreignObject> boundaries. This preserves tldraw's text rendering (foreignObject with HTML) while applying the correct allowlist in each context.

Context-dependent URI sanitization:

  • <image> / <feImage>: raster data: only (no svg+xml — could embed unsanitized SVG)
  • <use>: fragment refs (#id) only
  • <a>: http:, https:, mailto: only

CSS sanitization: Strips @import, expression(), -moz-binding, behavior:, and external url() references. Preserves data: font/image URLs and url(#id) fragment references (needed for gradients/filters). Handles multiline url() values (dotAll flag) and CSS escape decoding with bounds checks.

URL-bearing presentation attributes: Sanitizes url() values in fill, filter, clip-path, mask, stroke, marker-*, cursor — not just href/xlink:href. Case-insensitive matching. External url() references in these attributes are stripped while url(#id) internal refs are preserved.

Animation safety: <animate>, <set>, <animateColor>, <animateTransform> elements targeting href, xlink:href, or on* via attributeName are removed (prevents runtime injection of javascript: URIs that bypass static sanitization).

Event handler stripping: All on* attributes stripped after invisible whitespace normalization — catches current and future handlers without maintaining a blocklist.

Integration: Applied at all 4 SVG entry points via dynamic import (keeps sanitizer out of main bundle). maybeSanitizeSvgFile has try-catch so one bad file doesn't abort a batch. Failed SVG sanitization shows a toast in multi-file drops. SVG text paste returns silently on failure. File-replace returns silently on failure (called without await).

Change type

  • improvement

Test plan

  1. Paste tldraw-generated SVG (copy as SVG) — text, fonts, foreignObject preserved
  2. Paste malicious SVGs (script, onerror, onload, javascript: href) — all blocked
  3. Paste SVG with animation targeting href — animation removed
  4. Paste SVG with external url() in fill/filter/mask attributes — stripped
  5. Drop multiple files including a malicious SVG — toast shown, other files still processed
  • Unit tests (76 tests covering attack vectors, preservation, and round-trip with all shape types)

API changes

  • Added sanitizeSvg(svgText: string): string — public export for SDK users building custom handlers

Release notes

  • Add SVG sanitization on paste and file drop to prevent XSS and data exfiltration

@huppy-bot huppy-bot bot added the improvement Product improvement label Feb 12, 2026
@vercel
Copy link
Copy Markdown

vercel bot commented Feb 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
examples Ready Ready Preview Feb 12, 2026 2:01pm
5 Skipped Deployments
Project Deployment Actions Updated (UTC)
analytics Ignored Ignored Preview Feb 12, 2026 2:01pm
chat-template Ignored Ignored Preview Feb 12, 2026 2:01pm
tldraw-docs Ignored Ignored Preview Feb 12, 2026 2:01pm
tldraw-shader Ignored Ignored Preview Feb 12, 2026 2:01pm
workflow-template Ignored Ignored Preview Feb 12, 2026 2:01pm

Request Review

@vercel vercel bot temporarily deployed to Preview – tldraw-shader February 12, 2026 09:40 Inactive
@vercel vercel bot temporarily deployed to Preview – chat-template February 12, 2026 09:40 Inactive
@vercel vercel bot temporarily deployed to Preview – workflow-template February 12, 2026 09:40 Inactive

async function maybeSanitizeSvgFile(file: File): Promise<File | null> {
if (file.type !== 'image/svg+xml') return file
try {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strict MIME check bypasses SVG sanitizer

Medium Severity

maybeSanitizeSvgFile only sanitizes when file.type is exactly image/svg+xml. SVG files with valid MIME variants (for example with parameters) bypass sanitizeSvg and are processed as trusted content when those MIME types are allowed in acceptedImageMimeTypes, creating a sanitizer bypass path.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

@MitjaBezensek MitjaBezensek Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DEFAULT_SUPPORTED_VECTOR_IMAGE_TYPES is exactly ['image/svg+xml']. notifyIfFileNotAllowed rejects files before they reach maybeSanitizeSvgFile if their type isn't in the accepted list.

@MitjaBezensek MitjaBezensek force-pushed the mitja/sanitization-again branch from 5dab162 to 61f2a31 Compare February 12, 2026 09:57
) {
const { sanitizeSvg } = await import('./utils/svg/sanitizeSvg')
text = sanitizeSvg(text)
if (!text) return
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SVG text paste can still throw

Low Severity

defaultHandleExternalSvgTextContent dynamically imports sanitizeSvg without a try/catch. If that chunk load fails, paste handling rejects before reaching the new silent-failure path, so SVG text paste can still produce an unhandled error instead of being safely ignored.

Fix in Cursor Fix in Web

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

return match
}
return ''
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CSS comments bypass URL sanitization

High Severity

sanitizeCssValue matches url( and @import with regexes that do not handle CSS comments. Payloads like url/**/(...) or @import/**/url(...) survive sanitization because CSS treats comments as whitespace. This leaves external resource loads in sanitized SVG/CSS and reintroduces network exfiltration vectors.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Member

@mimecuvalo mimecuvalo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this PR does make me pretty nervous to maintain this 😬

couple things to consider before moving forward:

  • how do we stay up to date with DOMPurify?
  • can this block the main thread if it's a big SVG? should we consider a web worker?
  • is this possibly a delay-loaded JS module since not many regular folks would prbly paste in SVGs?

@MitjaBezensek
Copy link
Copy Markdown
Contributor Author

this PR does make me pretty nervous to maintain this 😬

couple things to consider before moving forward:

  • how do we stay up to date with DOMPurify?
  • can this block the main thread if it's a big SVG? should we consider a web worker?
  • is this possibly a delay-loaded JS module since not many regular folks would prbly paste in SVGs?

That's why I initially went with using DOMPurify directly, but it did pull 9k minified (or something like that). And I think we decided that's too much.

We use DOMParser which doesn't seem to be available in a worker. This should be pretty fast, it's a linear scan over nodes.

We already use a dynamic import

const { sanitizeSvg } = await import('./utils/svg/sanitizeSvg')

Copy link
Copy Markdown
Member

@mimecuvalo mimecuvalo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool, i didn't see that it was delay-loaded, nice.

LGTM 👍

@MitjaBezensek MitjaBezensek added this pull request to the merge queue Feb 23, 2026
Merged via the queue into main with commit 579ba9f Feb 23, 2026
20 checks passed
@MitjaBezensek MitjaBezensek deleted the mitja/sanitization-again branch February 23, 2026 08:49
steveruizok added a commit that referenced this pull request Feb 27, 2026
Add 12 new entries from PRs merged since v4.4.0:
- Featured: click-through on transparent image pixels (#7942)
- API: enum-to-const-object refactor (#8084)
- Improvements: SVG sanitizer (#7896), save-on-blur (#8037)
- Bug fixes: cross-origin download (#8090), zero-size draw (#8067),
  rich text toolbar cleanup (#8050), zoom threshold (#8040),
  selection foreground fallback (#8011), sticky note SVG shadow (#7934),
  arrow frame clamping (#7932), zero pressure draw (#5693)
github-merge-queue bot pushed a commit that referenced this pull request Mar 4, 2026
Update `next.mdx` release notes to cover all SDK-relevant PRs merged to
main since v4.4.0.

Highlights:
- Display values system (#8121) with breaking changes and migration
guide
- Click-through on transparent image pixels (#7942)
- `Editor.resizeToBounds()` (#8120)
- SVG sanitization (#7896)
- TypeScript enum-to-const refactoring (#8084)
- 14 bug fixes and 4 improvements

### Change type

- [x] `other`

### Test plan

1. Verify the release notes render correctly on the docs site

### Release notes

- Update next release notes with changes since v4.4.0.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: documentation-only changes updating release notes content
and date, with no runtime/code behavior impact.
> 
> **Overview**
> Updates `apps/docs/content/releases/next.mdx` for the upcoming release
by refreshing the date and replacing the brief blurb with expanded
release notes.
> 
> Documents new SDK surface area (`Geometry2d.ignoreHit`,
`Editor.resizeToBounds`, `sanitizeSvg`), highlights click-through on
transparent image pixels, and adds a list of recent improvements and bug
fixes (paste parenting, link/alt-text persistence, SVG sanitization
behavior, circular-dependency cleanup, and several crash/export/sync
fixes).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
5f7dc0a. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
github-merge-queue bot pushed a commit that referenced this pull request Mar 18, 2026
In order to publish the v4.5.0 release notes and record the v4.4.1
patch, this PR archives release notes and resets `next.mdx` for the next
cycle.

**v4.5.0.mdx** (new file):
- Archived from `next.mdx` with full frontmatter, keywords, and GitHub
release link
- Featured sections: click-through on transparent image pixels (#7942),
breaking `EmbedShapeUtil.configure()` change (#8034)
- API changes: `Editor.getResizeScaleFactor()` (#8042),
`TLImageAsset.pixelRatio` (#8163), `sanitizeSvg` (#7896),
`experimental__onDropOnCanvas` (#7911), enum-to-const refactoring
(#8084)
- 6 improvements and 20 bug fixes from production

**v4.4.0.mdx:**
- Add v4.4.1 patch release section with tooltip positioning fix (#8171)
- Add v4.4.1 to keywords

**next.mdx:**
- Reset with `last_version: v4.5.0` and empty content

### Change type

- [x] `other`

### Code changes

| Section       | LOC change    |
| ------------- | ------------- |
| Documentation | +128 / -107   |
huppy-bot bot pushed a commit that referenced this pull request Mar 18, 2026
In order to publish the v4.5.0 release notes and record the v4.4.1
patch, this PR archives release notes and resets `next.mdx` for the next
cycle.

**v4.5.0.mdx** (new file):
- Archived from `next.mdx` with full frontmatter, keywords, and GitHub
release link
- Featured sections: click-through on transparent image pixels (#7942),
breaking `EmbedShapeUtil.configure()` change (#8034)
- API changes: `Editor.getResizeScaleFactor()` (#8042),
`TLImageAsset.pixelRatio` (#8163), `sanitizeSvg` (#7896),
`experimental__onDropOnCanvas` (#7911), enum-to-const refactoring
(#8084)
- 6 improvements and 20 bug fixes from production

**v4.4.0.mdx:**
- Add v4.4.1 patch release section with tooltip positioning fix (#8171)
- Add v4.4.1 to keywords

**next.mdx:**
- Reset with `last_version: v4.5.0` and empty content

### Change type

- [x] `other`

### Code changes

| Section       | LOC change    |
| ------------- | ------------- |
| Documentation | +128 / -107   |
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

improvement Product improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sanitize SVG content in the SDK to prevent XSS

2 participants