high · 7.5Jun 26, 2026

better-helperjs Path Traversal via String Prefix Bypass in Static Server

Shubham Kandhare
Security Engagement Manager, SecureLayer7

The production static file server in better-helperjs lets attackers read files from adjacent directories on disk by exploiting a flawed string prefix check that mistakes a shared directory name…

Packagebetter-helperjs
Ecosystemnpm
Affected<= 3.0.5
Fixed in3.0.6

The problem

In versions up to and including 3.0.5, the safeStaticPath() function in src/ssr/site-server.ts validates a resolved file path using resolved.startsWith(root). That check treats the root directory string as a plain text prefix, not a filesystem boundary.

If the static root is /app/dist/client, the resolved path /app/dist/client-secrets/database.sqlite passes the check because the string literally starts with /app/dist/client. An unauthenticated remote attacker can therefore read any file in a sibling directory whose name begins with the same prefix as the configured static root.

This only triggers in NODE_ENV=production; the Vite dev middleware used during development is not affected.

Proof of concept

A working proof-of-concept for this issue in better-helperjs, with the exact payload below.

http
GET /%2e%2e%2fclient-secrets/secret.txt HTTP/1.1
Host: localhost:4174
Connection: close

The percent-encoded %2e%2e%2f (../) is decoded before path.resolve() runs, so the server resolves the request to /app/dist/client-secrets/secret.txt. Because that string starts with the root /app/dist/client, the old startsWith(root) guard passes and the file is served.

The patch tightens the check to resolved.startsWith(root + path.sep) || resolved === root. Appending path.sep (/ on POSIX, \ on Windows) means a path must start with /app/dist/client/, so /app/dist/client-secrets/... no longer matches. The root cause is CWE-22: the validator used lexical string comparison instead of canonical path boundary comparison.

The fix

Upgrade better-helperjs to version 3.0.6 or later. The fix is in safeStaticPath(): the guard is now !resolved.startsWith(root + path.sep) && resolved !== root. If you cannot upgrade immediately, ensure no sensitive directories exist adjacent to your static build output directory that share the same name prefix (for example, avoid having both dist/client and dist/client-secrets on the same host).

Reporter not attributed.

References: [1][2]

Related research