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);
}
}