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:
@@ -27,7 +27,7 @@ final class Cursor
|
|||||||
}
|
}
|
||||||
|
|
||||||
$raw = base64_decode(strtr($cursor, '-_', '+/'), true);
|
$raw = base64_decode(strtr($cursor, '-_', '+/'), true);
|
||||||
if ($raw === false || preg_match('/^o:(\d{1,9})$/', $raw, $matches) !== 1) {
|
if ($raw === false || preg_match('/^o:(\d{1,9})$/D', $raw, $matches) !== 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ final class ExposurePolicy
|
|||||||
}
|
}
|
||||||
} elseif (
|
} elseif (
|
||||||
is_string($value)
|
is_string($value)
|
||||||
&& preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $key) === 1
|
&& preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#D', $key) === 1
|
||||||
&& self::isAllowed($key, $value)
|
&& self::isAllowed($key, $value)
|
||||||
) {
|
) {
|
||||||
$clean[$key] = $value;
|
$clean[$key] = $value;
|
||||||
@@ -275,7 +275,7 @@ final class ExposurePolicy
|
|||||||
/** @param list<string>|null $groupIds */
|
/** @param list<string>|null $groupIds */
|
||||||
private static function isGroupId(string $id, ?array $groupIds): bool
|
private static function isGroupId(string $id, ?array $groupIds): bool
|
||||||
{
|
{
|
||||||
if (preg_match('#^[a-z0-9-]+$#', $id) !== 1) {
|
if (preg_match('#^[a-z0-9-]+$#D', $id) !== 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ final class LogTab implements AdminTab
|
|||||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ability' => preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $ability) === 1 ? $ability : '',
|
'ability' => preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#D', $ability) === 1 ? $ability : '',
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ final class LogTab implements AdminTab
|
|||||||
|
|
||||||
private static function isDate(string $value): bool
|
private static function isDate(string $value): bool
|
||||||
{
|
{
|
||||||
return preg_match('#^\d{4}-\d{2}-\d{2}$#', $value) === 1;
|
return preg_match('#^\d{4}-\d{2}-\d{2}$#D', $value) === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(): void
|
public function render(): void
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace LIT\MCP\Guides;
|
|||||||
*/
|
*/
|
||||||
final class GuideRepository
|
final class GuideRepository
|
||||||
{
|
{
|
||||||
private const TOPIC_PATTERN = '/^[a-z0-9]+(?:-[a-z0-9]+)*$/';
|
private const TOPIC_PATTERN = '/^[a-z0-9]+(?:-[a-z0-9]+)*$/D';
|
||||||
|
|
||||||
/** @var array<string, string>|null Filtered topics, read once per instance (= request). */
|
/** @var array<string, string>|null Filtered topics, read once per instance (= request). */
|
||||||
private ?array $filtered = null;
|
private ?array $filtered = null;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ final class VariableRef
|
|||||||
public const MARKER = '$variable(';
|
public const MARKER = '$variable(';
|
||||||
|
|
||||||
/** custom_meta_<key>: no leading "_" – Divi renders protected meta publicly (ruling S12). */
|
/** custom_meta_<key>: no leading "_" – Divi renders protected meta publicly (ruling S12). */
|
||||||
public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/';
|
public const META_KEY = '/^[A-Za-z0-9-][A-Za-z0-9_-]*$/D';
|
||||||
|
|
||||||
/** @var array<string, array<string, string>> Placeholders::POST values and their settings. */
|
/** @var array<string, array<string, string>> Placeholders::POST values and their settings. */
|
||||||
public const DEFAULT_DYNAMIC_CONTENT = [
|
public const DEFAULT_DYNAMIC_CONTENT = [
|
||||||
@@ -79,7 +79,7 @@ final class VariableRef
|
|||||||
|
|
||||||
private const COLOR_SETTINGS = ['hue', 'saturation', 'lightness', 'opacity'];
|
private const COLOR_SETTINGS = ['hue', 'saturation', 'lightness', 'opacity'];
|
||||||
|
|
||||||
private const WHOLE = '/^\$variable\((.*)\)\$$/s';
|
private const WHOLE = '/^\$variable\((.*)\)\$$/sD';
|
||||||
|
|
||||||
private const META_PREFIX = 'custom_meta_';
|
private const META_PREFIX = 'custom_meta_';
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ final class VariableRef
|
|||||||
'loop_term_meta_key_',
|
'loop_term_meta_key_',
|
||||||
];
|
];
|
||||||
|
|
||||||
private const NUMBER = '/^-?\d+(\.\d+)?$/';
|
private const NUMBER = '/^-?\d+(\.\d+)?$/D';
|
||||||
|
|
||||||
/** @var array<string, array<string, string>> */
|
/** @var array<string, array<string, string>> */
|
||||||
private array $dynamicContent;
|
private array $dynamicContent;
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ final class CursorTest extends TestCase
|
|||||||
self::assertNull(Cursor::decode(base64_encode('o:12;DROP')));
|
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
|
public function testLimitIsClamped(): void
|
||||||
{
|
{
|
||||||
self::assertSame(20, Cursor::limit(null));
|
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
|
public function testOldFlatOptionStaysValid(): void
|
||||||
{
|
{
|
||||||
$old = ['lit/content-get' => 'indirect', 'lit/menu-list' => 'direct'];
|
$old = ['lit/content-get' => 'indirect', 'lit/menu-list' => 'direct'];
|
||||||
|
|||||||
@@ -63,6 +63,22 @@ final class LogTabTest extends TestCase
|
|||||||
self::assertSame(['result' => '', 'date_from' => '', 'date_to' => '2026-13-99'], $tab->extraFilters());
|
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
|
public function testClearNeedsNonceAndRights(): void
|
||||||
{
|
{
|
||||||
Functions\stubTranslationFunctions();
|
Functions\stubTranslationFunctions();
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ final class GuideRepositoryTest extends TestCase
|
|||||||
self::assertNull($this->guides->get('not-string'));
|
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
|
public function testANonArrayFilterResultIsIgnored(): void
|
||||||
{
|
{
|
||||||
Filters\expectApplied('lit_mcp_guides')->andReturn('kaputt');
|
Filters\expectApplied('lit_mcp_guides')->andReturn('kaputt');
|
||||||
|
|||||||
@@ -138,6 +138,33 @@ final class VariableRefTest extends TestCase
|
|||||||
self::ref()->canonical($input, ComposeFixture::sanitizer());
|
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. */
|
/** I-1: the escape really is in the input, and nothing with ")$" inside comes out. */
|
||||||
public function testEscapedEndMarkerIsBytesNotTheCharacter(): void
|
public function testEscapedEndMarkerIsBytesNotTheCharacter(): void
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user