Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/Stempler/src/Transform/Finalizer/DynamicToPHP.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,24 @@ private function getFilterContext(VisitorContext $ctx): string
return \sprintf(
'json_encode(%s, %s, %s)',
'%s',
'JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT',
'JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_INVALID_UTF8_SUBSTITUTE',
'512',
);
}

// in on* and other attributes
// php {{ }} in on* event handlers. The browser HTML-decodes the attribute value before the JS engine
// parses it, so the value must survive both decodings: encode it as a JavaScript literal first, then
// HTML-encode the surrounding quotes. The JSON_HEX_* flags leave no HTML-special character inside the
// literal, so the outer htmlspecialchars() only affects the delimiters.
// JSON_INVALID_UTF8_SUBSTITUTE mirrors ENT_SUBSTITUTE: broken input yields U+FFFD instead of making
// json_encode() return false and collapse the whole value to an empty string.
if ($context[0] instanceof Verbatim && $context[1] instanceof Attr && $context[1]->name !== 'style') {
return \sprintf("'%s', %s, '%s'", '"', $this->defaultFilter, '"');
return \sprintf(
"htmlspecialchars(json_encode(%s, %s, %s), ENT_QUOTES | ENT_SUBSTITUTE, 'utf-8')",
'%s',
'JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_INVALID_UTF8_SUBSTITUTE',
'512',
);
}

return $this->defaultFilter;
Expand Down
74 changes: 70 additions & 4 deletions src/Stempler/tests/Transform/DynamicToPHPTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,28 +66,83 @@ public function testVerbatim(): void

public function testVerbatim2(): void
{
self::assertSame('<a onclick="alert(<?php echo \'&quot;\', '
. 'htmlspecialchars((string) ("hello world"), ENT_QUOTES | ENT_SUBSTITUTE, \'utf-8\'), \'&quot;\'; ?>)"></a>', $res = $this->compile('<a onclick="alert({{ "hello world" }})"></a>')->getContent());
self::assertSame('<a onclick="alert(<?php echo htmlspecialchars(json_encode('
. '"hello world", JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT '
. '| JSON_INVALID_UTF8_SUBSTITUTE, 512), '
. 'ENT_QUOTES | ENT_SUBSTITUTE, \'utf-8\'); ?>)"></a>', $res = $this->compile('<a onclick="alert({{ "hello world" }})"></a>')->getContent());

self::assertSame('<a onclick="alert(&quot;hello world&quot;)"></a>', $this->eval($res));
}

/**
* An event handler attribute is JavaScript delivered inside HTML: the browser HTML-decodes the value before
* the JS engine parses it. Quotes therefore have to be JavaScript escapes; HTML entities would decode back
* into real quotes and end the string literal early.
*/
public function testVerbatimEventHandlerEscapesQuotesForJavaScript(): void
{
// chr(34) keeps the double quote out of the template itself, where it would close the attribute
$res = $this->compile('<a onclick="alert({{ chr(34) . \'hi\' . chr(34) }})"></a>')->getContent();

self::assertSame(
'<a onclick="alert(&quot;\u0022hi\u0022&quot;)"></a>',
$rendered = $this->eval($res),
);

// what the JS engine parses, once the browser has decoded the attribute value
self::assertSame(
'alert("\u0022hi\u0022")',
$this->decodeEventHandler($rendered, 'onclick'),
);
}

public function testVerbatimEventHandlerKeepsNonStringTypes(): void
{
$res = $this->compile('<a onclick="alert({{ 123 }})"></a>')->getContent();

self::assertSame('<a onclick="alert(123)"></a>', $this->eval($res));
}

/**
* Broken input must degrade the way ENT_SUBSTITUTE does, not make json_encode() return false and silently
* collapse the value to an empty string.
*/
public function testVerbatimEventHandlerSubstitutesInvalidUtf8(): void
{
$res = $this->compile('<a onclick="alert({{ chr(177) . \'1\' }})"></a>')->getContent();

self::assertSame('<a onclick="alert(&quot;\ufffd1&quot;)"></a>', $this->eval($res));
}

public function testVerbatim3(): void
{
self::assertSame('<script>alert(<?php echo json_encode'
. '("hello world", JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT, 512); ?>)</script>', $res = $this->compile('<script>alert({{ "hello world" }})</script>')->getContent());
. '("hello world", JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT '
. '| JSON_INVALID_UTF8_SUBSTITUTE, 512); ?>)</script>', $res = $this->compile('<script>alert({{ "hello world" }})</script>')->getContent());

self::assertSame('<script>alert("hello world")</script>', $this->eval($res));
}

public function testVerbatim4(): void
{
self::assertSame('<script>alert(<?php echo json_encode' .
'("hello\' \'world", JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT, 512); ?>)</script>', $res = $this->compile('<script>alert({{ "hello\' \'world" }})</script>')->getContent());
'("hello\' \'world", JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT '
. '| JSON_INVALID_UTF8_SUBSTITUTE, 512); ?>)</script>', $res = $this->compile('<script>alert({{ "hello\' \'world" }})</script>')->getContent());

self::assertSame('<script>alert("hello\u0027 \u0027world")</script>', $this->eval($res));
}

/**
* Without JSON_INVALID_UTF8_SUBSTITUTE json_encode() returns false on broken input and the value collapses
* to an empty string, silently changing the arity of the surrounding JavaScript call.
*/
public function testVerbatimScriptSubstitutesInvalidUtf8(): void
{
$res = $this->compile('<script>alert({{ chr(177) . \'1\' }})</script>')->getContent();

self::assertSame('<script>alert("\ufffd1")</script>', $this->eval($res));
}

protected function getVisitors(): array
{
$dynamic = new DynamicToPHP();
Expand All @@ -104,4 +159,15 @@ private function eval(string $body): string

return \ob_get_clean();
}

/**
* Emulates the browser: the HTML parser decodes entities in an attribute value, and the JS engine parses
* the decoded result.
*/
private function decodeEventHandler(string $html, string $attribute): string
{
\preg_match('/' . \preg_quote($attribute, '/') . '="([^"]*)"/', $html, $matches);

return \html_entity_decode($matches[1], ENT_QUOTES | ENT_HTML5, 'utf-8');
}
}
Loading