Skip to content

Commit 089016a

Browse files
authored
fix: gotenberg_font_face quotes escaped in HTML context (#267)
1 parent 6ead891 commit 089016a

4 files changed

Lines changed: 83 additions & 20 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ sensiolabs_gotenberg:
377377
- [UPGRADE FROM 1.0.0 to 1.1.0](./UPGRADE-1.1.md)
378378
- [UPGRADE FROM 1.1.0 to 1.2.0](./UPGRADE-1.2.md)
379379
- [UPGRADE FROM 1.2.0 to 1.3.0](./UPGRADE-1.3.md)
380+
- [UPGRADE FROM 1.3.0 to 1.4.0](./UPGRADE-1.4.md)
380381

381382
## Credits
382383

UPGRADE-1.4.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# UPGRADE FROM 1.3.0 to 1.4.0
22

33
## Breaking changes
4-
54
* `AbstractBuilder::getHeadersBag()` now retains `Gotenberg-Webhook-Extra-Http-Headers` as an array until the payload is built. Code reading this value directly must expect an array instead of a JSON string.
5+
6+
## Bug Fixes
7+
* `gotenberg_font_face()` is now declared `is_safe` for both `html` and `css` Twig contexts.
8+
Previously, using it inside a `<style>` tag would cause Twig's HTML auto-escaping to convert
9+
double quotes to `&quot;`, producing invalid CSS. If you were using the `| raw` filter as a
10+
workaround, you can now remove it.
11+
12+
```diff
13+
- <style>{{ gotenberg_font_face('fonts/my-font.woff2', 'MyFont') | raw }}</style>
14+
+ <style>{{ gotenberg_font_face('fonts/my-font.woff2', 'MyFont') }}</style>
15+
```

src/Twig/GotenbergExtension.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public function getFunctions(): array
1212
return [
1313
new TwigFunction('gotenberg_asset', [GotenbergRuntime::class, 'getAssetUrl']),
1414
new TwigFunction('gotenberg_font_style_tag', [GotenbergRuntime::class, 'getFontStyleTag'], ['is_safe' => ['html']]),
15-
new TwigFunction('gotenberg_font_face', [GotenbergRuntime::class, 'getFontFace'], ['is_safe' => ['css']]),
15+
new TwigFunction('gotenberg_font_face', [GotenbergRuntime::class, 'getFontFace'], ['is_safe' => ['html', 'css']]),
1616
];
1717
}
1818
}

tests/Twig/GotenbergRuntimeTest.php

Lines changed: 70 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,28 @@
22

33
namespace Sensiolabs\GotenbergBundle\Tests\Twig;
44

5+
use PHPUnit\Framework\Attributes\DataProvider;
56
use PHPUnit\Framework\Attributes\TestWith;
67
use PHPUnit\Framework\TestCase;
78
use Sensiolabs\GotenbergBundle\Builder\BuilderAssetInterface;
9+
use Sensiolabs\GotenbergBundle\Twig\GotenbergExtension;
810
use Sensiolabs\GotenbergBundle\Twig\GotenbergRuntime;
911
use Symfony\Component\Asset\Packages;
1012
use Symfony\Component\AssetMapper\AssetMapperRepository;
13+
use Twig\Environment;
14+
use Twig\Loader\ArrayLoader;
15+
use Twig\RuntimeLoader\FactoryRuntimeLoader;
1116

