Skip to content
Open
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions modules/cms/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -332,12 +332,14 @@ protected function registerBackendPermissions()
'cms.manage_content' => [
'label' => 'cms::lang.permissions.manage_content',
'tab' => 'cms::lang.permissions.name',
'comment' => 'cms::lang.permissions.manage_content_comment',
'roles' => [UserRole::CODE_DEVELOPER],
'order' => 100
],
'cms.manage_assets' => [
'label' => 'cms::lang.permissions.manage_assets',
'tab' => 'cms::lang.permissions.name',
'comment' => 'cms::lang.permissions.manage_assets_comment',
'roles' => [UserRole::CODE_DEVELOPER],
'order' => 100
],
Expand Down
2 changes: 2 additions & 0 deletions modules/cms/lang/en/lang.php
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,9 @@
'permissions' => [
'name' => 'CMS',
'manage_content' => 'Manage website content files',
'manage_content_comment' => 'This permission should only be given to trusted users, as it allows direct access to the theme\'s content files.',
'manage_assets' => 'Manage website assets - images, JavaScript files, CSS files',
'manage_assets_comment' => 'This permission should only be given to trusted users, as it allows direct access to the theme\'s asset files, which are combined and served publicly.',
'manage_pages' => 'Create, modify and delete website pages',
'manage_pages_comment' => 'This permission should only be given to trusted users, as it allows direct access to the theme\'s page content files, including PHP code if enabled.',
'manage_layouts' => 'Create, modify and delete CMS layouts',
Expand Down
43 changes: 29 additions & 14 deletions modules/system/classes/CombineAssets.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Assetic\Filter\CssRewriteFilter;
use Assetic\Filter\JavaScriptMinifierFilter;
use Assetic\Filter\StylesheetMinifyFilter;
use Winter\Storm\Filesystem\PathResolver;
use Winter\Storm\Parse\Assetic\Cache\FilesystemCache;
use Winter\Storm\Parse\Assetic\Filter\LessCompiler;
use Winter\Storm\Parse\Assetic\Filter\ScssCompiler;
Expand Down Expand Up @@ -130,30 +131,44 @@ public function init()
$this->useDeepHashing = Config::get('app.debug', false);
}

// Constrain asset import directives to known asset trees. Without these
// explicit roots, a writable asset could disclose arbitrary server-readable
// files: `@import (inline) "<path>"` in a .less file (GHSA-58fp-mcx6-7qf9),
// `=include ../../../.env` in a .js file (GHSA-2223-f22x-24cq), or an
// `@import` traversal in a .css file. The asset's own source directory is
// always allowed implicitly; this list adds the cross-tree roots that
// legitimate themes/plugins/modules actually import from (e.g. a plugin
// importing a module asset, or a theme importing its own ../vendor).
$allowedImportRoots = [
themes_path(),
plugins_path(),
base_path('modules'),
];

/*
* Register JavaScript filters
*/
$this->registerFilter('js', new JavascriptImporter);
$jsImporter = new JavascriptImporter;
$jsImporter->setAllowedImportRoots($allowedImportRoots);
$this->registerFilter('js', $jsImporter);

/*
* Register CSS filters
*/
$this->registerFilter('css', new CssImportFilter);
$cssImportFilter = new CssImportFilter;
// Assetic's CssImportFilter resolves `@import` targets relative to the source
// with `..` traversal allowed; confine the resolved path to the allowed roots.
$cssImportFilter->setImportValidator(function ($path) use ($allowedImportRoots) {
$resolved = PathResolver::resolve($path);

return $resolved !== false
&& PathResolver::withinAny($resolved, $allowedImportRoots);
});
$this->registerFilter('css', $cssImportFilter);
$this->registerFilter(['css', 'less', 'scss'], new CssRewriteFilter);

// Constrain @import resolution to known asset trees. Without these
// explicit roots, `@import (inline) "<path>"` in a writable .less file
// would disclose any server-readable file. The asset's own source root
// is always allowed implicitly; this list adds the cross-tree roots
// that legitimate themes/plugins actually use (e.g. a plugin importing
// a module .less, or a theme importing its own ../vendor). See
// GHSA-58fp-mcx6-7qf9.
$lessCompiler = new LessCompiler;
$lessCompiler->setAllowedImportRoots([
themes_path(),
plugins_path(),
base_path('modules'),
]);
$lessCompiler->setAllowedImportRoots($allowedImportRoots);
$this->registerFilter('less', $lessCompiler);
$this->registerFilter('scss', new ScssCompiler);

Expand Down
79 changes: 79 additions & 0 deletions modules/system/tests/classes/CombineAssetsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,85 @@ public function testLessCompilerAllowsLegitimatePartial()
}
}

