Skip to content

Commit ff46886

Browse files
authored
security: ignore round-trip raw attributes on untrusted HTML import; bound abbreviation expansion (#254)
Ports two confirmed hardening fixes from the downstream carve-php fork, on top of the prior always-on attribute/URL hardening baseline. HtmlToDjot round-trip injection: - The reverse converter re-emitted the data-djot-src (block) and data-djot-raw (inline) round-trip attributes verbatim as raw Djot, so untrusted HTML could smuggle a raw-HTML block and inject live markup (e.g. a script element) into the converted output. Both attributes are now ignored by default; honoring them is opt-in via new HtmlToDjot(trustedRoundTrip: true) and propagated through the recursive inline sub-conversion. The internal Tabs round-trip generators, which consume djot's own rendered HTML, use the trusted converter so faithful round-trips are preserved. Abbreviation output-amplification DoS: - Each abbreviation occurrence re-emitted its full definition, so a tiny source with a huge definition and many occurrences expanded to definition_len times occurrence_count bytes (a RAM-exhaustion DoS). A per-render byte budget, max(1MB, 8 * sourceLength), now bounds cumulative expansion across the HTML, Markdown and ANSI renderers; once the budget is exceeded an occurrence degrades to plain key text. Document carries the source length, set in DjotConverter::parse(). Adds HtmlToDjotRoundTripSecurityTest and AbbreviationBudgetTest; existing round-trip-fidelity tests use the trusted converter explicitly.
1 parent f066e5f commit ff46886

12 files changed

Lines changed: 387 additions & 33 deletions

src/Converter/HtmlToDjot.php

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,30 @@
2323
* Key Djot requirements handled:
2424
* - Blank lines required around block elements (headings, code blocks, lists)
2525
* - Nested lists require blank line before the nested portion
26+
*
27+
* SECURITY: this converter is NOT a sanitizer. Its output is Djot markup that
28+
* may still contain content derived from the input; render untrusted input with
29+
* safe mode enabled on the downstream renderer. By default the converter IGNORES
30+
* the `data-djot-src` / `data-djot-raw` round-trip attributes on the input (they
31+
* would otherwise be re-emitted verbatim as raw Djot, letting a crafted attribute
32+
* smuggle a raw-HTML block -> live <script> into the output). Only enable
33+
* round-trip extraction via the constructor flag when the HTML is TRUSTED (e.g.
34+
* produced by djot itself for a faithful round-trip).
2635
*/
2736
class HtmlToDjot
2837
{
38+
/**
39+
* When true, trust and re-emit the `data-djot-src` / `data-djot-raw`
40+
* round-trip attributes on the input. Default false: untrusted HTML must not
41+
* be able to smuggle raw Djot (incl. a raw-HTML block) through them.
42+
*/
43+
protected bool $trustedRoundTrip = false;
44+
45+
public function __construct(bool $trustedRoundTrip = false)
46+
{
47+
$this->trustedRoundTrip = $trustedRoundTrip;
48+
}
49+
2950
protected int $listDepth = 0;
3051

3152
protected bool $inPre = false;
@@ -948,6 +969,14 @@ protected function processPreBlock(DOMElement $node): string
948969

949970
protected function extractRoundTripSource(DOMElement $node, string $tagName): ?string
950971
{
972+
// Untrusted HTML must not be able to smuggle raw Djot through a
973+
// `data-djot-src` attribute (it is emitted verbatim, so a crafted value
974+
// could inject a raw-HTML block -> live <script>). Only honor it when the
975+
// caller explicitly trusts the source.
976+
if (!$this->trustedRoundTrip) {
977+
return null;
978+
}
979+
951980
if (!$node->hasAttribute('data-djot-src')) {
952981
return null;
953982
}
@@ -1852,8 +1881,12 @@ protected function processSpan(DOMElement $node): string
18521881
return '\\' . $node->textContent;
18531882
}
18541883

1855-
// Check for raw inline content (round-trip support)
1856-
if ($node->hasAttribute('data-djot-raw')) {
1884+
// Check for raw inline content (round-trip support). Only honor it for
1885+
// trusted input: `data-djot-raw` is re-emitted verbatim as a raw inline
1886+
// span (`` `...`{=html} ``), so a crafted attribute on untrusted HTML
1887+
// could smuggle live markup. Untrusted input falls through to ordinary
1888+
// span processing (getElementAttributes drops the data-djot-* attribute).
1889+
if ($this->trustedRoundTrip && $node->hasAttribute('data-djot-raw')) {
18571890
return $this->processRawInline($node);
18581891
}
18591892

@@ -2224,7 +2257,10 @@ protected function formatCaptionText(string $captionText): string
22242257

22252258
protected function convertInlineFragmentToDjot(string $html): string
22262259
{
2227-
$converter = new self();
2260+
// Propagate the trust setting so a trusted round-trip parent keeps
2261+
// honoring inner round-trip attributes in the recursive sub-conversion
2262+
// (and an untrusted parent keeps ignoring them).
2263+
$converter = new self($this->trustedRoundTrip);
22282264
$converter->preserveTextWhitespace = true;
22292265

22302266
$doc = new DOMDocument();

src/DjotConverter.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,10 @@ public function parse(string $djot): Document
417417

418418
$document = $this->parser->parse($djot);
419419

420+
// Record the source size so render-time DoS budgets (abbreviation
421+
// expansion) can scale with the input rather than a fixed cap.
422+
$document->setSourceLength(strlen($djot));
423+
420424
foreach ($this->extensions as $extension) {
421425
if ($extension instanceof ParsedDocumentExtensionInterface) {
422426
$extension->afterParse($document);

src/Extension/TabsExtension.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,7 @@ protected function reconstructChildToDjot(Node $child, HtmlRenderer $renderer):
582582
$child instanceof DefinitionList => $this->renderDefinitionListToDjot($child, $renderer),
583583
$child instanceof Div => $this->renderDivToDjot($child, $renderer),
584584
$child instanceof Table => $this->renderTableToDjot($child, $renderer),
585-
default => rtrim((new HtmlToDjot())->convert($renderer->renderNodeFragment($child)), "\n"),
585+
default => rtrim((new HtmlToDjot(trustedRoundTrip: true))->convert($renderer->renderNodeFragment($child)), "\n"),
586586
};
587587
}
588588

@@ -592,9 +592,9 @@ protected function renderDefinitionListToDjot(DefinitionList $list, HtmlRenderer
592592

593593
foreach ($list->getChildren() as $child) {
594594
if ($child instanceof DefinitionTerm) {
595-
$lines[] = rtrim((new HtmlToDjot())->convert($renderer->renderNodeFragment($child)), "\n");
595+
$lines[] = rtrim((new HtmlToDjot(trustedRoundTrip: true))->convert($renderer->renderNodeFragment($child)), "\n");
596596
} elseif ($child instanceof DefinitionDescription) {
597-
$content = rtrim((new HtmlToDjot())->convert($renderer->renderNodeFragment($child)), "\n");
597+
$content = rtrim((new HtmlToDjot(trustedRoundTrip: true))->convert($renderer->renderNodeFragment($child)), "\n");
598598
$lines[] = ': ' . $content;
599599
}
600600
}
@@ -655,7 +655,7 @@ protected function renderTableToDjot(Table $table, HtmlRenderer $renderer): stri
655655
continue;
656656
}
657657

658-
$cells[] = rtrim((new HtmlToDjot())->convert($renderer->renderNodeFragment($cell)), "\n");
658+
$cells[] = rtrim((new HtmlToDjot(trustedRoundTrip: true))->convert($renderer->renderNodeFragment($cell)), "\n");
659659
if ($row->isHeader() || !isset($alignments[$cellIndex])) {
660660
$alignments[$cellIndex] = $cell->getAlignment();
661661
}

src/Node/Document.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,35 @@ class Document extends Node
1616
*/
1717
protected array $abbreviations = [];
1818

19+
/**
20+
* Byte length of the original source the document was parsed from.
21+
*
22+
* Used to scale render-time DoS budgets (e.g. abbreviation expansion).
23+
* Defaults to 0 for documents built directly rather than parsed.
24+
*/
25+
protected int $sourceLength = 0;
26+
1927
public function getType(): string
2028
{
2129
return 'document';
2230
}
2331

32+
/**
33+
* Get the byte length of the original parsed source.
34+
*/
35+
public function getSourceLength(): int
36+
{
37+
return $this->sourceLength;
38+
}
39+
40+
/**
41+
* Set the byte length of the original parsed source.
42+
*/
43+
public function setSourceLength(int $sourceLength): void
44+
{
45+
$this->sourceLength = $sourceLength;
46+
}
47+
2448
/**
2549
* Get abbreviation definitions
2650
*

src/Renderer/AnsiRenderer.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
use Djot\Node\Inline\Symbol;
4848
use Djot\Node\Inline\Text;
4949
use Djot\Node\Node;
50+
use Djot\Renderer\Utility\AbbreviationBudgetTrait;
5051

5152
/**
5253
* Renders AST to ANSI-formatted terminal output
@@ -56,6 +57,8 @@
5657
*/
5758
class AnsiRenderer implements RendererInterface
5859
{
60+
use AbbreviationBudgetTrait;
61+
5962
// ANSI escape codes
6063
/**
6164
* @var string
@@ -367,6 +370,8 @@ public function getSoftBreakMode(): SoftBreakMode
367370

368371
public function render(Document $document): string
369372
{
373+
$this->resetAbbreviationBudget($document->getSourceLength());
374+
370375
$output = $this->renderChildren($document);
371376

372377
// Normalize multiple blank lines
@@ -921,6 +926,13 @@ protected function renderCaption(Caption $node): string
921926
protected function renderAbbreviation(Abbreviation $node): string
922927
{
923928
$text = $this->renderChildren($node);
929+
930+
// DoS guard: once the cumulative expansion bytes would exceed the
931+
// budget, degrade to plain key text (no parenthesized definition).
932+
if (!$this->chargeAbbreviationExpansion($node->getTitle())) {
933+
return $text;
934+
}
935+
924936
$title = $node->getTitle();
925937

926938
// Show abbreviation with definition in parentheses

src/Renderer/HtmlRenderer.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
use Djot\Node\Inline\Symbol;
4949
use Djot\Node\Inline\Text;
5050
use Djot\Node\Node;
51+
use Djot\Renderer\Utility\AbbreviationBudgetTrait;
5152
use Djot\Renderer\Utility\EventDispatcherTrait;
5253
use Djot\SafeMode;
5354
use Djot\Util\StringUtil;
@@ -57,6 +58,7 @@
5758
*/
5859
class HtmlRenderer implements RendererInterface
5960
{
61+
use AbbreviationBudgetTrait;
6062
use EventDispatcherTrait;
6163

6264
protected SoftBreakMode $softBreakMode = SoftBreakMode::Newline;
@@ -304,6 +306,7 @@ public function render(Document $document): string
304306
$this->sharedRenderContext,
305307
function () use ($document): string {
306308
$this->sharedRenderContext->reset();
309+
$this->resetAbbreviationBudget($document->getSourceLength());
307310

308311
$html = $this->renderDocumentWithSections($document);
309312

@@ -1060,9 +1063,16 @@ protected function renderDelete(Delete $node): string
10601063

10611064
protected function renderAbbreviation(Abbreviation $node): string
10621065
{
1063-
$attrs = $this->renderAttributes($node);
10641066
$title = $node->getTitle();
10651067

1068+
// DoS guard: once the cumulative expansion bytes would exceed the
1069+
// budget, degrade to plain key text (no <abbr> wrapper, no title).
1070+
if (!$this->chargeAbbreviationExpansion($title)) {
1071+
return $this->renderChildren($node);
1072+
}
1073+
1074+
$attrs = $this->renderAttributes($node);
1075+
10661076
return '<abbr title="' . $this->escapeAttribute($title) . '"' . $attrs . '>'
10671077
. $this->renderChildren($node) . '</abbr>';
10681078
}

src/Renderer/MarkdownRenderer.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
use Djot\Node\Inline\Symbol;
4848
use Djot\Node\Inline\Text;
4949
use Djot\Node\Node;
50+
use Djot\Renderer\Utility\AbbreviationBudgetTrait;
5051
use Djot\Renderer\Utility\EventDispatcherTrait;
5152
use Djot\Util\StringUtil;
5253

@@ -66,6 +67,7 @@
6667
*/
6768
class MarkdownRenderer implements RendererInterface
6869
{
70+
use AbbreviationBudgetTrait;
6971
use EventDispatcherTrait;
7072

7173
protected int $listDepth = 0;
@@ -96,6 +98,8 @@ public function getSoftBreakMode(): SoftBreakMode
9698

9799
public function render(Document $document): string
98100
{
101+
$this->resetAbbreviationBudget($document->getSourceLength());
102+
99103
$markdown = $this->renderChildren($document);
100104

101105
// Normalize multiple blank lines
@@ -528,12 +532,21 @@ protected function renderCaption(Caption $node): string
528532
*/
529533
protected function renderAbbreviation(Abbreviation $node): string
530534
{
535+
// DoS guard: once the cumulative expansion bytes would exceed the
536+
// budget, degrade to plain key text (no <abbr> wrapper, no title).
537+
// Return the children as ordinary Markdown text - NOT the HTML-escaped
538+
// form below, which is only correct inside the raw <abbr> element (a
539+
// bare `&`/`<` in the key must stay literal in Markdown output).
540+
if (!$this->chargeAbbreviationExpansion($node->getTitle())) {
541+
return $this->renderChildren($node);
542+
}
543+
531544
// The whole element is raw inline HTML, so both the title (attribute)
532545
// and the text (element content) need HTML escaping, NOT Markdown text
533546
// escaping: a `"` in the title or a `<` in the text would otherwise
534547
// break the tag / be misparsed as markup downstream.
535-
$title = htmlspecialchars($node->getTitle(), ENT_QUOTES, 'UTF-8');
536548
$text = htmlspecialchars($this->renderChildren($node), ENT_QUOTES, 'UTF-8');
549+
$title = htmlspecialchars($node->getTitle(), ENT_QUOTES, 'UTF-8');
537550

538551
return '<abbr title="' . $title . '">' . $text . '</abbr>';
539552
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Djot\Renderer\Utility;
6+
7+
/**
8+
* Bounds the cumulative bytes contributed by abbreviation expansion across a
9+
* single render, guarding against an output-amplification (memory) DoS.
10+
*
11+
* Each occurrence of an abbreviation re-emits its full definition (the `title`
12+
* of an `<abbr>` element, the `(definition)` suffix in ANSI, etc.). A tiny
13+
* source such as `*[HT]: <50KB of text>` followed by many `HT` occurrences
14+
* would otherwise expand to `definition_len * occurrence_count` bytes
15+
* (hundreds of MB), which PHP happily allocates - a true RAM-exhaustion DoS.
16+
*
17+
* Policy:
18+
* budget = max(BUDGET_BASE, BUDGET_FACTOR * sourceByteLength)
19+
* Once the next occurrence's expansion would exceed the budget, that occurrence
20+
* (and every subsequent one) degrades gracefully to its plain key text only -
21+
* no `<abbr>` wrapper, no title. The budget sits far above any real document,
22+
* so normal output is byte-identical.
23+
*
24+
* The counter is reset per full render call (resetAbbreviationBudget()).
25+
*/
26+
trait AbbreviationBudgetTrait
27+
{
28+
/**
29+
* Base (floor) budget in bytes, applied even for tiny sources.
30+
*
31+
* @var int
32+
*/
33+
protected const ABBREVIATION_BUDGET_BASE = 1000000;
34+
35+
/**
36+
* Multiplier applied to the source byte length.
37+
*
38+
* @var int
39+
*/
40+
protected const ABBREVIATION_BUDGET_FACTOR = 8;
41+
42+
/**
43+
* Cumulative expansion bytes already emitted in the current render.
44+
*/
45+
protected int $abbreviationExpansionBytes = 0;
46+
47+
/**
48+
* Computed budget for the current render (max of base and factor*source).
49+
*/
50+
protected int $abbreviationBudget = self::ABBREVIATION_BUDGET_BASE;
51+
52+
/**
53+
* Reset the budget counter and (re)compute the budget for a fresh render.
54+
*/
55+
protected function resetAbbreviationBudget(int $sourceLength): void
56+
{
57+
$this->abbreviationExpansionBytes = 0;
58+
$this->abbreviationBudget = max(
59+
self::ABBREVIATION_BUDGET_BASE,
60+
self::ABBREVIATION_BUDGET_FACTOR * $sourceLength,
61+
);
62+
}
63+
64+
/**
65+
* Charge a single abbreviation occurrence against the budget.
66+
*
67+
* @param string $expansion The definition text whose bytes are emitted.
68+
*
69+
* @return bool True if the expansion fits within budget and may be emitted
70+
* (the bytes are charged); false if it would exceed the budget and the
71+
* occurrence must degrade to plain key text.
72+
*/
73+
protected function chargeAbbreviationExpansion(string $expansion): bool
74+
{
75+
$cost = strlen($expansion);
76+
if ($this->abbreviationExpansionBytes + $cost > $this->abbreviationBudget) {
77+
return false;
78+
}
79+
80+
$this->abbreviationExpansionBytes += $cost;
81+
82+
return true;
83+
}
84+
}

0 commit comments

Comments
 (0)