1217
class GotenbergRuntimeTest extends TestCase
1318
{
1419
public function testGetAsset(): void
1520
{
16-
$runtime = new GotenbergRuntime();
1721
$builder = $this->createMock(BuilderAssetInterface::class);
18-
$builder
19-
->expects($this->once())
20-
->method('addAsset')
21-
->with('foo')
22-
;
22+
$builder->expects($this->once())->method('addAsset')->with('foo');
23+
24+
$runtime = new GotenbergRuntime();
2325
$runtime->setBuilder($builder);
26+
2427
$this->assertSame('foo', $runtime->getAssetUrl('foo'));
2528
}
2629

@@ -34,14 +37,12 @@ public function testGetAssetThrowsWhenBuilderIsNotSet(): void
3437

3538
public function testGetFontFace(): void
3639
{
37-
$runtime = new GotenbergRuntime();
3840
$builder = $this->createMock(BuilderAssetInterface::class);
39-
$builder
40-
->expects($this->once())
41-
->method('addAsset')
42-
->with('foo.ttf')
43-
;
41+
$builder->expects($this->once())->method('addAsset')->with('foo.ttf');
42+
43+
$runtime = new GotenbergRuntime();
4444
$runtime->setBuilder($builder);
45+
4546
$this->assertSame(
4647
'@font-face {font-family: "my_font";src: url("foo.ttf");}',
4748
$runtime->getFontFace('foo.ttf', 'my_font'),
@@ -50,14 +51,12 @@ public function testGetFontFace(): void
5051

5152
public function testGetFontStyleTag(): void
5253
{
53-
$runtime = new GotenbergRuntime();
5454
$builder = $this->createMock(BuilderAssetInterface::class);
55-
$builder
56-
->expects($this->once())
57-
->method('addAsset')
58-
->with('foo.ttf')
59-
;
55+
$builder->expects($this->once())->method('addAsset')->with('foo.ttf');
56+
57+
$runtime = new GotenbergRuntime();
6058
$runtime->setBuilder($builder);
59+
6160
$this->assertSame(
6261
'<style>@font-face {font-family: "my_font";src: url("foo.ttf");}</style>',
6362
$runtime->getFontStyleTag('foo.ttf', 'my_font'),
@@ -180,4 +179,57 @@ public function testGetAssetUrlWhenMissingAssetMapperRepositoryAndPackages(): vo
180179

181180
$this->assertSame('origin.png', $path);
182181
}
182+
183+
/**
184+
* @return iterable<string, array{string, string}>
185+
*/
186+
public static function provideFontRenderingCases(): iterable
187+
{
188+
yield 'gotenberg_font_face renders correctly inside a style tag' => [
189+
'<style>{{ gotenberg_font_face("foo.ttf", "my_font") }}</style>',
190+
'<style>@font-face {font-family: "my_font";src: url("foo.ttf");}</style>',
191+
];
192+
yield 'gotenberg_font_style_tag renders correctly in html context' => [
193+
'{{ gotenberg_font_style_tag("foo.ttf", "my_font") }}',
194+
'<style>@font-face {font-family: "my_font";src: url("foo.ttf");}</style>',
195+
];
196+
yield 'gotenberg_font_face escapes html tags in path inside a style tag' => [
197+
'<style>{{ gotenberg_font_face("fonts/<script>alert(1).ttf", "my_font") }}</style>',
198+
'<style>@font-face {font-family: "my_font";src: url("&lt;script&gt;alert(1).ttf");}</style>',
199+
];
200+
yield 'gotenberg_font_style_tag escapes html tags in path' => [
201+
'{{ gotenberg_font_style_tag("fonts/<script>alert(1).ttf", "my_font") }}',
202+
'<style>@font-face {font-family: "my_font";src: url("&lt;script&gt;alert(1).ttf");}</style>',
203+
];
204+
yield 'gotenberg_font_face escapes html injection in name inside a style tag' => [
205+
'<style>{{ gotenberg_font_face("foo.ttf", "</style><script>alert(\'xss\')</script>") }}</style>',
206+
'<style>@font-face {font-family: "&lt;/style&gt;&lt;script&gt;alert(&#039;xss&#039;)&lt;/script&gt;";src: url("foo.ttf");}</style>',
207+
];
208+
yield 'gotenberg_font_style_tag escapes html injection in name' => [
209+
'{{ gotenberg_font_style_tag("foo.ttf", "</style><script>alert(\'xss\')</script>") }}',
210+
'<style>@font-face {font-family: "&lt;/style&gt;&lt;script&gt;alert(&#039;xss&#039;)&lt;/script&gt;";src: url("foo.ttf");}</style>',
211+
];
212+
yield 'applying e("css") filter to gotenberg_font_face output destroys the css rule structure' => [
213+
'<style>{{ gotenberg_font_face("foo.ttf", "my_font") | e("css") }}</style>',
214+
'<style>\40 font\2D face\20 \7B font\2D family\3A \20 \22 my\5F font\22 \3B src\3A \20 url\28 \22 foo\2E ttf\22 \29 \3B \7D </style>',
215+
];
216+
}
217+
218+
#[DataProvider('provideFontRenderingCases')]
219+
public function testFontRendering(string $template, string $expected): void
220+
{
221+
$builder = $this->createMock(BuilderAssetInterface::class);
222+
$builder->method('addAsset');
223+
224+
$runtime = new GotenbergRuntime();
225+
$runtime->setBuilder($builder);
226+
227+
$twig = new Environment(new ArrayLoader(), ['autoescape' => 'html']);
228+
$twig->addExtension(new GotenbergExtension());
229+
$twig->addRuntimeLoader(new FactoryRuntimeLoader([
230+
GotenbergRuntime::class => static fn () => $runtime,
231+
]));
232+
233+
$this->assertSame($expected, $twig->createTemplate($template)->render([]));
234+
}
183235
}

0 commit comments

Comments
 (0)