fix: anchored input patterns end at the last character (/D)

Without /D, "$" also matches before a trailing "\n", so "custom_meta_telefon\n",
a color setting "5\n" (stored as sent) or a whole reference followed by "\n"
passed VariableRef. Added /D to every ^...$ pattern that validates client or
admin input:
- VariableRef META_KEY, NUMBER, WHOLE (canonical/typeOf; Placeholders::field
  shares META_KEY)
- Cursor::decode, GuideRepository topic names (MCP input and filter keys)
- ExposurePolicy::sanitize ability keys and group ids, LogTab list filters

Not changed: Placeholders::PATTERN (runs on trim()), Warnings hex checks (hints
only), NodeSummary/AttrPathIndex/LogListTable (stored or Divi data, no client
input). Each fixed pattern has a test with a trailing "\n" that is rejected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 18:02:51 +02:00
co-authored by Claude Opus 5.5
parent 60290c85a5
commit e420145cbe
10 changed files with 81 additions and 9 deletions
+6
View File
@@ -27,6 +27,12 @@ final class CursorTest extends TestCase
self::assertNull(Cursor::decode(base64_encode('o:12;DROP')));
}
/** "$" without /D also matches before a trailing newline – the cursor must end with the digits. */
public function testCursorWithTrailingNewlineIsRejected(): void
{
self::assertNull(Cursor::decode(rtrim(strtr(base64_encode("o:5\n"), '+/', '-_'), '=')));
}
public function testLimitIsClamped(): void
{
self::assertSame(20, Cursor::limit(null));
@@ -236,6 +236,20 @@ final class ExposurePolicyTest extends TestCase
);
}
/** "$" without /D also matches before a trailing newline – ability names and group ids end with their last character. */
public function testSanitizeRejectsKeysWithTrailingNewline(): void
{
self::assertSame(
[],
ExposurePolicy::sanitize([
"lit/menu-list\n" => 'disabled',
"@group:divi\n" => 'indirect',
"@readonly:divi\n" => true,
])
);
self::assertSame([], ExposurePolicy::sanitize(["@group:divi\n" => 'indirect'], ['core', 'divi']));
}
public function testOldFlatOptionStaysValid(): void
{
$old = ['lit/content-get' => 'indirect', 'lit/menu-list' => 'direct'];
+16
View File
@@ -63,6 +63,22 @@ final class LogTabTest extends TestCase
self::assertSame(['result' => '', 'date_from' => '', 'date_to' => '2026-13-99'], $tab->extraFilters());
}
/**
* "$" without /D also matches before a trailing newline. sanitize_text_field() already drops
* it in WordPress; the whitelist must hold on its own, so the sanitizer passes it through here.
*/
public function testFiltersRejectATrailingNewlineEvenWithoutTheSanitizer(): void
{
Functions\when('sanitize_text_field')->returnArg();
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
$_GET['ability'] = "lit/content-update\n";
$_GET['date_from'] = "2026-09-01\n";
$_GET['date_to'] = "2026-09-25\n";
self::assertSame('', $tab->filters()['ability']);
self::assertSame(['result' => '', 'date_from' => '', 'date_to' => ''], $tab->extraFilters());
}
public function testClearNeedsNonceAndRights(): void
{
Functions\stubTranslationFunctions();
@@ -76,6 +76,15 @@ final class GuideRepositoryTest extends TestCase
self::assertNull($this->guides->get('not-string'));
}
/** "$" without /D also matches before a trailing newline – such a filter topic is no topic name. */
public function testATopicWithTrailingNewlineIsRejected(): void
{
Filters\expectApplied('lit_mcp_guides')->andReturn(["site-rules\n" => 'eingeschleust']);
self::assertSame(['divi-shortform', 'overview'], $this->guides->topics());
self::assertNull($this->guides->get("site-rules\n"));
}
public function testANonArrayFilterResultIsIgnored(): void
{
Filters\expectApplied('lit_mcp_guides')->andReturn('kaputt');
@@ -138,6 +138,33 @@ final class VariableRefTest extends TestCase
self::ref()->canonical($input, ComposeFixture::sanitizer());
}
/**
* "$" without /D also matches before a trailing newline: the whole-reference pattern, a meta
* key name and a color setting number must each end with their last character.
*/
public function testATrailingNewlineIsNeverAnAnchorMatch(): void
{
$color = VariableRef::serialize('color', 'gcid-a1b2', []);
$rejects = [
'whole reference' => [$color . "\n", 'Verweise nur allein im Wert'],
'meta key name' => [
VariableRef::serialize('content', "custom_meta_telefon\n", ['before' => '', 'after' => '']),
'Unbekannter Name',
],
'color number' => [VariableRef::serialize('color', 'gcid-a1b2', ['hue' => "5\n"]), 'nur Zahlen'],
];
foreach ($rejects as $label => [$input, $message]) {
try {
self::ref()->canonical($input, ComposeFixture::sanitizer());
self::fail($label . ': accepted');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString($message, $e->getMessage(), $label);
}
}
self::assertNull(VariableRef::typeOf($color . "\n"));
}
/** I-1: the escape really is in the input, and nothing with ")$" inside comes out. */
public function testEscapedEndMarkerIsBytesNotTheCharacter(): void
{