Skip to content

Commit 8774dca

Browse files
dereuromarkclaude
andauthored
Add SafeMode for XSS protection (#3)
- Add SafeMode configuration class with URL sanitization, attribute filtering, and raw HTML handling modes - Block dangerous URL schemes (javascript:, vbscript:, data:, file:) - Filter event handler attributes (onclick, onload, etc.) - Support for escaping, stripping, or allowing raw HTML - Add safeMode option to DjotConverter constructor - Add 27 tests covering all safe mode functionality Closes #2 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8e67e36 commit 8774dca

File tree

4 files changed

+707
-10
lines changed

4 files changed

+707
-10
lines changed

src/DjotConverter.php

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,43 @@ class DjotConverter
2727
* @param bool $xhtml Whether to use XHTML-compatible output
2828
* @param bool $warnings Whether to collect warnings during parsing
2929
* @param bool $strict Whether to throw exceptions on parse errors
30+
* @param \Djot\SafeMode|bool|null $safeMode Enable safe mode (true for defaults, SafeMode instance for custom config)
3031
*/
31-
public function __construct(bool $xhtml = false, bool $warnings = false, bool $strict = false)
32-
{
32+
public function __construct(
33+
bool $xhtml = false,
34+
bool $warnings = false,
35+
bool $strict = false,
36+
bool|SafeMode|null $safeMode = null,
37+
) {
3338
$this->collectWarnings = $warnings;
3439
$this->strictMode = $strict;
3540
$this->parser = new BlockParser($warnings, $strict);
3641
$this->renderer = new HtmlRenderer($xhtml);
42+
43+
// Configure safe mode
44+
if ($safeMode === true) {
45+
$this->renderer->setSafeMode(SafeMode::defaults());
46+
} elseif ($safeMode instanceof SafeMode) {
47+
$this->renderer->setSafeMode($safeMode);
48+
}
49+
}
50+
51+
/**
52+
* Enable or disable safe mode
53+
*
54+
* @param \Djot\SafeMode|bool|null $safeMode True for defaults, SafeMode for custom, null/false to disable
55+
*/
56+
public function setSafeMode(bool|SafeMode|null $safeMode): self
57+
{
58+
if ($safeMode === true) {
59+
$this->renderer->setSafeMode(SafeMode::defaults());
60+
} elseif ($safeMode instanceof SafeMode) {
61+
$this->renderer->setSafeMode($safeMode);
62+
} else {
63+
$this->renderer->setSafeMode(null);
64+
}
65+
66+
return $this;
3767
}
3868

3969
/**

src/Renderer/HtmlRenderer.php

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
use Djot\Node\Inline\Symbol;
4545
use Djot\Node\Inline\Text;
4646
use Djot\Node\Node;
47+
use Djot\SafeMode;
4748

4849
/**
4950
* Renders AST to HTML
@@ -52,6 +53,11 @@ class HtmlRenderer
5253
{
5354
protected bool $softBreakAsNewline = true;
5455

56+
/**
57+
* Safe mode configuration (null = disabled)
58+
*/
59+
protected ?SafeMode $safeMode = null;
60+
5561
/**
5662
* @var array<string, array<\Closure(\Djot\Event\RenderEvent): void>>
5763
*/
@@ -99,6 +105,32 @@ public function __construct(protected bool $xhtml = false)
99105
{
100106
}
101107

108+
/**
109+
* Enable safe mode with the given configuration
110+
*/
111+
public function setSafeMode(?SafeMode $safeMode): self
112+
{
113+
$this->safeMode = $safeMode;
114+
115+
return $this;
116+
}
117+
118+
/**
119+
* Get the current safe mode configuration
120+
*/
121+
public function getSafeMode(): ?SafeMode
122+
{
123+
return $this->safeMode;
124+
}
125+
126+
/**
127+
* Check if safe mode is enabled
128+
*/
129+
public function isSafeModeEnabled(): bool
130+
{
131+
return $this->safeMode !== null;
132+
}
133+
102134
public function setSoftBreakAsNewline(bool $value): void
103135
{
104136
$this->softBreakAsNewline = $value;
@@ -312,6 +344,11 @@ protected function renderAttributesExcluding(Node $node, array $exclude): string
312344
return '';
313345
}
314346

347+
// Filter dangerous attributes in safe mode
348+
if ($this->safeMode !== null) {
349+
$attrs = $this->safeMode->filterAttributes($attrs);
350+
}
351+
315352
// Sort attributes: id first, then others in source order
316353
uksort($attrs, function (string $a, string $b): int {
317354
if ($a === 'id') {
@@ -625,6 +662,11 @@ protected function renderLink(Link $node): string
625662
$href = $node->getDestination();
626663
$title = $node->getTitle();
627664

665+
// Sanitize URL in safe mode
666+
if ($this->safeMode !== null && $href !== null) {
667+
$href = $this->safeMode->sanitizeUrl($href);
668+
}
669+
628670
$html = '<a';
629671
// Only output href if destination is set (even if empty)
630672
if ($href !== null) {
@@ -642,10 +684,15 @@ protected function renderImage(Image $node): string
642684
{
643685
$attrs = $this->renderAttributes($node);
644686
$alt = $this->escape($node->getAlt());
645-
$src = $this->escape($node->getSource());
687+
$src = $node->getSource();
646688
$title = $node->getTitle();
647689

648-
$html = '<img alt="' . $alt . '" src="' . $src . '"';
690+
// Sanitize URL in safe mode
691+
if ($this->safeMode !== null) {
692+
$src = $this->safeMode->sanitizeUrl($src);
693+
}
694+
695+
$html = '<img alt="' . $alt . '" src="' . $this->escape($src) . '"';
649696
if ($title !== null) {
650697
$html .= ' title="' . $this->escape($title) . '"';
651698
}
@@ -720,6 +767,11 @@ protected function renderAttributes(Node $node): string
720767
return '';
721768
}
722769

770+
// Filter dangerous attributes in safe mode
771+
if ($this->safeMode !== null) {
772+
$attrs = $this->safeMode->filterAttributes($attrs);
773+
}
774+
723775
// Sort attributes: id first, then others in source order
724776
uksort($attrs, function (string $a, string $b): int {
725777
if ($a === 'id') {
@@ -768,21 +820,47 @@ protected function escapeAttribute(string $text): string
768820
protected function renderRawBlock(RawBlock $node): string
769821
{
770822
// Only output if format is HTML
771-
if ($node->getFormat() === 'html') {
772-
return $node->getContent() . "\n";
823+
if ($node->getFormat() !== 'html') {
824+
return '';
773825
}
774826

775-
return '';
827+
$content = $node->getContent();
828+
829+
// Handle raw HTML according to safe mode
830+
if ($this->safeMode !== null) {
831+
$mode = $this->safeMode->getRawHtmlMode();
832+
if ($mode === SafeMode::RAW_HTML_STRIP) {
833+
return '';
834+
}
835+
if ($mode === SafeMode::RAW_HTML_ESCAPE) {
836+
return $this->escape($content) . "\n";
837+
}
838+
}
839+
840+
return $content . "\n";
776841
}
777842

778843
protected function renderRawInline(RawInline $node): string
779844
{
780845
// Only output if format is HTML
781-
if ($node->getFormat() === 'html') {
782-
return $node->getContent();
846+
if ($node->getFormat() !== 'html') {
847+
return '';
783848
}
784849

785-
return '';
850+
$content = $node->getContent();
851+
852+
// Handle raw HTML according to safe mode
853+
if ($this->safeMode !== null) {
854+
$mode = $this->safeMode->getRawHtmlMode();
855+
if ($mode === SafeMode::RAW_HTML_STRIP) {
856+
return '';
857+
}
858+
if ($mode === SafeMode::RAW_HTML_ESCAPE) {
859+
return $this->escape($content);
860+
}
861+
}
862+
863+
return $content;
786864
}
787865

788866
protected function renderDefinitionList(DefinitionList $node): string

0 commit comments

Comments
 (0)