Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
- Base: wintercms/winter branch 1.2 (full framework) - Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS - Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication - Partials: hero (offline-first), features, modes (offline/nube toggle), screenshots, pricing (3 planes), comparison, FAQ, CTA - Plugin VivesPOS.Site with ContactForm - Dockerfile: PHP 8.2 Apache, port 80, healthcheck - Added winter/wn-pages, blog, sitemap, seo plugins - Active theme set to vivespos
190 lines
6.6 KiB
PHP
190 lines
6.6 KiB
PHP
<?php
|
|
|
|
namespace Backend\Tests\Widgets;
|
|
|
|
use Backend\Widgets\Table;
|
|
use System\Tests\Bootstrap\PluginTestCase;
|
|
|
|
/**
|
|
* Regression coverage for GHSA-hq84-x37p-j6q5.
|
|
*
|
|
* The Table widget partial renders the request's `search` value inside a
|
|
* <script type="text/template"> block. <script> is an HTML raw-text context, so the
|
|
* surrounding value="..." quoting is not a boundary: a literal </script> 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 = '<script type="text/template" data-table-toolbar-search>';
|
|
|
|
/**
|
|
* Sets the ?search= value seen by the get() helper, which reads Request::query().
|
|
*/
|
|
protected function setSearchQuery(string $value): void
|
|
{
|
|
$this->app['request']->query->set('search', $value);
|
|
}
|
|
|
|
protected function renderTable(array $config = []): string
|
|
{
|
|
$table = new Table(null, array_merge([
|
|
'dataSource' => 'client',
|
|
'columns' => [
|
|
'title' => ['title' => 'Title'],
|
|
],
|
|
], $config));
|
|
|
|
return $table->render();
|
|
}
|
|
|
|
/**
|
|
* Returns everything rendered after the search template's closing tag. On a correctly
|
|
* escaped output this is only the partial's own trailing markup.
|
|
*/
|
|
protected function markupAfterSearchTemplate(string $html): string
|
|
{
|
|
$openerPos = strpos($html, self::SEARCH_TEMPLATE_OPENER);
|
|
$this->assertNotFalse($openerPos, 'Search template block should be present');
|
|
|
|
$afterOpener = substr($html, $openerPos + strlen(self::SEARCH_TEMPLATE_OPENER));
|
|
|
|
// End tags are case-insensitive and may carry whitespace before the ">", so a
|
|
// literal "</script>" search would miss </ScRiPt> and "</script >" terminators
|
|
// and report a clean result for payloads that do in fact break out.
|
|
$this->assertSame(
|
|
1,
|
|
preg_match('~</script\s*>~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' => ['</script><meta name="probe-plain">'],
|
|
'mixed case' => ['</ScRiPt><meta name="probe-case">'],
|
|
'trailing space' => ['</script ><meta name="probe-space">'],
|
|
'trailing tab' => ["</script\t><meta name=\"probe-tab\">"],
|
|
'trailing newline' => ["</script\n><meta name=\"probe-newline\">"],
|
|
'attribute breakout' => ['"><img src=x onerror=alert(1)>'],
|
|
'script element' => ['</script><script>alert(1)</script>'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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, '</script>'),
|
|
'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('<img', $escaped, 'Image element escaped the template');
|
|
$this->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('</script><meta name="probe-toggle">');
|
|
|
|
$html = $this->renderTable(['searching' => $searching]);
|
|
|
|
$this->assertStringNotContainsString(
|
|
'</script><meta',
|
|
$html,
|
|
'Sink must be escaped with searching=' . var_export($searching, true)
|
|
);
|
|
$this->assertSame(
|
|
self::EXPECTED_SCRIPT_CLOSERS,
|
|
substr_count($html, '</script>'),
|
|
'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><meta name="probe-control">');
|
|
|
|
$escaped = $this->markupAfterSearchTemplate($this->renderTable());
|
|
|
|
$this->assertStringNotContainsString('probe-control', $escaped);
|
|
}
|
|
}
|