Skip to content

fix: gotenberg_font_face quotes escaped in HTML context#267

Open
marilenaRM wants to merge 3 commits into
sensiolabs:1.xfrom
marilenaRM:fix/font-face-html-escaping
Open

fix: gotenberg_font_face quotes escaped in HTML context#267
marilenaRM wants to merge 3 commits into
sensiolabs:1.xfrom
marilenaRM:fix/font-face-html-escaping

Conversation

@marilenaRM
Copy link
Copy Markdown

Q A
Gotenberg API version ? 8.x
Bug fix ? yes
New feature ? no
BC break ? no
Issues Fix #240

Description

gotenberg_font_face was declared is_safe => ['css'] only, causing Twig's HTML auto-escaping to convert double quotes to &quot; when the function was used inside a <style> tag.

Each method now manages its own htmlspecialchars sanitization independently, removing the shared generateFontFace helper that obscured the security boundary tied to is_safe declarations.

@marilenaRM marilenaRM force-pushed the fix/font-face-html-escaping branch 2 times, most recently from 9319d21 to f522a8e Compare May 6, 2026 19:54
gotenberg_font_face was declared is_safe => ['css'] only, causing
Twig's HTML auto-escaping to convert double quotes to &quot; when
the function was used inside a <style> tag.

Each method now manages its own htmlspecialchars sanitization
independently, removing the shared generateFontFace helper that
obscured the security boundary tied to is_safe declarations.
@marilenaRM marilenaRM force-pushed the fix/font-face-html-escaping branch from f522a8e to a37d862 Compare May 6, 2026 20:31
Comment thread src/Twig/GotenbergRuntime.php Outdated
$name = htmlspecialchars($name);
$basename = htmlspecialchars(basename($path));

return '<style>@font-face {font-family: "'.$name.'";src: url("'.$basename.'");}</style>';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why duplicate the escaping logic?

I'm not sure I see the benefit here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point! sorry for this, it's useless

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no worries! Thanks for fixing it

Comment thread tests/Twig/GotenbergExtensionTest.php Outdated
use Twig\Loader\ArrayLoader;
use Twig\RuntimeLoader\FactoryRuntimeLoader;

class GotenbergExtensionTest extends TestCase
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the tests!

Would you be able to also add tests that verify that the escaping is happening correctly please?
This will prevent potential future changes from sneaking exploits in.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

something along the lines of

$template = $twig->createTemplate('<style>{{ gotenberg_font_face("</style><script>/*foo.ttf*/ alert('hey')</script><style>", "my_font") }}</style>');

for both font face and style tag

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and its best to also do it for the name argument, otherwise we are only testing one

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests added!

Comment thread tests/Twig/GotenbergExtensionTest.php Outdated
use Twig\Loader\ArrayLoader;
use Twig\RuntimeLoader\FactoryRuntimeLoader;

class GotenbergExtensionTest extends TestCase
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests written in the right class, thanks!

@Neirda24
Copy link
Copy Markdown
Contributor

Neirda24 commented May 7, 2026

@marilenaRM : in the original issue linked in your description @Jean-Beru asked if

<style>
    {{ gotenberg_font_face('vendor/@fontsource-variable/montserrat/files/montserrat-latin-wght-normal.woff2', 'Montserrat Variable') | e('css') }}
</style>

would work. It could make things easier to maintain. WDYT ?

return basename($path);
}

public function getFontStyleTag(string $path, string $name): string
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry but I don't get it. You update getFontStyleTag but the method related to gotenberg_font_face is getFontFace

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was confusing since I duplicated the html escaping, but I rolled back the changes in this file

@marilenaRM
Copy link
Copy Markdown
Author

marilenaRM commented May 7, 2026

@marilenaRM : in the original issue linked in your description @Jean-Beru asked if

<style>
    {{ gotenberg_font_face('vendor/@fontsource-variable/montserrat/files/montserrat-latin-wght-normal.woff2', 'Montserrat Variable') | e('css') }}
</style>

would work. It could make things easier to maintain. WDYT ?
@Neirda24 :
Well, I'd say that | e('css') is not the right approach here, for two reasons:

  1. It would produce invalid CSS.
    Twig's CSS escaping encodes every non-alphanumeric character into \HEX notation. Since gotenberg_font_face() returns a complete CSS rule (including structural characters like @, {, }, :, "), applying | e('css') to it would turn the entire rule into something unreadable by the browser. The filter is designed to escape values inside an existing CSS rule, not a full CSS block.
  2. It pushes the burden onto the template author.
    Even setting aside the above, requiring developers to remember to add | e('css') (or | raw) every time they use the function would be fragile and error-prone. The function should be safe and correct out of the box.

@marilenaRM marilenaRM requested review from StevenRenaux and esserj May 7, 2026 14:22
Comment thread tests/Twig/GotenbergRuntimeTest.php Outdated
Comment on lines +154 to +157
// | e('css') encodes every non-alphanumeric character (e.g. @ → \40, { → \7B)
// which destroys the structure of the CSS rule
$this->assertStringNotContainsString('@font-face', $output);
$this->assertStringContainsString('\40', $output);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should make an assertion on the whole string ($this->assertSame);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment thread tests/Twig/GotenbergRuntimeTest.php Outdated
{
[$twig] = $this->createTwigEnvironment();

$output = $twig->createTemplate('<style>{{ gotenberg_font_face("foo.ttf", "my_font") | e("css") }}</style>')->render([]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about using a DataProvider to provide the expected value and the template?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

Comment thread tests/Twig/GotenbergRuntimeTest.php Outdated
Comment on lines +228 to +252
private function createRuntime(): array
{
$builder = $this->createMock(BuilderAssetInterface::class);
$runtime = new GotenbergRuntime();
$runtime->setBuilder($builder);

return [$runtime, $builder];
}

/**
* @return array{Environment, MockObject&BuilderAssetInterface}
*/
private function createTwigEnvironment(): array
{
[$runtime, $builder] = $this->createRuntime();
$builder->method('addAsset');

$twig = new Environment(new ArrayLoader(), ['autoescape' => 'html']);
$twig->addExtension(new GotenbergExtension());
$twig->addRuntimeLoader(new FactoryRuntimeLoader([
GotenbergRuntime::class => static fn () => $runtime,
]));

return [$twig, $builder];
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, these methods add confusion by returning multiple values. We could create the Environment in the newly added test. Combined with a DataProvider, no code will be duplicated.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

@marilenaRM marilenaRM requested a review from Jean-Beru May 7, 2026 15:43
Copy link
Copy Markdown
Contributor

@Jean-Beru Jean-Beru left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some minor comments about test description.

Comment on lines +169 to +170
// basename() strips directory parts on /, so </style> in a path naturally loses its </ —
// but opening tags like <script> survive and must be escaped by htmlspecialchars
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, we can remove this comment. Tests should not describe method implementation but only make assertions on the result.

*/
public static function provideFontRenderingCases(): iterable
{
yield 'font_face_in_html_context' => [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The keys allow you to describe the test in a concrete sentence to improve readability. For example: "with gotenberg_font_face in a style tag". Can you update them?

Comment on lines +179 to +180
// names are not processed by basename — the full injection string must be escaped
yield 'font_face_escapes_malicious_name' => [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment can be resumed in the key.

Comment on lines +188 to +190
// | e('css') encodes every non-alphanumeric character (@ → \40, { → \7B, etc.),
// destroying the structure of the CSS rule — it is the wrong tool for a complete CSS rule
yield 'font_face_css_escape_filter_breaks_rule' => [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment can be resumed in the key.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: gotenberg_font_face is escaped in html files

5 participants