From bb385b9bcb25674cb55c46a701949560e94b96ff Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 25 May 2026 11:49:32 -0500 Subject: [PATCH] feat: implement EscapedValue class for XSS protection and update TemplateFactory to use it --- src/Views/EscapedValue.php | 20 +++++++++++ src/Views/Layout.php | 4 +-- src/Views/TemplateFactory.php | 33 ++++++++++++++----- tests/Unit/Views/TemplateEngineTest.php | 44 +++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 src/Views/EscapedValue.php diff --git a/src/Views/EscapedValue.php b/src/Views/EscapedValue.php new file mode 100644 index 00000000..5f0fd398 --- /dev/null +++ b/src/Views/EscapedValue.php @@ -0,0 +1,20 @@ +value); + } +} diff --git a/src/Views/Layout.php b/src/Views/Layout.php index 4c11dc4c..2c9e6563 100644 --- a/src/Views/Layout.php +++ b/src/Views/Layout.php @@ -15,8 +15,6 @@ public function __construct( ) { parent::__construct($template, $data); - foreach ($sections as $name => $value) { - $this->templateFactory->startSection($name, $value); - } + $this->templateFactory->inheritSections($sections); } } diff --git a/src/Views/TemplateFactory.php b/src/Views/TemplateFactory.php index 16043c80..0adbac84 100644 --- a/src/Views/TemplateFactory.php +++ b/src/Views/TemplateFactory.php @@ -5,12 +5,19 @@ namespace Phenix\Views; use Phenix\Views\Contracts\View as ViewContract; +use Stringable; class TemplateFactory { protected string|null $section; + + /** + * @var array + */ protected array $sections; + protected string|null $layout; + protected array $data; public function __construct( @@ -46,15 +53,17 @@ public function make(string $template, array $data = []): ViewContract public function startSection(string $name, string|null $value = null): void { - if ($value) { - $this->sections[$name] = $value; - } else { - $this->section = $name; + if ($value !== null) { + $this->sections[$name] = new EscapedValue($value); - ob_start(); - - $this->sections[$name] = null; + return; } + + $this->section = $name; + + ob_start(); + + $this->sections[$name] = null; } public function endSection(): void @@ -70,7 +79,15 @@ public function endSection(): void public function yieldSection(string $name): string { - return $this->sections[$name] ?? ''; + return (string) ($this->sections[$name] ?? ''); + } + + /** + * @param array $sections + */ + public function inheritSections(array $sections): void + { + $this->sections = $sections; } public function clear(): void diff --git a/tests/Unit/Views/TemplateEngineTest.php b/tests/Unit/Views/TemplateEngineTest.php index 2babe73d..cc182409 100644 --- a/tests/Unit/Views/TemplateEngineTest.php +++ b/tests/Unit/Views/TemplateEngineTest.php @@ -4,7 +4,9 @@ use Phenix\Facades\View; use Phenix\Views\Exceptions\ViewNotFoundException; +use Phenix\Views\TemplateCache; use Phenix\Views\TemplateEngine; +use Phenix\Views\TemplateFactory; it('render a template successfully', function (): void { $template = new TemplateEngine(); @@ -108,6 +110,48 @@ expect($output)->toContain('New title'); }); +it('escapes XSS in inline section value', function (): void { + $template = new TemplateEngine(); + $template->clearCache(); + + $output = $template->view('users.index', [ + 'title' => '', + ])->render(); + + expect($output)->toBeString(); + expect($output)->not->toContain(''); + + expect($factory->yieldSection('title')) + ->toBe('<script>alert("xss")</script>'); +}); + +it('preserves rendered block section html in template factory', function (): void { + $factory = new TemplateFactory(new TemplateCache()); + + $factory->startSection('content'); + echo '

Users

'; + $factory->endSection(); + + expect($factory->yieldSection('content'))->toBe('

Users

'); +}); + +it('stores empty and zero inline section values without starting a buffer', function (): void { + $factory = new TemplateFactory(new TemplateCache()); + + $factory->startSection('empty', ''); + $factory->startSection('zero', '0'); + + expect($factory->yieldSection('empty'))->toBe(''); + expect($factory->yieldSection('zero'))->toBe('0'); +}); + it('render template using facade', function (): void { View::clearCache();