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/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'); + } } 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');