Skip to content

fix(js): respect allow-local-file-access in require#7332

Merged
dwisiswant0 merged 2 commits intodevfrom
dwisiswant0/fix/js/respect-allow-local-file-access-in-require
Apr 10, 2026
Merged

fix(js): respect allow-local-file-access in require#7332
dwisiswant0 merged 2 commits intodevfrom
dwisiswant0/fix/js/respect-allow-local-file-access-in-require

Conversation

@dwisiswant0
Copy link
Copy Markdown
Member

@dwisiswant0 dwisiswant0 commented Apr 7, 2026

Proposed changes

fix(js): respect allow-local-file-access in require

The goja require() function used the default
host filesystem loader which let JavaScript
templates import any local files even when
allow-local-file-access was disabled.

Pooled runtimes kept require() state around so
a module loaded during a privileged execution
could remain cached for a later restricted one.

Rebuild the require registry per execution after
setting the execution context, and route file-
backed module loads to preserve native modules
while enforcing the same sandbox rules (as
nuclei/fs).

Proof

Template:
id: allow-local-file-access-bypass-via-require

info:
  name: Allow Local File Access Bypass via require
  author: dwisiswant0
  severity: critical
  description: |
    Proof-of-concept template for reproducing the JavaScript require()-based
    local file access bypass.
  tags: test,javascript

# prereq:
# ```sh
# echo '{ "loaded": true, "message": "bypassed" }' > /tmp/proof.json
# ```

javascript:
  - code: |
      var proof = require("/tmp/proof.json");
      ExportAs("loaded", proof.loaded);
      ExportAs("message", proof.message);
      true;

    matchers:
      - type: dsl
        dsl:
          - 'loaded == "true"'
          - 'message == "bypassed"'
        condition: and

    # extractors:
    #   - type: dsl
    #     dsl:
    #       - '"loaded=" + loaded + " message=" + message'

this patch:

$ ./bin/nuclei -t template.yaml -u scanme.sh

                     __     _
   ____  __  _______/ /__  (_)
  / __ \/ / / / ___/ / _ \/ /
 / / / / /_/ / /__/ /  __/ /
/_/ /_/\__,_/\___/_/\___/_/   v3.7.1

		projectdiscovery.io

[INF] Current nuclei version: v3.7.1 (unknown) - remove '-duc' flag to enable update checks
[INF] Current nuclei-templates version: v10.4.1 (unknown) - remove '-duc' flag to enable update checks
[INF] New templates added in latest release: 76
[INF] Templates loaded for current scan: 1
[WRN] Loading 1 unsigned templates for scan. Use with caution.
[INF] Targets loaded for current scan: 1
[INF] Scan completed in 51.961951ms. No results found.

dev:

$ ./bin/nuclei-3.7.1 -t template.yaml -u scanme.sh -silent
[allow-local-file-access-bypass-via-require] [javascript] [critical] scanme.sh

Checklist

  • Pull request is created against the dev branch
  • All checks passed (lint, unit/integration/regression tests etc.) with my changes
  • I have added tests that prove my fix is effective or that my feature works
  • I have added necessary documentation (if appropriate)

Summary by CodeRabbit

  • Tests

    • Added comprehensive tests for JavaScript module loading under Local File Access controls and for path containment behavior, including symlink scenarios and execution-context cache isolation.
  • Refactor

    • Module loading initialization moved from a global shared instance to per-execution runtimes, improving isolation and permission handling.
  • Bug Fix / Security

    • Improved path containment checks using a robust filesystem-aware utility to better enforce template-directory sandboxing.
  • Documentation

    • Added package documentation describing safe filepath utilities.

The goja `require() `function used the default
host filesystem loader which let JavaScript
templates import any local files even when
`allow-local-file-access` was disabled.

Pooled runtimes kept `require()` state around so
a module loaded during a privileged execution
could remain cached for a later restricted one.

Rebuild the require registry per execution after
setting the execution context, and route file-
backed module loads to preserve native modules
while enforcing the same sandbox rules (as
`nuclei/fs`).

Signed-off-by: Dwi Siswanto <git@dw1.io>
@auto-assign auto-assign bot requested a review from Mzack9999 April 7, 2026 12:32
@neo-by-projectdiscovery-dev
Copy link
Copy Markdown

neo-by-projectdiscovery-dev bot commented Apr 7, 2026

