Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/Parser/InlineParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ final class InlineParser
{
private const SAFE_SCHEMES = ['http', 'https', 'mailto', ''];

/**
* Autolinks keep CommonMark's "any scheme" rule (ftp:, irc:, …) except these,
* which execute code or render attacker-controlled documents when followed.
*/
private const SCRIPT_SCHEMES = ['javascript', 'vbscript', 'data'];

/** Maximum nesting depth for recursive inline parsing (prevents stack overflow). */
private const MAX_DEPTH = 64;

Expand Down Expand Up @@ -495,6 +501,13 @@ private function scan(string $text, int $depth): array
if ($char === '<') {
// 1. URL autolink — MUST run before PATTERN_RAW_HTML_INLINE (see constant comment).
if (preg_match(self::PATTERN_AUTOLINK_URL, $text, $m, 0, $pos)) {
$scheme = strtolower(substr($m[1], 0, (int) strpos($m[1], ':')));
if (in_array($scheme, self::SCRIPT_SCHEMES, true)) {
// <javascript:…>, <data:…>, <vbscript:…>: literal text (XSS prevention).
$buffer .= $m[0];
$pos += strlen($m[0]);
continue;
}
$this->flushBuffer($buffer, $tokens);
$tokens[] = new AutolinkNode($m[1], false);
$pos += strlen($m[0]);
Expand Down Expand Up @@ -607,6 +620,9 @@ private function isSafeAngleBracketUrl(string $url): bool
if (preg_match('/[\x00-\x1F\x7F]/', rawurldecode($url))) {
return false;
}
// Browsers strip leading/trailing spaces from href/src before resolving the URL,
// so "< javascript:alert(1)>" must be judged on its trimmed form.
$url = trim($url, ' ');
// Reject protocol-relative URLs.
if (str_starts_with($url, '//') || str_starts_with($url, '\\')) {
return false;
Expand Down
52 changes: 52 additions & 0 deletions tests/Security/LinkSchemeBypassTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace PhpMarkdown\Tests\Security;

use PhpMarkdown\MarkdownParser;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
* Script-capable URL schemes must never reach an href/src, in the default (escaped) mode.
*/
final class LinkSchemeBypassTest extends TestCase
{
/** @return array<string, array{string}> */
public static function payloads(): array
{
return [
'angle link, leading space' => ['[a](< javascript:alert(1)>)'],
'angle link, spaces both sides' => ['[a](< JaVaScRiPt:alert(1) >)'],
'angle image, leading space' => ['![a](< javascript:alert(1)>)'],
'autolink javascript' => ['<javascript:alert(1)>'],
'autolink vbscript' => ['<vbscript:msgbox(1)>'],
'autolink data' => ['<data:text/html;base64,PHNjcmlwdD4=>'],
];
}

#[DataProvider('payloads')]
public function testScriptSchemeIsNotLinked(string $markdown): void
{
$html = strtolower((new MarkdownParser())->parse($markdown));

$this->assertDoesNotMatchRegularExpression('/(?:href|src)="\s*(?:javascript|vbscript|data):/', $html);
}

public function testAngleLinkWithSpacesStillWorks(): void
{
$this->assertSame(
"<p><a href=\"/my uri\">a</a></p>\n",
(new MarkdownParser())->parse('[a](</my uri>)'),
);
}

public function testCustomSchemeAutolinkStillWorks(): void
{
$this->assertSame(
"<p><a href=\"ftp://files.example.com\">ftp://files.example.com</a></p>\n",
(new MarkdownParser())->parse('<ftp://files.example.com>'),
);
}
}
25 changes: 18 additions & 7 deletions tests/Unit/AutolinkTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,14 +158,25 @@ public function test_autolink_not_confused_with_raw_html(): void

// ── Security: javascript: scheme ─────────────────────────────────────────

public function test_url_autolink_rejects_javascript_scheme(): void
/** @return array<string, array{string}> */
public static function scriptSchemeAutolinks(): array
{
// CommonMark spec does not filter schemes at parse time; javascript: is a valid scheme
// per PATTERN_AUTOLINK_URL. The renderer calls esc() on both href and text but does NOT
// suppress the link. Consumers may add their own scheme filtering on top.
$result = $this->parse('<javascript:alert(1)>');
$this->assertStringContainsString('<a href=', $result);
$this->assertStringContainsString('javascript:alert(1)', $result);
return [
'javascript' => ['<javascript:alert(1)>'],
'mixed case' => ['<JaVaScRiPt:alert(1)>'],
'vbscript' => ['<vbscript:msgbox(1)>'],
'data text/html' => ['<data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==>'],
];
}

#[\PHPUnit\Framework\Attributes\DataProvider('scriptSchemeAutolinks')]
public function test_url_autolink_rejects_script_schemes(string $input): void
{
// Script-capable schemes are rendered as literal (escaped) text, never as a link,
// consistent with the scheme filtering applied to inline and reference links.
$result = $this->parse($input);
$this->assertStringNotContainsString('<a ', $result);
$this->assertStringStartsWith('&lt;', $result);
}

// ── Inline context ────────────────────────────────────────────────────────
Expand Down
Loading