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:andhttps:mailto:andtel:- 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:
- Strips all event handler attributes (
onclick,onerror,onload, etc.) - 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 = ' 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 = ' 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
| Export | Type | Description |
|---|---|---|
defaultSanitizeUrl | SanitizeUrlFn | Protocol allowlist (http, https, mailto, tel, relative) |
defaultSanitizeAttributes | SanitizeAttributesFn | Strips on* handlers, validates URL attributes |
unsanitizedUrl | SanitizeUrlFn | Passthrough — allows all URLs |
unsanitizedAttributes | SanitizeAttributesFn | Passthrough — allows all attributes |
SanitizeUrlFn | Type | (url: string, context: SanitizeContext) => string |
SanitizeAttributesFn | Type | (attributes: Record<string, string>, context: SanitizeContext, sanitizeUrl: SanitizeUrlFn) => Record<string, string> |
SanitizeContext | Type | { 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 (onlyon*andsrcdocare 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. Withon*andsrcdocstripped andsrc/actionprotocol-restricted, the worst exploits are blocked, but an iframe to an arbitraryhttp(s)URL is still possible. UseexcludeHtmlOnly(['iframe', 'form', 'embed'])to remove them.srcsetand other less common URL attributes are not sanitized. Onlyhref,src,action,formaction,cite,data, andposterflow throughsanitizeUrl. Provide a customsanitizeAttributesif 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:
- Use the built-in sanitization (enabled by default). This blocks dangerous URL protocols and event handler attributes.
- Block all HTML using
buildUnsupportedHTML()for the strictest protection. - If you need some HTML, use
allowHtmlOnly()with a strict allowlist of safe tags. - Consider combining with DOMPurify for defense-in-depth.
For trusted content (CMS, documentation, etc.):
- The default sanitization is generally sufficient for trusted sources.
- Use
excludeHtmlOnly()to block specific tags you do not want (e.g.,iframe,form).
General best practices:
- Validate input on the server. Client-side filtering adds defense-in-depth but should not be your only protection.
- Use Content Security Policy (CSP) headers. Restrict inline scripts and other dangerous behaviors at the browser level.
- Keep dependencies updated. Regularly update
@humanspeak/svelte-markdown, Marked, and HTMLParser2 to get security fixes. - 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
- Allow/Deny Filtering — detailed filtering function documentation
- HTML Renderers — all HTML tag renderers
- HTML Filtering Examples — practical filtering examples