Neo - PR Security Review

No security issues found

Comment @pdneo help for available commands. · Open in Neo

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 7, 2026

Walkthrough

Refactors per-runtime JavaScript require initialization to read executionId from runtime context and perform path normalization; adds filesystem-safe IsPathWithinDirectory utility and tests; extends JS compiler tests to validate require(...) behavior under Local File Access (LFA) permutations and template-directory resolution.

Changes

Cohort / File(s) Summary
JS compiler tests (LFA behavior)
pkg/js/compiler/compiler_test.go
Added test helpers to create module files, set LFA per ExecutionId, compile/execute JS, and (non-Windows) symlink templates dir. Added tests asserting require failures when LFA disabled, success for modules under configured templates dir, success when LFA enabled, and that privileged module cache doesn't persist across executions.
Per-runtime require init
pkg/js/compiler/pool.go
Removed package-level *require.Registry. Added enableRequire(runtime) and newSourceLoader(runtime) that read executionId from runtime context and call protocolstate.NormalizePathWithExecutionId. executeWithRuntime and createNewRuntime now set runtime context values and call enableRequire.
Path containment checks
pkg/protocols/common/protocolstate/file.go, pkg/types/types.go
Replaced raw strings.HasPrefix checks with filepathutil.IsPathWithinDirectory(...) for template-directory containment validation.
Filesystem utilities
pkg/utils/filepath/doc.go, pkg/utils/filepath/filepath.go, pkg/utils/filepath/filepath_test.go
Added filepathutil package with exported IsPathWithinDirectory(path, directory) bool that canonicalizes paths (abs, eval symlinks, clean, Windows lowercase) and uses filepath.Rel. Added unit tests including symlink behavior (skipped on Windows).

Sequence Diagram(s)

sequenceDiagram
  participant Runner as Execution Runner
  participant Goja as goja.Runtime
  participant SourceLoader as require.SourceLoader
  participant Protocol as protocolstate
  participant FS as Filesystem

  Runner->>Goja: create new runtime, set context (executionId, ctx)
  Runner->>Goja: enableRequire(runtime)
  enableRequire->>SourceLoader: newSourceLoader(runtime) (captures executionId)
  Goja->>SourceLoader: require("modulePath")
  SourceLoader->>Protocol: NormalizePathWithExecutionId(modulePath, executionId)
  Protocol->>FS: resolve path, check IsPathWithinDirectory(templateDir)
  FS-->>Protocol: resolved normalized path
  Protocol-->>SourceLoader: normalized filesystem path or error
  SourceLoader->>FS: read module file (if permitted)
  FS-->>SourceLoader: module source or permission error
  SourceLoader-->>Goja: provide module source or throw
  Goja->>Runner: execution result / error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hopped through paths both clean and sly,

Checked symlinks, bounds, and why-not-try;
Each runtime now holds its own small key,
Templates guarded where they ought to be,
A rabbit cheers — secure modules, oh my! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(js): respect allow-local-file-access in require' directly and clearly summarizes the main change: enforcing the allow-local-file-access setting in JavaScript require() statements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dwisiswant0/fix/js/respect-allow-local-file-access-in-require

Comment @coderabbitai help to get the list of available commands and usage tips.

@dwisiswant0
Copy link
Copy Markdown
Member Author

@coderabbitai create an issue for this and link it to this PR, and it must be bug-oriented, also starts with "[BUG] " for the issue title.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 7, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

