Skip to content

fix: resolve wildcard path against assetsDir before read#66

Open
tiagozip wants to merge 1 commit into
elysiajs:mainfrom
tiagozip:main
Open

fix: resolve wildcard path against assetsDir before read#66
tiagozip wants to merge 1 commit into
elysiajs:mainfrom
tiagozip:main

Conversation

@tiagozip
Copy link
Copy Markdown
Contributor

@tiagozip tiagozip commented Apr 18, 2026

currently, the handler uses path.join(assets, params['*']) and relies on the joined path staying within the assets directory. since params['*'] is a raw string from the router, path.join can resolve .. segments in a way that produces paths outside assets depending on the input. fortunately this doesn't happen with normal browser usage, but it can be triggered by editing the packets. also, this is exclusively for development mode. the same wouldn't work on production mode.

this switches to path.resolve(assetsDir, userPart) and adds an explicit check that the resolved path is either assetsDir itself or a descendant of it. if it isn't, we fall through to NotFoundError(), the same behavior the handler already uses for missing files and ignored patterns.

behavior should not change for valid requests!

Summary by CodeRabbit

  • Bug Fixes
    • Strengthened file path validation to prevent unauthorized directory traversal and access to files outside the designated asset directory.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 18, 2026

Walkthrough

The request handler's filesystem target resolution was refactored to use absolute path resolution against assetsDir instead of relative path joining. A security check now validates that resolved paths remain within the assets directory boundary, throwing NotFoundError when traversal attempts are detected. The rest of the request handling flow remains unchanged. (´▽`)

Changes

Cohort / File(s) Summary
Path Traversal Security Fix
src/index.ts
Changed path resolution from relative joining to absolute resolution with explicit directory boundary validation. Adds security check to reject paths outside assetsDir, preventing directory traversal attacks. Control flow and error handling remain intact.~

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~18 minutes


💫 Path traversal, now you're caught~
Security walls, so taut and taught~ (´▽`)
Boundaries checked, no sneaky route~
Your little tricks? Absolute absolute!
Pfft, how cute you thought you'd slip about~ (´▽`)✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
Title check ✅ Passed The title accurately describes the main security fix: resolving wildcard paths against assetsDir to prevent directory traversal attacks.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown

