Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"ext-pdo": "*",
"ext-zip": "*",

"assetic/framework": "^3.2.2",
"assetic/framework": "^3.2.3",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"doctrine/dbal": "^2.6",
"enshrined/svg-sanitize": "~0.16",
"laravel/framework": "^9.49",
Expand Down
56 changes: 46 additions & 10 deletions src/Parse/Assetic/Filter/LessImportResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
* authoritative resolver, we have to collide-and-override the auto-added entry by
* using its exact normalised key (`buildImportDirs()` does this).
*
* That auto-added entry is re-created for *every* file less.php parses, not just the
* entry file, and it is also consulted by `data-uri()` / `image-size()`. Overriding
* only the entry file's directory therefore leaves any `.less` imported from another
* directory ungated. `makeResolver()` closes that by registering a resolver for each
* directory it admits, so the collision follows the import graph.
*
* Usage shapes:
*
* // parseFile()-based caller (e.g. theme asset compilation):
Expand Down Expand Up @@ -93,6 +99,12 @@ public static function makeResolver(array $allowedRoots, ?string $contextDir = n
}

if (PathResolver::withinAny($resolved, array_merge([$contextDir], $allowedRoots))) {
// less.php is about to make this file's directory "current", which
// re-adds an unconfined path-form import dir for it. Claim that key
// now so the gate keeps applying to the file's own imports and to
// any data-uri() / image-size() call it makes.
self::registerDir(dirname($resolved), array_merge([$contextDir], $allowedRoots));

return [$resolved, dirname($filename)];
}

Expand All @@ -117,16 +129,40 @@ public static function buildImportDirs(string $sourceFile, array $allowedRoots):
$resolvedSource = realpath($sourceFile);
$sourceDir = $resolvedSource !== false ? dirname($resolvedSource) : dirname($sourceFile);

// less.php normalises its auto-added currentDirectory key by running
// it through `WinPath()` (backslash -> forward slash) before storing,
// then SetImportDirs() applies `rtrim('/\\') . '/'`. We must reproduce
// the *exact same* normalisation here or PHP `array_merge`'s
// string-key collision won't happen on Windows and the gate becomes
// non-authoritative for relative-traversal attacks (the auto-added
// path-form entry would still match first via file_exists). This is
// not just a test issue — it's a security regression on Windows.
$key = rtrim((new Filesystem())->normalizePath($sourceDir), '/') . '/';
return [self::importDirKey($sourceDir) => self::makeResolver($allowedRoots, $sourceDir)];
}

/**
* Register a resolver for `$dir` directly on the parser's import-dir list, so it
* collides with the path-form entry less.php auto-adds while that directory is
* the current one. Existing entries are left alone: the first resolver to claim
* a directory is the one that admitted it, and re-registering would only widen
* the allowed set.
*
* @param string[] $allowedRoots
*/
public static function registerDir(string $dir, array $allowedRoots): void
{
$key = self::importDirKey($dir);

if (!isset(\Less_Parser::$options['import_dirs'][$key])) {
\Less_Parser::$options['import_dirs'][$key] = self::makeResolver($allowedRoots, $dir);
}
}

return [$key => self::makeResolver($allowedRoots, $sourceDir)];
/**
* Reproduce the exact key less.php uses for a directory in its import-dir list.
*
* It normalises the key by running the file through `AbsPath()`/`WinPath()`
* (backslash -> forward slash) and `dirname()`-ing it with a trailing slash, then
* `SetImportDirs()` applies `rtrim('/\\') . '/'`. Reproducing that normalisation
* exactly is what makes PHP `array_merge` string-key collision replace the
* auto-added entry with our callable. Getting it wrong doesn't fail loudly — it
* silently leaves the auto-added path-form entry matching first via `file_exists`,
* which is a security regression, and it differs by platform (Windows).
*/
public static function importDirKey(string $dir): string
{
return rtrim((new Filesystem())->normalizePath($dir), '/') . '/';
}
}
90 changes: 90 additions & 0 deletions src/Parse/Assetic/Filter/ScssCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Assetic\Contracts\Asset\AssetInterface;
use Assetic\Contracts\Filter\HashableInterface;
use Assetic\Contracts\Filter\DependencyExtractorInterface;
use Winter\Storm\Filesystem\PathResolver;
use Winter\Storm\Support\Facades\Event;

/**
Expand All @@ -15,12 +16,38 @@
*/
class ScssCompiler extends ScssphpFilter implements HashableInterface, DependencyExtractorInterface
{
use HasAllowedImportRoots;

protected $currentFiles = [];

protected $variables = [];

protected $lastHash;

/**
* Import paths configured on this filter, mirrored from the parent so that they
* can be treated as allowed import roots. The parent stores them privately.
*
* @var array<int, string|callable>
*/
protected $configuredImportPaths = [];

/**
* Directory of the asset currently being compiled. Always an allowed import root,
* so same-tree `@import "partial"` keeps working without configuration.
*
* @var string|null
*/
protected $sourceDirectory = null;

/**
* Whether getChildren() is already running. The parent recurses through the
* override for each child, and only the outermost call may set the root.
*
* @var bool
*/
protected $resolvingChildren = false;

public function __construct()
{
Event::listen('cms.combiner.beforePrepare', function ($compiler, $assets) {
Expand All @@ -30,6 +57,13 @@ public function __construct()
}
}
});

// Confine `@import` resolution to the compiled asset's own directory subtree
// plus any caller-configured roots, matching the LESS and JavaScript
// compilers. Without it, scssphp resolves imports against the importing
// file's own directory with `..` traversal allowed, so resolution is not
// bounded to the asset tree.
$this->setImportValidator([$this, 'isImportAllowed']);
}

public function setPresets(array $presets)
Expand All @@ -47,12 +81,68 @@ public function addVariable($variable)
$this->variables[] = $variable;
}