@dwisiswant0, I've created the bug issue and linked it to this PR. You can find it above once the script executes. The issue captures:

  • The root cause: require() using the default host filesystem loader, bypassing allow-local-file-access
  • The cache leak: pooled runtimes retaining module state across privileged/restricted executions
  • A link back to this PR (#7332) as the fix

🏁 Script executed:

#!/bin/bash
gh issue create \
  --repo projectdiscovery/nuclei \
  --title "[BUG] allow-local-file-access not respected in JavaScript require() function" \
  --body "## Bug Report

### Description

The \`require()\` function (powered by [goja](https://github.com/dop251/goja)) used the default host filesystem loader, which allowed JavaScript templates to import arbitrary local files even when the \`allow-local-file-access\` configuration option was explicitly **disabled**.

Additionally, pooled runtimes retained \`require()\` state across executions. This meant a module loaded during a **privileged** execution could remain cached and be accessible during a later **restricted** execution, effectively bypassing the sandbox.

### Steps to Reproduce

1. Disable \`allow-local-file-access\` in nuclei configuration.
2. Create a JavaScript template that uses \`require()\` to load a local file.
3. Run nuclei with the template.

**Expected behavior:** The \`require()\` call should be blocked/sandboxed when \`allow-local-file-access\` is disabled.

**Actual behavior:** The \`require()\` call succeeds and loads the local file, bypassing the \`allow-local-file-access\` restriction.

### Root Cause

- The goja require registry was globally shared and not rebuilt per-execution, so execution-specific context (including sandbox rules) was not applied to module loading.
- The cached module state from privileged executions leaked into subsequent restricted executions via the pooled runtime's require cache.

### Related PR

Fixed in: https://github.com/projectdiscovery/nuclei/pull/7332

### Reported by

`@dwisiswant0`" \
  --assignee dwisiswant0

Length of output: 54


@dwisiswant0, the bug issue has been created and is available here: #7333

Issue summary:

  • Title: [BUG] allow-local-file-access not respected in JavaScript require() function
  • Assigned to: @dwisiswant0
  • Linked to: PR #7332 (this PR as the fix)

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/js/compiler/pool.go`:
- Around line 239-253: The newSourceLoader's current behavior silently
substitutes an empty executionId when runtime.GetContextValue("executionId") is
missing, causing NormalizePathWithExecutionId to treat the call as a normal
LFA-disabled path; change this to fail fast by returning an error when the
executionId is absent or not a string. In newSourceLoader (require.SourceLoader
closure) check runtime.GetContextValue("executionId") and if ok==false or the
value isn't a string, return a descriptive error (rather than continuing with
executionID=""), then call protocolstate.NormalizePathWithExecutionId and
require.DefaultSourceLoader as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9b2391c9-7216-4f35-87d6-e14680443aae

📥 Commits

Reviewing files that changed from the base of the PR and between a2fb27a and f7b59de.

📒 Files selected for processing (2)
  • pkg/js/compiler/compiler_test.go
  • pkg/js/compiler/pool.go

Replace lexical prefix checks in the template file
sandbox with a shared path containment helper that
canonicalizes both paths before comparing them to
prevent false rejections when the configured
templates directory and the resolved file path
differ only due to symlink expansion on macOS or
path normalization on Windows.

Apply the helper in `protocolstate.NormalizePath()`
and `Options.GetValidAbsPath()` so JS `require()`-
based module loads and helper file resolution use
the same rules.

Signed-off-by: Dwi Siswanto <git@dw1.io>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/types/types.go (1)

900-901: ⚠️ Potential issue | 🔴 Critical

Replace remaining HasPrefix path checks; they are sandbox-bypass prone.

Line 900 and Line 925 still use lexical prefix checks for path boundaries. That can incorrectly allow sibling paths (e.g., /home/u/templates2 vs /home/u/templates) and weakens local-file restrictions. Replace with filepathutil.IsPathWithinDirectory(), which correctly canonicalizes paths (handling symlinks and absolute paths) and uses semantic path relationships via filepath.Rel() rather than unsafe string prefixing.

🔧 Proposed fix
@@
-	if isHomeDir(helperFilePath) && isHomeDir(templatePath) && strings.HasPrefix(filepath.Dir(helperFilePath), filepath.Dir(templatePath)) {
-		return helperFilePath, nil
-	}
+	if isHomeDir(helperFilePath) && isHomeDir(templatePath) {
+		helperDir := filepath.Dir(helperFilePath)
+		templateDir := filepath.Dir(templatePath)
+		if filepathutil.IsPathWithinDirectory(helperDir, templateDir) {
+			return helperFilePath, nil
+		}
+	}
@@
 func isHomeDir(path string) bool {
 	homeDir := folderutil.HomeDirOrDefault("")
-	return strings.HasPrefix(path, homeDir)
+	return filepathutil.IsPathWithinDirectory(path, homeDir)
 }

