logo

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'

    // 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'

    // 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'

    // 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'

    // 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'
</script>

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

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'

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

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

    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'

    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'

    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'

    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'

    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'

    // 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'

    // 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'

    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'

    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> Tags?

Marked does not produce <script> tokens by default, and HTMLParser2 processes HTML structurally rather than executing it. Additionally, the HTML renderer map does not include a script component, so <script> tags in markdown are not rendered.

However, for defense-in-depth, you should still block or sanitize HTML when processing untrusted input.

Related