/**
* Regression for GHSA-2223-f22x-24cq. A writable theme `.js` asset containing
* `=include ../../../.env` must not disclose server files through the combiner,
* whose output is served unauthenticated via the `combine/{file}` route.
*/
public function testJavascriptImporterBlocksTraversalImport()
{
$themeDir = $this->makeTempThemeDir();
// A real `.js` secret outside the theme subtree (but under base_path so
// Assetic's FileAsset root check passes). It escapes via `..` traversal but
// lands outside every allowed import root, so it must not be inlined.
$secretPath = dirname($themeDir) . '/js-secret-' . bin2hex(random_bytes(4)) . '.js';
file_put_contents($secretPath, 'var LEAK = "combine-leak-canary";');
file_put_contents(
$themeDir . '/assets/poc.js',
"/*\n=include ../../" . basename($secretPath) . "\n*/\n"
);

try {
$js = $this->compileJsTo($themeDir, 'assets/poc.js');
$this->assertStringNotContainsString('combine-leak-canary', $js);
} finally {
@unlink($secretPath);
\File::deleteDirectory($themeDir);
Comment on lines +193 to +195

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move temporary theme-directory cleanup to tearDown().

  • modules/system/tests/classes/CombineAssetsTest.php#L193-L195: register $themeDir for teardown instead of deleting it in this finally.
  • modules/system/tests/classes/CombineAssetsTest.php#L216-L218: register $themeDir for teardown instead of deleting it in this finally.
  • modules/system/tests/classes/CombineAssetsTest.php#L235-L236: register $themeDir for teardown instead of deleting it in this finally.

As per coding guidelines, “Fixtures that flow through Assetic\Asset\FileAsset ... must live under base_path() ... and be cleaned up with \File::deleteDirectory() in tearDown().”

Proposed cleanup pattern
+protected $temporaryThemeDirs = [];
+
+protected function tearDown(): void
+{
+    foreach ($this->temporaryThemeDirs as $themeDir) {
+        \File::deleteDirectory($themeDir);
+    }
+
+    $this->temporaryThemeDirs = [];
+    parent::tearDown();
+}
+
 protected function makeTempThemeDir(): string
 {
     $themeDir = base_path('storage/framework/cache/security-tests/theme-' . bin2hex(random_bytes(4)));
     mkdir($themeDir . '/assets/less', 0777, true);
+    $this->temporaryThemeDirs[] = $themeDir;
+
     return $themeDir;
 }
🧰 Tools
🪛 ast-grep (0.44.1)

[info] 193-193: Avoid unsafe call to unlink
Context: unlink($secretPath)
Note: [CWE-73] External Control of File Name or Path.

(avoid-unlink)

📍 Affects 1 file
  • modules/system/tests/classes/CombineAssetsTest.php#L193-L195 (this comment)
  • modules/system/tests/classes/CombineAssetsTest.php#L216-L218
  • modules/system/tests/classes/CombineAssetsTest.php#L235-L236
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/system/tests/classes/CombineAssetsTest.php` around lines 193 - 195,
Move temporary theme-directory cleanup from the three finally blocks in
modules/system/tests/classes/CombineAssetsTest.php at lines 193-195, 216-218,
and 235-236 into the test class tearDown() method. At each site, register
$themeDir for teardown instead of calling \File::deleteDirectory(), while
retaining secret-file cleanup; ensure tearDown() deletes all registered theme
directories with \File::deleteDirectory().

Source: Coding guidelines

}
}

/**
* The `.js`-only extension gate must block disclosure of non-JS files (e.g.
* `.env`) before any path resolution, even inside an otherwise reachable tree.
*/
public function testJavascriptImporterBlocksDisallowedExtension()
{
$themeDir = $this->makeTempThemeDir();
$secretPath = dirname($themeDir) . '/js-secret-' . bin2hex(random_bytes(4)) . '.env';
file_put_contents($secretPath, "APP_KEY=combine-leak-canary\n");
file_put_contents(
$themeDir . '/assets/poc.js',
"/*\n=include ../../" . basename($secretPath) . "\n*/\n"
);

try {
$js = $this->compileJsTo($themeDir, 'assets/poc.js');
$this->assertStringNotContainsString('combine-leak-canary', $js);
} finally {
@unlink($secretPath);
\File::deleteDirectory($themeDir);
}
}

/**
* Legitimate same-tree `=include partial.js` must still resolve, otherwise the
* hardening would break every asset that composes its own bundle.
*/
public function testJavascriptImporterAllowsSameTreeInclude()
{
$themeDir = $this->makeTempThemeDir();
file_put_contents($themeDir . '/assets/partial.js', 'var PARTIAL = "partial-marker";');
file_put_contents($themeDir . '/assets/main.js', "/*\n=include partial.js\n*/\nvar MAIN = 1;");

try {
$js = $this->compileJsTo($themeDir, 'assets/main.js');
$this->assertStringContainsString('partial-marker', $js);
} finally {
\File::deleteDirectory($themeDir);
}
}

protected function compileJsTo(string $themeDir, string $relativeAsset): string
{
$dest = sys_get_temp_dir() . '/winter-combine-out-' . bin2hex(random_bytes(4)) . '.js';
try {
CombineAssets::instance()->combineToFile([$relativeAsset], $dest, $themeDir);
return file_get_contents($dest) ?: '';
} finally {
@unlink($dest);
}
}

/** @var string */
protected $lastSecretPath = '';

Expand Down
Loading