Also applies to: 923-925

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/types/types.go` around lines 900 - 901, The current lexical path check
using strings.HasPrefix(filepath.Dir(helperFilePath),
filepath.Dir(templatePath)) is unsafe; update the conditional(s) that compare
helperFilePath and templatePath (including the other occurrence near the same
block) to use filepathutil.IsPathWithinDirectory (or equivalent canonicalizing
helper) to determine semantic containment after resolving paths/symlinks—replace
the HasPrefix-based checks with calls to
filepathutil.IsPathWithinDirectory(templatePath, helperFilePath) (or the
library's argument order) so the code in the helper/template validation branches
uses canonicalized path containment rather than string prefixing.
🧹 Nitpick comments (1)
pkg/utils/filepath/filepath_test.go (1)

33-55: Add a negative symlink-escape test case for regression safety.

Given this helper is security-sensitive, please also assert that a symlink inside the base directory pointing outside is rejected.

🧪 Proposed additional test
+func TestIsPathWithinDirectoryRejectsSymlinkEscape(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("symlink creation is not reliable on all Windows runners")
+	}
+
+	baseDir := t.TempDir()
+	outsideDir := t.TempDir()
+	outsideFile := filepath.Join(outsideDir, "secret.txt")
+	if err := os.WriteFile(outsideFile, []byte("secret"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+
+	linkPath := filepath.Join(baseDir, "escape")
+	if err := os.Symlink(outsideDir, linkPath); err != nil {
+		t.Fatalf("create symlink: %v", err)
+	}
+
+	escapedPath := filepath.Join(linkPath, "secret.txt")
+	if IsPathWithinDirectory(escapedPath, baseDir) {
+		t.Fatalf("expected %q to be outside %q via symlink escape", escapedPath, baseDir)
+	}
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/utils/filepath/filepath_test.go` around lines 33 - 55, Add a negative
symlink-escape test to ensure IsPathWithinDirectory rejects symlinks inside the
base that point outside: in the TestIsPathWithinDirectoryWithSymlinkedDirectory
test (or a new test nearby), create an external temp file or dir outside
realDir, then inside the aliasDir/base create a symlink (e.g., aliasDir/escape
-> outsidePath) and assert IsPathWithinDirectory(escapePath, aliasDir) returns
false (and fail the test if it returns true); this uses the existing helper
IsPathWithinDirectory and mirrors the positive symlink test structure but
verifies rejection of symlink escape.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@pkg/types/types.go`:
- Around line 900-901: The current lexical path check using
strings.HasPrefix(filepath.Dir(helperFilePath), filepath.Dir(templatePath)) is
unsafe; update the conditional(s) that compare helperFilePath and templatePath
(including the other occurrence near the same block) to use
filepathutil.IsPathWithinDirectory (or equivalent canonicalizing helper) to
determine semantic containment after resolving paths/symlinks—replace the
HasPrefix-based checks with calls to
filepathutil.IsPathWithinDirectory(templatePath, helperFilePath) (or the
library's argument order) so the code in the helper/template validation branches
uses canonicalized path containment rather than string prefixing.

---

Nitpick comments:
In `@pkg/utils/filepath/filepath_test.go`:
- Around line 33-55: Add a negative symlink-escape test to ensure
IsPathWithinDirectory rejects symlinks inside the base that point outside: in
the TestIsPathWithinDirectoryWithSymlinkedDirectory test (or a new test nearby),
create an external temp file or dir outside realDir, then inside the
aliasDir/base create a symlink (e.g., aliasDir/escape -> outsidePath) and assert
IsPathWithinDirectory(escapePath, aliasDir) returns false (and fail the test if
it returns true); this uses the existing helper IsPathWithinDirectory and
mirrors the positive symlink test structure but verifies rejection of symlink
escape.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0a68514d-de8d-4e2f-beb9-2400d85ef715

📥 Commits

Reviewing files that changed from the base of the PR and between f7b59de and 02563b4.

📒 Files selected for processing (6)
  • pkg/js/compiler/compiler_test.go
  • pkg/protocols/common/protocolstate/file.go
  • pkg/types/types.go
  • pkg/utils/filepath/doc.go
  • pkg/utils/filepath/filepath.go
  • pkg/utils/filepath/filepath_test.go
✅ Files skipped from review due to trivial changes (1)
  • pkg/utils/filepath/doc.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/js/compiler/compiler_test.go

@dwisiswant0 dwisiswant0 merged commit 6f2ade6 into dev Apr 10, 2026
21 checks passed
@dwisiswant0 dwisiswant0 deleted the dwisiswant0/fix/js/respect-allow-local-file-access-in-require branch April 10, 2026 01:06
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.

[BUG] allow-local-file-access not respected in JavaScript require() function

2 participants