Rendering untrusted input

August 18, 2026 ยท View on GitHub

Carve source from anyone who is not you - comments, form fields, imported documents, LLM output - needs configuring before it reaches a browser. This page is the whole story in one place.

The default

new CarveConverter() has no safe mode and no profile. Raw passthrough renders verbatim, every construct is allowed, and there is no length cap. That is the right default for content you author yourself and the wrong one for anything else - nothing below happens unless you ask for it.

Entry pointSafe modeProfile
new CarveConverter()offnone
new CarveConverter(safeMode: true)SafeMode::defaults()none
markup-carve/laravel-carve, shipped default profileonnone

The short version

use MarkupCarve\Carve\CarveConverter;
use MarkupCarve\Carve\Profile;
use MarkupCarve\Carve\SafeMode;

$converter = new CarveConverter(safeMode: SafeMode::strict());
$converter->setProfile(Profile::comment());

$html = $converter->convert($userInput);

foreach ($converter->getProfileViolations() as $violation) {
    // Optional: tell the author what was dropped and why.
    $messages[] = $violation->getMessage();
}

Two independent layers, and you usually want both:

  • SafeMode decides what happens to raw HTML, dangerous URL schemes and event-handler attributes. It is about output safety.
  • Profile decides which Carve constructs are allowed at all, with a size cap. It is about appropriateness - a comment field has no business containing headings, tables or footnotes.

On Laravel, markup-carve/laravel-carve already ships safe_mode => true as the default (and only) shipped converter profile, so the first layer is on there unless you define a profile of your own with safe_mode => false or reach for the raw Blade directive. Using carve-php directly, neither layer is on until you set it.

What the default does not protect against

Raw passthrough renders verbatim, and that includes event handlers:

```=html
<b onclick="steal()">x</b>
```
Inputno safe modesafeMode: trueSafeMode::strict()
raw block ```=htmlrendered liveescapedremoved
inline raw `<b>x</b>`{=html}rendered liveescapedremoved

Escaped text in the source (<script> typed as prose) is escaped in every mode, and a javascript: link destination is emptied in every mode - those two are handled without opting in. Raw passthrough is not. If you take input from anywhere untrusted and do not set a safe mode, you have an XSS hole.

Resource limits

Pathologically nested input is bounded rather than allowed to exhaust the host, and both bounds DEGRADE rather than refuse - the document still parses.

  • Container nesting in the document stops at BlockParser::MAX_NESTING_DEPTH (200). Past it an opener becomes ordinary paragraph text.
  • One line's container prefix is measured against the same cap before the parser descends it. This is a different axis: a line may spell far more markers than the document will ever open containers for, so an alternating > - repeated thousands of times used to spend a call frame per pair and overflow the stack (markup-carve/carve-php#1456). Past the cap the rest of the prefix degrades to paragraph text, which is what carve-js and carve-rs already produced for the same line.

Neither bound is configurable, and neither raises. If you need a hard ceiling on what you accept, set setMaxLength() on a Profile and reject oversized input before parsing.

SafeMode

SafeMode::defaults();  // escape raw HTML, block dangerous schemes and on* attributes
SafeMode::strict();    // strip raw HTML entirely, and also block the style attribute

What a SafeMode instance enforces once you attach one. These are the values inside SafeMode::defaults(), not what the converter does without a safe mode - without one, none of these rules apply at all:

SettingValue in defaults()
raw HTML modeRAW_HTML_ESCAPE (strict(): RAW_HTML_STRIP, or RAW_HTML_ALLOW)
dangerous schemesjavascript, vbscript, data, file
allowed schemesnull, meaning everything not listed as dangerous
blocked attribute prefixeson - covers onclick, onload, every handler
blocked attributessrcdoc, formaction (strict() adds style)
$safe = SafeMode::defaults()
    ->setAllowedSchemes(['https', 'mailto'])   // allowlist instead of denylist
    ->addDangerousScheme('tel');

setSafeMode() only takes effect on an HTML renderer. Passing safeMode to the constructor is the reliable form.

Profile

A profile allows or denies node types, caps document length, and carries a human-readable reason per denied feature. Four presets:

PresetFor
Profile::full()trusted authors, everything on
Profile::article()CMS-style content
Profile::comment()user comments - no headings, tables, footnotes; length capped
Profile::minimal()single-line fields, tightest cap

Denied constructs degrade rather than disappear - a denied heading renders as its own text - and each one is reported:

$converter->setProfile(Profile::comment());
$converter->convert("# Heading\n\ntext");

$converter->getProfileViolations()[0]->getMessage();
// "'heading' is not allowed: element_not_allowed (Headings are disabled in
//  comments to prevent disrupting page structure.)"

Those messages are written to be shown to the author. Build your own:

$profile = Profile::comment()
    ->denyInline(['image'])
    ->allowBlock(['paragraph', 'list', 'blockquote'])
    ->setLinkPolicy($policy);

LinkPolicy

Attach to a profile to control destinations:

use MarkupCarve\Carve\LinkPolicy;

$policy = (new LinkPolicy())
    ->setAllowedSchemes(['https', 'mailto'])
    ->setDeniedDomains(['spam.example'])
    ->setAllowExternal(true)
    ->addRelAttribute('nofollow');       // plus noopener/noreferrer as configured

$profile->setLinkPolicy($policy);

isUrlAllowed($url, $baseHost) is the same check the renderer runs, so you can reuse it for validation before storing.

Images and SVG

SvgSanitizer with SvgSanitizeOptions neutralizes SVG payloads (scripts, external references) for cases where images may be user-supplied. Combine with a SafeMode that keeps data: in the dangerous-scheme list, which it is by default.

Validating before storing

Two cheaper-than-rendering checks:

$converter->convert($source);
$converter->getWarnings();            // parse-level complaints
$converter->getProfileViolations();   // what the profile rejected

Rejecting input at submit time with the violation messages attached beats silently dropping constructs at render time - the author learns the rule once instead of wondering where their table went.

Layering with an HTML sanitizer

Safe mode plus a profile is enough for Carve's own output. If the same pipeline also renders HTML from other sources, keep your existing sanitizer - the two are not redundant: Carve controls what it emits, a sanitizer controls what survives regardless of origin.

Checklist

  • safeMode set (constructor, or safe_mode in the Laravel config) for any input you did not author
  • SafeMode::strict() where raw HTML has no legitimate use
  • a Profile matching the field's purpose, not full() by default
  • a LinkPolicy if destinations matter
  • violations surfaced to the author rather than swallowed