fix: StoredNodeGuard keeps plain relative links copyable (P18)

A link without scheme and without whitespace, quotes, <, >, backslashes or
control characters passes although esc_url_raw() would prefix http://; every
value with a scheme, and protocol-relative //host, must survive url() unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 01:58:47 +02:00
co-authored by Claude Opus 5.5
parent e808832fd3
commit 16c6d59725
2 changed files with 123 additions and 3 deletions
@@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Tests\Support\ComposeFixture;
@@ -322,4 +323,108 @@ final class StoredNodeGuardTest extends TestCase
self::assertCount(2, $problems);
self::assertSame($markup, Serializer::block($block));
}
/**
* Like esc_url_raw() for an editor: only allowed protocols, characters outside its list go, and a
* value without scheme that does not start with "/", "#" or "?" gets "http://" (so url() changes
* relative links like "kontakt/"). $keepProtocolRelative decides what "//host" becomes.
*/
private static function wpLikeGuard(bool $keepProtocolRelative = true): StoredNodeGuard
{
$inner = ComposeFixture::sanitizer();
$sanitizer = new class ($inner, $keepProtocolRelative) implements ValueSanitizer {
public function __construct(private ValueSanitizer $inner, private bool $keepProtocolRelative)
{
}
public function text(string $value): string
{
return $this->inner->text($value);
}
public function html(string $value): string
{
return $this->inner->html($value);
}
public function code(string $value): string
{
return $this->inner->code($value);
}
public function url(string $value): string
{
$allowed = '|[^a-z0-9-~+_.?#=!&;,/:%@$\|*\'()\[\]\x80-\xff]|i';
$value = (string) preg_replace($allowed, '', trim($value));
if (str_starts_with($value, '//')) {
return $this->keepProtocolRelative ? $value : 'http:' . $value;
}
if (preg_match('/^([a-z][a-z0-9+.-]*):/i', $value, $scheme) === 1) {
return in_array(strtolower($scheme[1]), ['http', 'https', 'mailto', 'tel'], true) ? $value : '';
}
return preg_match('/^[\/#?]/', $value) === 1 ? $value : 'http://' . $value;
}
public function allowsUnfilteredHtml(): bool
{
return false;
}
public function canReadPost(int $id): bool
{
return $this->inner->canReadPost($id);
}
};
$references = new VariableRef(
DiviFixtures::designSystem(),
DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())
);
$cleaner = new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer);
return new StoredNodeGuard($cleaner, $references, $sanitizer);
}
private static function link(string $url): Block
{
return self::block(self::node('divi/button', ['button' => ['innerContent' => self::desktop([
'text' => 'Kontakt',
'linkUrl' => $url,
])]]));
}
/** Ruling P18: relative links stay copyable although esc_url_raw() would change them. */
public function testRelativeLinksWithoutSchemePass(): void
{
$accepted = ['kontakt/', '/pfad', '#anker', '?q=1', 'https://kunde.test/a?b=1#c', 'mailto:info@kunde.test'];
foreach ($accepted as $url) {
self::assertSame([], self::wpLikeGuard()->problems(self::link($url)), $url);
}
}
/** Ruling P18: every value with a scheme must survive url() unchanged; odd relative values are refused. */
public function testLinksWithASchemeOrOddCharactersAreRefused(): void
{
$refused = [
'javascript:alert(1)',
'JaVaScRiPt:alert(1)',
'java' . chr(9) . 'script:alert(1)',
'data:text/html,probe',
'kontakt/"data-probe="1',
"kontakt/'x",
'kontakt/ x',
'kontakt/<b>',
'\\evil.example',
'/\\evil.example',
];
foreach ($refused as $url) {
self::assertFalse(self::link($url)->isOpaque(), $url);
self::assertNotSame([], self::wpLikeGuard()->problems(self::link($url)), $url);
}
// Protocol-relative: only when url() leaves it as it is.
self::assertSame([], self::wpLikeGuard()->problems(self::link('//evil.example')));
self::assertNotSame([], self::wpLikeGuard(false)->problems(self::link('//evil.example')));
}
}