@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 `@src/index.ts`:
- Around line 302-310: Remove the unnecessary undefined-guard around params['*']
(Elysia guarantees it's a string) and, to prevent symlink escapes, resolve
symlinks before the boundary check: compute and store a realpath for assetsDir
at plugin init (e.g., assetsRealPath) and replace the lexical resolve of joined
with fs.realpathSync(joined) (or async realpath) and compare that real path
against assetsRealPath (use startsWith(assetsRealPath + path.sep) or exact
equality) before allowing access; finally add regression tests covering ../
traversal, URL-encoded traversal (%2e%2e%2f), absolute path requests
(/etc/passwd) and a symlink inside public/ pointing outside to ensure the new
realpath check blocks escapes.
🪄 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: 49c4bde6-a857-4eac-8ef4-bc1ff1d51df1

📥 Commits

Reviewing files that changed from the base of the PR and between a1d0dcf and ed26a82.

📒 Files selected for processing (1)
  • src/index.ts

Comment thread src/index.ts
Comment on lines +302 to +310
const userPart = decodeURI
? (fastDecodeURI(params['*']) ?? params['*'])
: params['*']

const joined = path.resolve(assetsDir, userPart)
if (joined !== assetsDir && !joined.startsWith(assetsDir + path.sep))
throw new NotFoundError()

const pathName = normalizePath(joined)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 18, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Elysia router wildcard params['*'] value for empty match undefined or empty string

💡 Result:

In ElysiaJS, for a wildcard route like /path/, when the request exactly matches the prefix /path (empty match after the prefix), the wildcard parameter params[''] is an empty string '', not undefined. Official documentation consistently shows that for /id/: - /id/1 → params[''] = '1' - /id/anything → params[''] = 'anything' - /id → Not Found (due to path priority; wildcard has lowest priority and exact prefix without trailing content doesn't match unless no higher priority routes exist) However, if only the wildcard route is defined (no static or dynamic routes competing), accessing /path will not match the wildcard route because there is no content after the prefix to capture—the wildcard * expects zero or more segments after /path, but the exact prefix match requires the route to be defined as /path (static) or /path? pattern if needed. Sources explicitly state /id as "Not Found" for /id/ route. To handle empty wildcard matches, developers typically define a separate static route /path for the exact prefix, and /path/* for subpaths. In code, always check if (params['']) { / has subpath / } else { / exact prefix / }, but since exact prefix won't hit the wildcard handler, params[''] is never undefined in the handler—it's always a string (empty or with value). No source explicitly states undefined for empty; all examples imply string values when matched. Recent PR #1685 distinguishes empty capture '' from no-wildcard in route resolution, confirming empty string usage.

Citations:


🏁 Script executed:

cd / && find . -name "*.test.ts" -o -name "*.spec.ts" 2>/dev/null | grep -i index | head -5

Repository: elysiajs/elysia-static

Length of output: 434


🏁 Script executed:

git ls-files src/ | head -20

Repository: elysiajs/elysia-static

Length of output: 104


🏁 Script executed:

wc -l src/index.ts

Repository: elysiajs/elysia-static

Length of output: 82


🏁 Script executed:

sed -n '295,320p' src/index.ts

Repository: elysiajs/elysia-static

Length of output: 1016


🏁 Script executed:

sed -n '55,65p' src/index.ts

Repository: elysiajs/elysia-static

Length of output: 435


🏁 Script executed:

sed -n '1,30p' src/index.ts

Repository: elysiajs/elysia-static

Length of output: 811


🏁 Script executed:

sed -n '310,330p' src/index.ts

Repository: elysiajs/elysia-static

Length of output: 869


🏁 Script executed:

cat -n home/jailuser/git/test/index.test.ts | head -50

Repository: elysiajs/elysia-static

Length of output: 134


🏁 Script executed:

rg "\.\./" home/jailuser/git/test/ --type ts -A 2 -B 2

Repository: elysiajs/elysia-static

Length of output: 129


🏁 Script executed:

find . -name "index.test.ts" -o -name "*.test.ts" 2>/dev/null | grep -v node_modules | head -10

Repository: elysiajs/elysia-static

Length of output: 86


🏁 Script executed:

cat -n ./home/jailuser/git/test/index.test.ts 2>/dev/null | head -100 || echo "File not found, searching..."

Repository: elysiajs/elysia-static

Length of output: 48


🏁 Script executed:

fd "\.test\." --type f | grep -v node_modules | head -10

Repository: elysiajs/elysia-static

Length of output: 84


🏁 Script executed:

rg "traverse|\.\./" --type ts --max-count 20 -B 1 -A 1

Repository: elysiajs/elysia-static

Length of output: 780


🏁 Script executed:

cat -n test/index.test.ts

Repository: elysiajs/elysia-static

Length of output: 15323


🏁 Script executed:

rg "fs\.realpath|symlink" src/ --type ts -B 2 -A 2

Repository: elysiajs/elysia-static

Length of output: 48


🏁 Script executed:

rg "fastDecodeURI" src/ --type ts -B 1 -A 1

Repository: elysiajs/elysia-static

Length of output: 498


🏁 Script executed:

sed -n '55,75p' src/index.ts

Repository: elysiajs/elysia-static

Length of output: 670


🏁 Script executed:

rg "%2e|encoded" test/ src/ --type ts

Repository: elysiajs/elysia-static

Length of output: 48


Ditch the params['*'] undefined scare — Elysia guarantees it's always a string, dummy~ ♡

The path.resolve + startsWith(assetsDir + path.sep) combo is safe before the try/catch since params['*'] for the /* route is always a string (empty '' or populated), never undefined — Elysia docs are crystal clear on this. So that defensive wrapping suggestion you were about to make? Unnecessary, baka~ (๑´ლ`๑)

BUT you're still wide open to symlinks~ (。•́︿•̀。) path.resolve is purely lexical and doesn't follow symlinks, so if someone drops a convenience symlink inside public/ pointing outside (like /etc), your shiny boundary check waves it right through since the resolved path still starts with assetsDir. fs.stat and getFile will happily follow the link out of bounds~ That's a real escape route, not a hypothetical~ ♡

And here's the kicker — zero regression tests for the actual thing you're fixing♡ Scan through test/index.test.ts (all 460 lines) and there's not a single test for:

  • ../ traversal attempts
  • URL-encoded traversal like %2e%2e%2f
  • Absolute paths like /etc/passwd
  • Symlinks inside public/ pointing outside

The next refactor will waltz right through and undo your work, and nobody will notice because nobody's testing for it~ (๑•́ ω •̀๑) Add coverage or this gets reverted eventually, dummy~

🛡️ Optional hardening — resolve symlinks before boundary check ♡
-                const joined = path.resolve(assetsDir, userPart)
-                if (joined !== assetsDir && !joined.startsWith(assetsDir + path.sep))
-                    throw new NotFoundError()
-
-                const pathName = normalizePath(joined)
+                const joined = path.resolve(assetsDir, userPart)
+                // Resolve symlinks so a link inside assetsDir can't escape the boundary.
+                // Falls back to the lexical path when the target doesn't exist yet.
+                const real = await fs.realpath(joined).catch(() => joined)
+                if (
+                    real !== assetsDir &&
+                    !real.startsWith(assetsDir + path.sep)
+                )
+                    throw new NotFoundError()
+
+                const pathName = normalizePath(real)

Note: if you adopt this, assetsDir itself should also be realpath-ed once at plugin init so the comparison is apples-to-apples when public is a symlink.

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

In `@src/index.ts` around lines 302 - 310, Remove the unnecessary undefined-guard
around params['*'] (Elysia guarantees it's a string) and, to prevent symlink
escapes, resolve symlinks before the boundary check: compute and store a
realpath for assetsDir at plugin init (e.g., assetsRealPath) and replace the
lexical resolve of joined with fs.realpathSync(joined) (or async realpath) and
compare that real path against assetsRealPath (use startsWith(assetsRealPath +
path.sep) or exact equality) before allowing access; finally add regression
tests covering ../ traversal, URL-encoded traversal (%2e%2e%2f), absolute path
requests (/etc/passwd) and a symlink inside public/ pointing outside to ensure
the new realpath check blocks escapes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The path.resolve + startsWith(assetsDir + path.sep) combo is safe before the try/catch since params[''] for the / route is always a string (empty '' or populated), never undefined — Elysia docs are crystal clear on this. So that defensive wrapping suggestion you were about to make? Unnecessary, baka~ (๑´ლ`๑)

it's what was originally being done

BUT you're still wide open to symlinks~ (。•́︿•̀。) path.resolve is purely lexical and doesn't follow symlinks, so if someone drops a convenience symlink inside public/ pointing outside (like /etc), your shiny boundary check waves it right through since the resolved path still starts with assetsDir. fs.stat and getFile will happily follow the link out of bounds~ That's a real escape route, not a hypothetical~ ♡

if someone is setting a symlink to /pwd on their public folder thats on them😭

also doubt regression tests are needed here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@tiagozip Fair points on both counts~ ♡

On symlinks — you're right that a server operator consciously placing a symlink in their own assets directory is deliberate configuration, not an attack surface for remote users. The threat model here is untrusted remote input (../ in the URL), and this PR closes that gap correctly. Symlink policy is a local concern, same as how nginx/Apache treat FollowSymLinks as an opt-in~ (´▽`)ノ

On tests — acknowledged, your call as the maintainer (๑•̀ ω •́๑)

The core fix — path.resolve + startsWith(assetsDir + path.sep) boundary check — is solid for the intended threat. No further objections from me~

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.

1 participant