logo svelte /markdown v1.5.0

Security

@humanspeak/svelte-markdown renders markdown content into HTML. When processing user-generated markdown, it is important to understand the security implications and how to protect against cross-site scripting (XSS) and other injection attacks.

Built-in URL and Attribute Sanitization

The library includes built-in sanitization that runs in the Parser before tokens reach any renderer component or snippet. This means custom renderers and Svelte 5 snippets cannot bypass it.

URL Sanitization

By default, all href values on markdown links and images are validated against a protocol allowlist. Only safe protocols are permitted:

  • http: and https:
  • mailto: and tel:
  • Relative URLs (/path, #anchor, ?query, ./relative)

Dangerous protocols like javascript:, data:, vbscript:, and blob: are blocked automatically.

<!-- This link will have its href stripped -->
[Click me](javascript:alert('XSS'))

<!-- This link renders normally -->
[Click me](https://example.com)
<!-- This link will have its href stripped -->
[Click me](javascript:alert('XSS'))

<!-- This link renders normally -->
[Click me](https://example.com)

HTML Attribute Sanitization

For raw HTML within markdown, the default sanitizer:

  1. Strips all event handler attributes (onclick, onerror, onload, etc.)
  2. Validates URL-bearing attributes (href, src, action, formaction, cite, data, poster) against the same protocol allowlist
<!-- onclick is stripped, javascript: href is blocked -->
<a href="javascript:alert('XSS')" onclick="alert('XSS')">Click</a>

<!-- Safe attributes are preserved -->
<a href="https://example.com" class="link">Click</a>
<!-- onclick is stripped, javascript: href is blocked -->
<a href="javascript:alert('XSS')" onclick="alert('XSS')">Click</a>

<!-- Safe attributes are preserved -->
<a href="https://example.com" class="link">Click</a>

Context-Aware Overrides

Both sanitization functions receive a context object with the token type and HTML tag name, so you can implement per-element policies:

<script>
    import SvelteMarkdown, { defaultSanitizeUrl } from '@humanspeak/svelte-markdown'

    const markdown = '![diagram](data:image/png;base64,iVBORw0KGgo...) and [external](javascript:alert(1))'

    // Allow data: URIs only on images, block everywhere else
    function customSanitizeUrl(url, { type, tag }) {
        if (tag === 'img' && url.startsWith('data:image/')) return url
        return defaultSanitizeUrl(url, { type, tag })
    }
</script>

<SvelteMarkdown source={markdown} sanitizeUrl={customSanitizeUrl} />
<script>
    import SvelteMarkdown, { defaultSanitizeUrl } from '@humanspeak/svelte-markdown'

    const markdown = '![diagram](data:image/png;base64,iVBORw0KGgo...) and [external](javascript:alert(1))'

    // Allow data: URIs only on images, block everywhere else
    function customSanitizeUrl(url, { type, tag }) {
        if (tag === 'img' && url.startsWith('data:image/')) return url
        return defaultSanitizeUrl(url, { type, tag })
    }
</script>

<SvelteMarkdown source={markdown} sanitizeUrl={customSanitizeUrl} />

Custom Attribute Sanitization

Override sanitizeAttributes to apply per-tag attribute rules:

<script>
    import SvelteMarkdown, {
        defaultSanitizeAttributes,
        defaultSanitizeUrl
    } from '@humanspeak/svelte-markdown'

    const markdown = '<iframe src="https://example.com" onload="alert(1)"></iframe>'

    // Block all attributes on iframes, use defaults for everything else
    function customSanitizeAttributes(attributes, context, sanitizeUrl) {
        if (context.tag === 'iframe') return {}
        return defaultSanitizeAttributes(attributes, context, sanitizeUrl)
    }
</script>

<SvelteMarkdown
    source={markdown}
    sanitizeAttributes={customSanitizeAttributes}
/>
<script>
    import SvelteMarkdown, {
        defaultSanitizeAttributes,
        defaultSanitizeUrl
    } from '@humanspeak/svelte-markdown'

    const markdown = '<iframe src="https://example.com" onload="alert(1)"></iframe>'

    // Block all attributes on iframes, use defaults for everything else
    function customSanitizeAttributes(attributes, context, sanitizeUrl) {
        if (context.tag === 'iframe') return {}
        return defaultSanitizeAttributes(attributes, context, sanitizeUrl)
    }
</script>

<SvelteMarkdown
    source={markdown}
    sanitizeAttributes={customSanitizeAttributes}
/>

Disabling Sanitization

If you fully trust your markdown source and need to allow all URLs and attributes, use the passthrough functions:

<script>
    import SvelteMarkdown, {
        unsanitizedUrl,
        unsanitizedAttributes
    } from '@humanspeak/svelte-markdown'

    // Only use passthrough sanitizers when the source is fully trusted
    // (e.g. your own CMS, never user input).
    const trustedContent = '# Internal doc\n\n<iframe src="/internal/dashboard"></iframe>'
</script>

<SvelteMarkdown
    source={trustedContent}
    sanitizeUrl={unsanitizedUrl}
    sanitizeAttributes={unsanitizedAttributes}
/>
<script>
    import SvelteMarkdown, {
        unsanitizedUrl,
        unsanitizedAttributes
    } from '@humanspeak/svelte-markdown'

    // Only use passthrough sanitizers when the source is fully trusted
    // (e.g. your own CMS, never user input).
    const trustedContent = '# Internal doc\n\n<iframe src="/internal/dashboard"></iframe>'
</script>

<SvelteMarkdown
    source={trustedContent}
    sanitizeUrl={unsanitizedUrl}
    sanitizeAttributes={unsanitizedAttributes}
/>

Warning: Only disable sanitization for content you fully control. Never use passthrough functions with user-generated input.

Sanitization API Reference

ExportTypeDescription
defaultSanitizeUrlSanitizeUrlFnProtocol allowlist (http, https, mailto, tel, relative)
defaultSanitizeAttributesSanitizeAttributesFnStrips on* handlers, validates URL attributes
unsanitizedUrlSanitizeUrlFnPassthrough — allows all URLs
unsanitizedAttributesSanitizeAttributesFnPassthrough — allows all attributes
SanitizeUrlFnType(url: string, context: SanitizeContext) => string
SanitizeAttributesFnType(attributes: Record<string, string>, context: SanitizeContext, sanitizeUrl: SanitizeUrlFn) => Record<string, string>
SanitizeContextType{ type: string, tag: string }

Known Gaps (Not Handled by Defaults)

The built-in sanitizer covers URL protocols and event handlers, but some classes of risk pass through and need either custom sanitizers or HTML tag filtering:

  • Inline style="..." attributes are not sanitized. They pass through unchanged (only on* and srcdoc are stripped). Modern browsers do not execute JavaScript via CSS, but visual hijacking (display: none) and exfiltration via background-image URLs are possible.
  • <iframe>, <form>, and <embed> are rendered by default. With on* and srcdoc stripped and src/action protocol-restricted, the worst exploits are blocked, but an iframe to an arbitrary http(s) URL is still possible. Use excludeHtmlOnly(['iframe', 'form', 'embed']) to remove them.
  • srcset and other less common URL attributes are not sanitized. Only href, src, action, formaction, cite, data, and poster flow through sanitizeUrl. Provide a custom sanitizeAttributes if you need broader coverage.
  • No bundled DOM sanitizer. By design, the package does not ship DOMPurify or equivalent. For fully untrusted input, layer one on top of the defaults below.

For trusted sources like your own AI agent output, the built-in defaults are typically sufficient. For arbitrary user-generated markdown, combine the defaults with HTML tag filtering and/or external sanitization.

Blocking HTML in Markdown

For additional defense, you can block HTML tags entirely using the allow/deny filtering utilities. This is complementary to URL/attribute sanitization.

Block All HTML

<script>
    import SvelteMarkdown, { buildUnsupportedHTML } from '@humanspeak/svelte-markdown'

    let userInput = $state('# User content\n\n<script>alert(1)</' + 'script>')

    const renderers = {
        html: buildUnsupportedHTML()
    }
</script>

<SvelteMarkdown source={userInput} {renderers} />
<script>
    import SvelteMarkdown, { buildUnsupportedHTML } from '@humanspeak/svelte-markdown'

    let userInput = $state('# User content\n\n<script>alert(1)</' + 'script>')

    const renderers = {
        html: buildUnsupportedHTML()
    }
</script>

<SvelteMarkdown source={userInput} {renderers} />

This replaces every HTML tag renderer with UnsupportedHTML, which renders nothing. Markdown syntax (headings, lists, emphasis, etc.) still works normally.

Allow Only Safe Tags

<script>
    import SvelteMarkdown, { allowHtmlOnly } from '@humanspeak/svelte-markdown'

    let userInput = $state('Hello <strong>world</strong> <iframe src="https://example.com"></iframe>')

    const safeHtml = allowHtmlOnly([
        'strong', 'em', 'a', 'code', 'pre',
        'ul', 'ol', 'li', 'p', 'br', 'hr',
        'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
        'blockquote', 'table', 'thead', 'tbody',
        'tr', 'th', 'td'
    ])
</script>

<SvelteMarkdown source={userInput} renderers={{ html: safeHtml }} />
<script>
    import SvelteMarkdown, { allowHtmlOnly } from '@humanspeak/svelte-markdown'

    let userInput = $state('Hello <strong>world</strong> <iframe src="https://example.com"></iframe>')

    const safeHtml = allowHtmlOnly([
        'strong', 'em', 'a', 'code', 'pre',
        'ul', 'ol', 'li', 'p', 'br', 'hr',
        'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
        'blockquote', 'table', 'thead', 'tbody',
        'tr', 'th', 'td'
    ])
</script>

<SvelteMarkdown source={userInput} renderers={{ html: safeHtml }} />

Block Specific Dangerous Tags

<script>
    import SvelteMarkdown, { excludeHtmlOnly } from '@humanspeak/svelte-markdown'

    let userInput = $state('Click <a href="https://example.com">here</a> <iframe src="x"></iframe>')

    const filteredHtml = excludeHtmlOnly([
        'iframe', 'embed', 'form', 'input',
        'textarea', 'select', 'button',
        'canvas', 'dialog'
    ])
</script>

<SvelteMarkdown source={userInput} renderers={{ html: filteredHtml }} />
<script>
    import SvelteMarkdown, { excludeHtmlOnly } from '@humanspeak/svelte-markdown'

    let userInput = $state('Click <a href="https://example.com">here</a> <iframe src="x"></iframe>')

    const filteredHtml = excludeHtmlOnly([
        'iframe', 'embed', 'form', 'input',
        'textarea', 'select', 'button',
        'canvas', 'dialog'
    ])
</script>

<SvelteMarkdown source={userInput} renderers={{ html: filteredHtml }} />

Restricting Markdown Elements

You can also restrict which markdown elements are rendered:

<script>
    import SvelteMarkdown, {
        allowRenderersOnly,
        buildUnsupportedHTML
    } from '@humanspeak/svelte-markdown'

    let userInput = $state('# Heading\n\n**Bold** and *italic* with a [link](https://example.com).')

    // Only allow basic text formatting
    const renderers = {
        ...allowRenderersOnly([
            'paragraph', 'text', 'strong', 'em',
            'link', 'list', 'listitem', 'br'
        ]),
        html: buildUnsupportedHTML()
    }
</script>

<SvelteMarkdown source={userInput} {renderers} />
<script>
    import SvelteMarkdown, {
        allowRenderersOnly,
        buildUnsupportedHTML
    } from '@humanspeak/svelte-markdown'

    let userInput = $state('# Heading\n\n**Bold** and *italic* with a [link](https://example.com).')

    // Only allow basic text formatting
    const renderers = {
        ...allowRenderersOnly([
            'paragraph', 'text', 'strong', 'em',
            'link', 'list', 'listitem', 'br'
        ]),
        html: buildUnsupportedHTML()
    }
</script>

<SvelteMarkdown source={userInput} {renderers} />

External Sanitization

For applications that require additional protection, you can sanitize the markdown source before passing it to SvelteMarkdown. Note that input-level sanitizers like DOMPurify operate on HTML, not Markdown syntax — they will not catch Markdown-native injection vectors like [Click](javascript:alert('XSS')). The built-in URL sanitization handles this automatically.

Using DOMPurify (Combined with Built-in Sanitization)

<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import DOMPurify from 'dompurify'

    let rawInput = $state('')

    // DOMPurify handles HTML-level threats
    // Built-in sanitization handles Markdown-native threats
    const sanitized = $derived(
        DOMPurify.sanitize(rawInput, { ALLOWED_TAGS: [] })
    )
</script>

<SvelteMarkdown source={sanitized} />
<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'
    import DOMPurify from 'dompurify'

    let rawInput = $state('')

    // DOMPurify handles HTML-level threats
    // Built-in sanitization handles Markdown-native threats
    const sanitized = $derived(
        DOMPurify.sanitize(rawInput, { ALLOWED_TAGS: [] })
    )
</script>

<SvelteMarkdown source={sanitized} />

Using the parsed Callback

You can inspect and filter tokens after parsing:

<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'

    let userInput = $state('Plain markdown is safe, but <div>raw HTML</div> may not be.')

    function handleParsed(tokens) {
        // Inspect tokens for suspicious content
        const htmlTokens = tokens.filter(t => t.type === 'html')
        if (htmlTokens.length > 0) {
            console.warn('HTML detected in markdown input:', htmlTokens)
        }
    }
</script>

<SvelteMarkdown source={userInput} parsed={handleParsed} />
<script>
    import SvelteMarkdown from '@humanspeak/svelte-markdown'

    let userInput = $state('Plain markdown is safe, but <div>raw HTML</div> may not be.')

    function handleParsed(tokens) {
        // Inspect tokens for suspicious content
        const htmlTokens = tokens.filter(t => t.type === 'html')
        if (htmlTokens.length > 0) {
            console.warn('HTML detected in markdown input:', htmlTokens)
        }
    }
</script>

<SvelteMarkdown source={userInput} parsed={handleParsed} />

Security Recommendations

For user-generated content:

  1. Use the built-in sanitization (enabled by default). This blocks dangerous URL protocols and event handler attributes.
  2. Block all HTML using buildUnsupportedHTML() for the strictest protection.
  3. If you need some HTML, use allowHtmlOnly() with a strict allowlist of safe tags.
  4. Consider combining with DOMPurify for defense-in-depth.

For trusted content (CMS, documentation, etc.):

  1. The default sanitization is generally sufficient for trusted sources.
  2. Use excludeHtmlOnly() to block specific tags you do not want (e.g., iframe, form).

General best practices:

  1. Validate input on the server. Client-side filtering adds defense-in-depth but should not be your only protection.
  2. Use Content Security Policy (CSP) headers. Restrict inline scripts and other dangerous behaviors at the browser level.
  3. Keep dependencies updated. Regularly update @humanspeak/svelte-markdown, Marked, and HTMLParser2 to get security fixes.
  4. Audit your custom renderers. If you create custom renderers, make sure they do not use {@html} with untrusted content.

What About <script> and <style> Tags?

Neither tag has a renderer in the HTML map. Both fall through to UnsupportedHTML, which renders them as visible escaped text (for example, <script>...</script>) rather than executing or applying them. HTMLParser2 processes HTML structurally and does not execute scripts on its own.

This means dangerous content is visible but inert. For untrusted input you should still block or sanitize HTML — escaped script text in a document is rarely the user experience you want.

Related