From 961f91fdddd6e480b778f0e22c4fd58bef549e6d Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Sun, 26 Jul 2026 12:22:13 -0600 Subject: [PATCH 1/2] Add optional import validator to CssImportFilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CssImportFilter resolves `@import` targets relative to the source stylesheet with `..` traversal allowed and no confinement, so a consumer that inlines imports from untrusted CSS can disclose any readable `.css` file on the server via path traversal. Add an opt-in `setImportValidator(callable)` hook. When set, the validator receives the resolved local import path and returns false to skip the import (leaving the raw `@import` statement untouched). It applies only to the local FileAsset branch — remote/HTTP imports are unaffected — and defaults to null, so existing behaviour is unchanged for callers that do not configure it. This lets a framework confine imports to allowed roots without Assetic taking a dependency on any particular path-resolution implementation. Relates to downstream advisory GHSA-2223-f22x-24cq (Winter CMS combiner LFI). Co-Authored-By: Claude Fable 5 --- src/Assetic/Filter/CssImportFilter.php | 29 ++++++++++++++- .../Test/Filter/CssImportFilterTest.php | 35 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/Assetic/Filter/CssImportFilter.php b/src/Assetic/Filter/CssImportFilter.php index a7abc2f..8f8d0a1 100644 --- a/src/Assetic/Filter/CssImportFilter.php +++ b/src/Assetic/Filter/CssImportFilter.php @@ -18,6 +18,9 @@ class CssImportFilter extends BaseCssFilter implements DependencyExtractorInterf { private $importFilter; + /** @var callable|null */ + private $importValidator; + /** * Constructor. * @@ -28,13 +31,33 @@ public function __construct(?FilterInterface $importFilter = null) $this->importFilter = $importFilter ?: new CssRewriteFilter(); } + /** + * Set an optional validator that authorises each local file import before it is + * inlined. The validator receives the resolved import path (assembled from the + * asset's source root and the `@import` URL) and must return true to allow the + * import or false to skip it, leaving the raw `@import` statement untouched. + * + * This is an opt-in confinement hook for consumers that inline imports from + * potentially untrusted stylesheets: without it, `@import` targets are resolved + * relative to the source with `..` traversal allowed, which can disclose any + * readable `.css` file on the server. Defaults to null (no restriction) so + * existing behaviour is unchanged for callers that do not set it. + */ + public function setImportValidator(?callable $importValidator): self + { + $this->importValidator = $importValidator; + + return $this; + } + public function filterLoad(AssetInterface $asset) { $importFilter = $this->importFilter; + $importValidator = $this->importValidator; $sourceRoot = $asset->getSourceRoot(); $sourcePath = $asset->getSourcePath(); - $callback = function ($matches) use ($importFilter, $sourceRoot, $sourcePath) { + $callback = function ($matches) use ($importFilter, $importValidator, $sourceRoot, $sourcePath) { if (!$matches['url'] || null === $sourceRoot) { return $matches[0]; } @@ -69,6 +92,10 @@ public function filterLoad(AssetInterface $asset) } elseif ('css' != pathinfo($importPath ?: '', PATHINFO_EXTENSION) || !file_exists($importSource)) { // ignore non-css and non-existant imports return $matches[0]; + } elseif (null !== $importValidator && !$importValidator($importSource)) { + // ignore imports the caller-supplied validator rejects (e.g. a path + // that escapes the allowed roots via `..` traversal) + return $matches[0]; } else { $import = new FileAsset($importSource, array($importFilter), $importRoot, $importPath); } diff --git a/tests/Assetic/Test/Filter/CssImportFilterTest.php b/tests/Assetic/Test/Filter/CssImportFilterTest.php index 4e32edd..930167a 100644 --- a/tests/Assetic/Test/Filter/CssImportFilterTest.php +++ b/tests/Assetic/Test/Filter/CssImportFilterTest.php @@ -50,6 +50,41 @@ public function getFilters() ); } + public function testImportValidatorCanRejectImports() + { + $asset = new FileAsset(__DIR__ . '/fixtures/cssimport/main.css', [], __DIR__ . '/fixtures/cssimport', 'main.css'); + $asset->load(); + + $filter = new CssImportFilter(); + $filter->setImportValidator(function ($path) { + return false; + }); + $filter->filterLoad($asset); + + // Rejected imports are left as raw @import statements and never inlined. + $this->assertStringNotContainsString('body { color: red; }', $asset->getContent()); + $this->assertStringContainsString('@import "import.css";', $asset->getContent()); + } + + public function testImportValidatorReceivesResolvedPathAndCanAllow() + { + $asset = new FileAsset(__DIR__ . '/fixtures/cssimport/main.css', [], __DIR__ . '/fixtures/cssimport', 'main.css'); + $asset->load(); + + $seen = []; + $filter = new CssImportFilter(); + $filter->setImportValidator(function ($path) use (&$seen) { + $seen[] = $path; + return true; + }); + $filter->filterLoad($asset); + + // Allowed imports inline as normal, and the validator sees a resolved path. + $this->assertStringContainsString('body { color: red; }', $asset->getContent()); + $this->assertNotEmpty($seen); + $this->assertStringContainsString('import.css', implode('|', $seen)); + } + public function testNonCssImport() { $asset = new FileAsset(__DIR__ . '/fixtures/cssimport/noncssimport.css', [], __DIR__ . '/fixtures/cssimport', 'noncssimport.css'); From 3c92e910755042fc3a1ec85d803fd4b0f9202ef8 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Sun, 26 Jul 2026 12:49:33 -0600 Subject: [PATCH 2/2] Fix TypeScriptFilter for TypeScript 7 (--outFile removed) TypeScript 7 removed the single-file `--outFile` option, so `TypeScriptFilter` failed with "error TS5102: Option 'outFile' has been removed" against current tsc (the CI matrix installs `typescript: *`). Compile into an output directory with `--outDir` and read the emitted `input.js` back instead. Also drops the now-unnecessary `--ignoreDeprecations` argument. Verified against tsc 7.0.2; full suite green. Co-Authored-By: Claude Fable 5 --- src/Assetic/Filter/TypeScriptFilter.php | 32 +++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/Assetic/Filter/TypeScriptFilter.php b/src/Assetic/Filter/TypeScriptFilter.php index a84b5c9..21adc43 100644 --- a/src/Assetic/Filter/TypeScriptFilter.php +++ b/src/Assetic/Filter/TypeScriptFilter.php @@ -35,15 +35,39 @@ protected function getInputPath(string $input) */ public function filterLoad(AssetInterface $asset) { + // Newer TypeScript releases removed the single-file `--outFile` option, so + // emit into an output directory and read the compiled file back. The input + // is always written as `input.ts` (see getInputPath()), so tsc produces + // `input.js` inside the output directory. $args = [ '{INPUT}', - '--ignoreDeprecations', - '6.0', - '--outFile', - '{OUTPUT}' + '--outDir', + '{OUTPUT}', ]; $result = $this->runProcess($asset->getContent(), $args); $asset->setContent($result); } + + /** + * {@inheritDoc} + * + * tsc emits into a directory rather than a single named file, so the output + * location must be a throw-away directory instead of a temporary file. + */ + protected function getOutputPath() + { + $prefix = preg_replace('/[^\w]/', '', static::class); + return FilesystemUtils::createThrowAwayDirectory($prefix . '-output'); + } + + /** + * {@inheritDoc} + * + * Reads the compiled `input.js` from the output directory produced by tsc. + */ + protected function getOutput() + { + return file_get_contents($this->outputPath . '/input.js'); + } }