Skip to content

Confine JavaScript importer paths to allowed roots (GHSA-2223-f22x-24cq) - #237

Open
LukeTowers wants to merge 5 commits into
developfrom
wip/ghsa-2223-js-importer-lfi
Open

Confine JavaScript importer paths to allowed roots (GHSA-2223-f22x-24cq)#237
LukeTowers wants to merge 5 commits into
developfrom
wip/ghsa-2223-js-importer-lfi

Conversation

@LukeTowers

@LukeTowers LukeTowers commented Jul 26, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the local file disclosure reported in GHSA-2223-f22x-24cq. The JavaScript combiner =include/=require directives (Winter\Storm\Parse\Assetic\Filter\JavascriptImporter) resolved their target with realpath() and no confinement check, so a .js asset containing =include ../../../.env inlined arbitrary server-readable files into combined output that System\Classes\SystemController@combine serves unauthenticated (APP_KEY, DB credentials, etc.).

This is the JavaScript sibling of the LESS import flaw hardened for GHSA-58fp-mcx6-7qf9; that fix gated the LESS filter but left JavascriptImporter (and the CSS importer) exploitable.

Changes

  • JavascriptImporter now enforces two layers in directiveInclude():
    • A .js-only extension gate — rejects .env/.php/.log etc. before any path math. Extension-less includes are unaffected (still resolved to .js first), matching the CSS importer which only inlines .css.
    • Path confinement — the resolved path must lie within the including file's own directory subtree or a caller-configured allowed root. A mandatory =require into a disallowed path now throws (consistent with the not-found and disallowed-extension guards); the optional =include still degrades to a comment.
  • PathResolver::withinAny() (new) — the shared confinement primitive: returns true if a path lies within any of a list of roots, skipping null/empty entries so callers can pass an optional context dir alongside a roots list without pre-filtering. Used by both the JS importer and LessImportResolver.
  • HasAllowedImportRoots (new trait) — shared allowed-roots storage/setter for JavascriptImporter and LessCompiler.
  • LessImportResolver / LessCompiler refactored onto the shared primitive + trait (no behaviour change).

Callers configure the roots; System\Classes\CombineAssets supplies themes/plugins/modules.

Tests

New JavascriptImporterTest covering traversal blocking, the extension gate, allowed-root includes, legitimate same-tree includes, and both =require failure modes (disallowed extension and out-of-root path). New PathResolverTest::testWithinAny(). Full filter suite: 25/25 pass (9 new + 16 existing LESS).

Known follow-up — SCSS

ScssCompiler (scssphp) is not covered by this change and remains the last import-capable combiner filter without confinement (CombineAssets wires allowed roots into JS, CSS and LESS but registers ScssCompiler bare). Impact is narrower than JS/LESS — scssphp only imports .scss/.sass/.css targets and the content must parse as Sass, so .env/keys can't be disclosed — but a .scss file outside the asset tree can still be inlined via @import '../../../x.scss'. scssphp resolves imports internally with no post-resolution hook (source-dir- and importPath-relative traversal is resolved before any callable importer runs), so a correct fix requires subclassing scssphp's Compiler (override findImport) and swapping it into the filter's filterLoad — an invasive change to a shared filter that warrants its own PR + tests. Tracked separately.

Related

  • winter: wip/ghsa-2223-js-importer-lfi (wires the allowed roots into CombineAssets + adds the CSS validator)
  • assetic-php/assetic: wip/ghsa-2223-css-import-validator (opt-in CssImportFilter validator)

🤖 Generated with Claude Code

The JS combiner `=include`/`=require` directives resolved their target with
`realpath()` and no confinement check, so a `.js` asset containing
`=include ../../../.env` inlined arbitrary server-readable files into combined
output that `System\Classes\SystemController@combine` serves unauthenticated —
an authenticated local file disclosure (APP_KEY, DB credentials, etc.). This is
the JavaScript sibling of the LESS import flaw hardened for GHSA-58fp-mcx6-7qf9;
that fix gated the LESS filter but left `JavascriptImporter` exploitable.

Harden `JavascriptImporter::directiveInclude()` with two layers:

- Restrict inlined files to the `.js` extension, rejecting `.env`/`.php`/`.log`
  and the like before any path math. Extension-less includes are unaffected —
  they are still resolved to `.js` first — and this mirrors the CSS importer,
  which only inlines `.css`.
- Confine the resolved path to the including file's own directory subtree or a
  caller-configured allowed root, via a new shared `ImportGuard` policy.

Extract the confinement decision shared with the LESS importer into
`ImportGuard::isAllowed()`, and the allowed-roots storage into a
`HasAllowedImportRoots` trait, so both filters enforce one identical policy.
`System\Classes\CombineAssets` configures the roots (themes/plugins/modules).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wintercms wintercms deleted a comment from coderabbitai Bot Jul 26, 2026
LukeTowers and others added 4 commits July 26, 2026 12:50
The import confinement check was a single static method on a dedicated
ImportGuard class. Fold it into PathResolver as a general-purpose
`withinAny($path, $directories)` — a natural sibling of `within()` — which
removes the one-method class and is reusable beyond the asset importers.

The "source directory always allowed" semantics become just another entry in
the directory list: callers pass the context dir (which may be null — non-string
and empty entries are ignored) alongside the configured roots. JavascriptImporter
and LessImportResolver call it directly; no behaviour change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The combiner's CSS import confinement relies on
`CssImportFilter::setImportValidator()`, added in assetic/framework v3.2.1.
Raise the minimum so the method is guaranteed present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
withinAny() was only exercised transitively through the JS/LESS importer tests.
Add a dedicated test covering multi-directory matching, the no-match and
empty-list cases, and the null/empty/non-string skipping that lets callers pass
a nullable context directory alongside the roots list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Make the path-confinement guard in JavascriptImporter::directiveInclude()
honour $required: a mandatory =require into a disallowed path now throws a
RuntimeException, matching the not-found and disallowed-extension guards.
The optional =include still degrades to an error comment. Also hoist the
allowed-roots array out of the per-include loop. Adds a regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses GHSA-2223-f22x-24cq by hardening the JavaScript asset combiner’s =include / =require directives against local file disclosure via traversal and unconstrained realpath() resolution. It introduces a shared path-confinement helper and allowed-roots plumbing so import-capable filters can restrict imports to the including file’s subtree and caller-approved roots.

Changes:

  • Harden JavascriptImporter with a .js extension allowlist and path confinement (including stricter behavior for mandatory =require).
  • Add shared primitives for confinement and configuration (PathResolver::withinAny() and HasAllowedImportRoots), and refactor LESS import gating to use them.
  • Add regression tests for the new confinement behavior and helper.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.

Show a summary per file
File Description
tests/Parse/Assetic/JavascriptImporterTest.php Adds regression tests covering traversal blocking, extension gating, allowed-root imports, and =require failure modes.
tests/Filesystem/PathResolverTest.php Adds coverage for PathResolver::withinAny() including skipping null/empty entries.
src/Parse/Assetic/Filter/LessImportResolver.php Refactors LESS import confinement check to use PathResolver::withinAny().
src/Parse/Assetic/Filter/LessCompiler.php Switches to shared allowed-roots storage via HasAllowedImportRoots.
src/Parse/Assetic/Filter/JavascriptImporter.php Enforces .js-only inlining and confines resolved paths to allowed roots.
src/Parse/Assetic/Filter/HasAllowedImportRoots.php Introduces shared trait for storing/configuring additional allowed import roots.
src/Filesystem/PathResolver.php Adds withinAny() helper to check containment against multiple roots.
composer.json Updates assetic/framework requirement to ^3.2.1.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants