block. in the query * string terminated the template early and injected attacker markup into the backend * document. The value must therefore be HTML-encoded on output. * * @see modules/backend/widgets/table/partials/_table.php */ class TableSearchEscapingTest extends PluginTestCase { /** * The partial emits exactly two template blocks: [data-table-toolbar] and * [data-table-toolbar-search]. Any extra closing tag in the output means a payload * introduced a raw-text terminator of its own. */ const EXPECTED_SCRIPT_CLOSERS = 2; const SEARCH_TEMPLATE_OPENER = '" search would miss and "" terminators // and report a clean result for payloads that do in fact break out. $this->assertSame( 1, preg_match('~~i', $afterOpener, $m, PREG_OFFSET_CAPTURE), 'Search template should be closed' ); return substr($afterOpener, $m[0][1] + strlen($m[0][0])); } public static function rawTextTerminatorProvider(): array { return [ 'plain closing tag' => [''], 'mixed case' => [''], 'trailing space' => [''], 'trailing tab' => [""], 'trailing newline' => [""], 'attribute breakout' => ['">'], 'script element' => [''], ]; } /** * @dataProvider rawTextTerminatorProvider */ public function testSearchValueCannotTerminateTheScriptTemplate(string $payload) { $this->setSearchQuery($payload); $html = $this->renderTable(); $this->assertStringNotContainsString( $payload, $html, 'The raw payload must never be reflected verbatim' ); $this->assertSame( self::EXPECTED_SCRIPT_CLOSERS, substr_count($html, ''), 'Payload introduced an extra raw-text terminator into the output' ); } /** * @dataProvider rawTextTerminatorProvider */ public function testPayloadCannotEscapeIntoDocumentMarkup(string $payload) { $this->setSearchQuery($payload); $escaped = $this->markupAfterSearchTemplate($this->renderTable()); $this->assertStringNotContainsString('probe-', $escaped, 'Marker escaped the template'); $this->assertStringNotContainsString('assertStringNotContainsString('alert(1)', $escaped, 'Script payload escaped the template'); } /** * The template is emitted unconditionally by the partial -- it does not depend on the * `searching` option -- so the sink must be safe in both states. `searching` defaults * to false, which was the configuration most affected instances shipped with. */ public function testEscapingAppliesRegardlessOfSearchingOption() { foreach ([true, false] as $searching) { $this->setSearchQuery(''); $html = $this->renderTable(['searching' => $searching]); $this->assertStringNotContainsString( ''), 'Unexpected terminator with searching=' . var_export($searching, true) ); } } public function testAngleBracketsAndQuotesAreEncoded() { $this->setSearchQuery('<>"\'&'); $html = $this->renderTable(); $this->assertStringContainsString('value="<>"'&"', $html); } /** * Guards against a fix that escapes but mangles ordinary input. */ public function testOrdinarySearchTextIsPreserved() { $this->setSearchQuery('hello world'); $this->assertStringContainsString('value="hello world"', $this->renderTable()); } /** * Guards against a fix that breaks non-ASCII search terms. */ public function testUnicodeSearchTextIsPreserved() { $this->setSearchQuery('héllo 世界 😀'); $this->assertStringContainsString('value="héllo 世界 😀"', $this->renderTable()); } /** * Control: a payload with no raw-text terminator was never able to break out, so it * must not be counted as evidence that escaping works. If this ever fails, the tests * above are measuring something other than the raw-text boundary. */ public function testEscapedSlashControlNeverEscapedTheTemplate() { $this->setSearchQuery('<\\/script>'); $escaped = $this->markupAfterSearchTemplate($this->renderTable()); $this->assertStringNotContainsString('probe-control', $escaped); } }