The Vulnerability in Context
The wasm-build/server.js file is a lightweight Node.js HTTP server that serves static files for a WebAssembly demo. It reads a URL from an incoming request, maps it to a file on disk, and streams the file back to the client. Simple enough—but two distinct security flaws were hiding in that straightforward logic:
- Path traversal: User-controlled URL input was joined directly to the server's base directory without sanitizing
../sequences, allowing an attacker to escape the web root and read arbitrary files on the host. - Resource exhaustion: No connection limits, timeouts, or request-size constraints existed, leaving the server open to denial-of-service attacks from connection flooding.
Together, these issues could let an attacker exfiltrate sensitive files from the host machine and take the server offline. Because this file ships in a Node.js library, both risks cascade to every downstream consumer of the package.
The Vulnerability Explained
Path Traversal (CWE-22)
Here is the original code at the heart of the problem (around line 23 in server.js):
// BEFORE — vulnerable
let filePath = req.url === '/' ? '/demo.html' : req.url;
filePath = path.join(__dirname, filePath);
req.url is raw, attacker-controlled input. path.join() does resolve .. segments—but it resolves them relative to the joined result, which means a crafted URL can still produce a path that escapes __dirname.
Concrete attack scenario:
GET /../../../../etc/passwd HTTP/1.1
Host: localhost:8000
path.join('/app/wasm-build', '/../../../../etc/passwd') resolves to /etc/passwd on Linux. The server then calls fs.access() and streams the file back. No authentication, no boundary check, no error—just the raw contents of /etc/passwd (or any other file the Node.js process can read).
On a developer machine or CI environment, this could expose SSH keys (~/.ssh/id_rsa), environment files (.env), cloud credentials, or application secrets.
Resource Exhaustion / DoS (CWE-400)
The original http.createServer() call set no limits at all:
// BEFORE — no limits
const server = http.createServer((req, res) => { ... });
server.listen(PORT, () => { ... });
Node.js defaults leave maxConnections unbounded and timeouts at several minutes. An attacker running:
ab -n 100000 -c 500 http://target:8000/
…can exhaust the server's file descriptors, memory, and CPU within seconds. Slow-loris style attacks—opening many connections and drip-feeding headers—are equally effective because headersTimeout was never set.
The Fix
The pull request makes two targeted changes to wasm-build/server.js.
Fix 1: Path Sanitization and Boundary Enforcement
// AFTER — safe
let urlPath = req.url === '/' ? '/demo.html' : req.url;
// Sanitize the requested path before use: strip any traversal segments
urlPath = path.normalize(urlPath).replace(/^(\.\.[\/\\])+/, '');
let filePath = path.join(__dirname, urlPath);
// Prevent path traversal outside of __dirname
if (!filePath.startsWith(__dirname)) {
res.writeHead(403, { 'Content-Type': 'text/html' });
res.end('<h1>403 - Forbidden</h1>');
return;
}
Three things happen here:
| Step | What it does | Why it matters |
|---|---|---|
path.normalize(urlPath) |
Resolves . and .. segments in the URL path |
Canonicalizes the path before any further processing |
.replace(/^(\.\.[\/\\])+/, '') |
Strips any leading ../ or ..\ sequences |
Removes traversal attempts at the start of the normalized path |
filePath.startsWith(__dirname) |
Asserts the final resolved path is inside the web root | Defense-in-depth: catches any edge case the regex misses |
The startsWith(__dirname) check is the critical defense-in-depth layer. Even if a creative encoding or platform quirk slips past the regex, the boundary assertion will catch it and return a 403 Forbidden response.
Fix 2: Connection and Timeout Limits
// AFTER — resource limits enforced
server.maxConnections = 100;
server.timeout = 30000;
server.headersTimeout = 10000;
server.requestTimeout = 10000;
| Setting | Value | Effect |
|---|---|---|
maxConnections |
100 | Caps simultaneous open sockets; new connections are queued or refused beyond this |
timeout |
30 000 ms | Closes idle keep-alive sockets after 30 seconds |
headersTimeout |
10 000 ms | Aborts connections that don't complete headers within 10 seconds (mitigates slow-loris) |
requestTimeout |
10 000 ms | Aborts requests that take longer than 10 seconds to complete |
These four lines transform an unlimited server into one with predictable resource consumption under adversarial load.
Key Takeaways
path.join(__dirname, req.url)is not safe by itself. You must also callpath.normalize(), strip leading traversal sequences, and assert the result starts with your base directory—all three steps together.- The
startsWith(__dirname)boundary check is the last line of defense. Even if path normalization and regex stripping are bypassed by an unexpected encoding, this assertion catches the escape attempt. - Node.js HTTP servers are unbounded by default. Without explicit
maxConnections,headersTimeout, andrequestTimeout, any production server is trivially vulnerable to resource exhaustion. - This demo server shipped in a library package. "Demo" or "build tool" code that gets distributed still needs to be hardened—downstream consumers may run it in environments you don't control.
- Four lines of configuration (
server.maxConnections,server.timeout,server.headersTimeout,server.requestTimeout) eliminated the DoS surface entirely. Defense doesn't always require architectural changes.
How Orbis AppSec Detected This
- Source:
req.url— raw, attacker-controlled input from every incoming HTTP request - Sink:
path.join(__dirname, filePath)followed byfs.access(filePath, ...)inwasm-build/server.js:23, where the unvalidated path is used for filesystem access - Missing control: No path normalization, no traversal-sequence stripping, and no boundary assertion that the resolved path remains inside
__dirname; additionally, nomaxConnections,headersTimeout, orrequestTimeouton the server instance - CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory) and CWE-400 (Uncontrolled Resource Consumption)
- Fix: The path is now normalized and stripped of traversal sequences before joining, a
startsWith(__dirname)guard returns 403 on escape attempts, and four server-level resource limits cap connection and request duration
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
Two vulnerabilities in roughly 10 lines of code in wasm-build/server.js combined to create a serious risk: arbitrary file read via path traversal and server crash via connection flooding. The path traversal fix demonstrates a pattern every Node.js developer should internalize—path.join() is not a sanitizer, and user-controlled paths must always be validated against a boundary after resolution. The resource exhaustion fix is a reminder that Node.js ships with no connection limits by default, and that four lines of configuration can make the difference between a resilient server and one that falls over under trivial load.
Security in static file servers is easy to overlook because the logic feels simple. As this case shows, simple logic and serious vulnerabilities are not mutually exclusive.