← Runnable Projects

阅读中文版本

Node.js Static File Server with Traversal Protection

A dependency-free static file server with content-type detection, 404 handling, and a path-traversal guard, verified by a test that serves a temporary directory.

beginnerjavascriptverified-runnable

Project

node-static-server

Entry point: server.mjs

Install

node --version

Run

node server.mjs ./public

Test

node --test server.test.mjs

Expected output

Serves index.html and CSS with correct content types, returns 404 for missing files, and 403 for encoded traversal attempts.

Dependencies

  • node >=20

Verification evidence

Node 24.15: node --test -> 1 passed; index returned 200 with HTML, CSS returned text/css, missing returned 404, and ../ traversal attempts returned 403.

Security note

The guard rejects resolved paths outside the served root, but production servers should also set caching, compression, and request-size limits.

What it does

Serves files from a root directory with basic MIME types and a path-traversal guard. The guard resolves the requested path and rejects anything outside the configured root.

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, resolve, sep } from 'node:path';
import { pathToFileURL } from 'node:url';

const TYPES = {
  '.html': 'text/html; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.js': 'text/javascript; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.txt': 'text/plain; charset=utf-8',
};

export function createStaticServer(rootDir) {
  const root = resolve(rootDir);
  return createServer(async (req, res) => {
    const pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
    const filePath = resolve(join(root, pathname === '/' ? 'index.html' : pathname));
    if (filePath !== root && !filePath.startsWith(root + sep)) {
      res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
      res.end('forbidden');
      return;
    }
    try {
      const data = await readFile(filePath);
      res.writeHead(200, { 'content-type': TYPES[extname(filePath)] ?? 'application/octet-stream' });
      res.end(data);
    } catch {
      res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
      res.end('not found');
    }
  });
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const root = process.argv[2] ?? '.';
  createStaticServer(root).listen(3000, () => console.log('serving ' + root + ' on 3000'));
}

Test

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createStaticServer } from './server.mjs';

test('serves files and blocks traversal', async () => {
  const root = mkdtempSync(join(tmpdir(), 'static-'));
  writeFileSync(join(root, 'index.html'), '<h1>hello</h1>');
  writeFileSync(join(root, 'style.css'), 'body{}');
  const server = createStaticServer(root).listen(0);
  const { port } = server.address();
  try {
    assert.equal((await fetch(`http://127.0.0.1:${port}/`)).status, 200);
    assert.match((await fetch(`http://127.0.0.1:${port}/style.css`)).headers.get('content-type') ?? '', /text\/css/);
    assert.equal((await fetch(`http://127.0.0.1:${port}/missing.txt`)).status, 404);
    assert.equal((await fetch(`http://127.0.0.1:${port}/..%2f..%2fsecret.txt`)).status, 403);
  } finally {
    server.close();
    rmSync(root, { recursive: true, force: true });
  }
});

Run and verify

Run node --test server.test.mjs, then node server.mjs ./public and request /, /style.css, a missing file, and an encoded traversal path.

Expected output: / returns 200 with HTML, /style.css returns text/css, a missing file returns 404, and encoded traversal returns 403. Verified with Node 24.15: the test passed.

Limitations

No directory index, range requests, caching headers, or compression. Put a production reverse proxy or CDN in front for those features.