public function setImportPaths(array $paths)
{
$this->configuredImportPaths = $paths;

parent::setImportPaths($paths);
}

public function addImportPath($path)
{
$this->configuredImportPaths[] = $path;

parent::addImportPath($path);
}

/**
* Determines whether scssphp may inline the file it resolved an `@import` to.
*
* Passed to {@see ScssphpFilter::setImportValidator()} and called with the
* resolved filesystem path of every candidate import.
*/
public function isImportAllowed(string $path): bool
{
$resolved = PathResolver::resolve($path);

if ($resolved === false) {
return false;
}

// withinAny() skips non-string entries, so callable import paths (which
// scssphp also accepts) are simply not treated as roots.
return PathResolver::withinAny($resolved, array_merge(
[$this->sourceDirectory],
$this->configuredImportPaths,
$this->allowedImportRoots
Comment thread
coderabbitai[bot] marked this conversation as resolved.
));
}

public function filterLoad(AssetInterface $asset)
{
$this->sourceDirectory = $asset->getSourceDirectory();

parent::setVariables($this->variables);
parent::filterLoad($asset);
}

public function getChildren(AssetFactory $factory, $content, $loadPath = null)
{
// Nested calls keep the entry asset's directory as the root, as filterLoad() does.
if ($this->resolvingChildren) {
return parent::getChildren($factory, $content, $loadPath);
}

$this->resolvingChildren = true;
$this->sourceDirectory = $loadPath;

try {
return parent::getChildren($factory, $content, $loadPath);
} finally {
$this->resolvingChildren = false;
}
}
Comment thread
LukeTowers marked this conversation as resolved.

public function setHash($hash)
{
$this->lastHash = $hash;
Expand Down
91 changes: 90 additions & 1 deletion tests/Parse/Assetic/LessCompilerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,96 @@ public function testBlocksCrossTreeImportWhenRootIsNotWhitelisted()
$this->assertStringNotContainsString('cross-tree-marker', $css);
}

/**
* less.php re-creates the unconfined path-form import dir for every file it
* parses, keyed by that file's own directory. Confining only the entry asset's
* directory therefore left anything imported from a subdirectory ungated.
*/
public function testBlocksTraversalFromAnImportedSubdirectoryFile()
{
mkdir($this->tmpReal . '/theme/assets/less/sub', 0777, true);
$main = $this->tmpReal . '/theme/assets/less/main.less';
file_put_contents($main, '@import "sub/child.less"; .main { color: blue; }');
file_put_contents(
$this->tmpReal . '/theme/assets/less/sub/child.less',
'@import (inline) "../../../../secret.env"; .child { color: red; }'
);

$css = $this->compile($main);

$this->assertStringNotContainsString('APP_KEY', $css);
$this->assertStringNotContainsString('do-not-leak-me', $css);
}

/**
* `data-uri()` resolves through the same import-dir list as `@import` and
* inlines the file's bytes, so the gate has to cover it too.
*/
public function testBlocksDataUriFileReadFromAnImportedSubdirectoryFile()
{
mkdir($this->tmpReal . '/theme/assets/less/sub', 0777, true);
$main = $this->tmpReal . '/theme/assets/less/main.less';
file_put_contents($main, '@import "sub/child.less"; .main { color: blue; }');
file_put_contents(
$this->tmpReal . '/theme/assets/less/sub/child.less',
'.x { background: data-uri("text/plain", "../../../../secret.env"); }'
);

$css = $this->compile($main);

$this->assertStringNotContainsString('APP_KEY', $css);
$this->assertStringNotContainsString('do-not-leak-me', $css);
}

/**
* A legitimate multi-level partial chain inside the asset tree must keep
* resolving — the gate follows the import graph rather than blocking it.
*/
public function testAllowsNestedPartialChain()
{
mkdir($this->tmpReal . '/theme/assets/less/sub', 0777, true);
$main = $this->tmpReal . '/theme/assets/less/main.less';
file_put_contents($main, '@import "sub/child.less"; .main-marker { color: blue; }');
file_put_contents(
$this->tmpReal . '/theme/assets/less/sub/child.less',
'@import "deeper.less"; .child-marker { color: green; }'
);
file_put_contents(
$this->tmpReal . '/theme/assets/less/sub/deeper.less',
'.deeper-marker { color: purple; }'
);

$css = $this->compile($main);

$this->assertStringContainsString('main-marker', $css);
$this->assertStringContainsString('child-marker', $css);
$this->assertStringContainsString('deeper-marker', $css);
}

/**
* A file admitted from a subdirectory must still be able to import from the entry
* asset's tree above it, not just from its own directory downwards.
*/
public function testAllowsImportedSubdirectoryFileToImportFromEntryDirectory()
{
mkdir($this->tmpReal . '/theme/assets/less/sub', 0777, true);
$main = $this->tmpReal . '/theme/assets/less/main.less';
file_put_contents($main, '@import "sub/child.less"; .main-marker { color: blue; }');
file_put_contents(
$this->tmpReal . '/theme/assets/less/sub/child.less',
'@import "../variables.less"; .child-marker { color: green; }'
);
file_put_contents(
$this->tmpReal . '/theme/assets/less/variables.less',
'.variables-marker { color: purple; }'
);

$css = $this->compile($main);

$this->assertStringContainsString('child-marker', $css);
$this->assertStringContainsString('variables-marker', $css);
}

protected function compile(string $sourceFile, ?LessCompiler $compiler = null): string
{
$compiler ??= new LessCompiler();
Expand All @@ -110,5 +200,4 @@ protected function compile(string $sourceFile, ?LessCompiler $compiler = null):
$compiler->filterLoad($asset);
return $asset->getContent();
}

}
Loading
Loading