Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/1.guide/10.cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ npx srvx --static ./dist

If `--static` is omitted, srvx serves files from a `public/` directory when one exists. When both a server entry and a static directory are present, static files take priority and unmatched requests fall through to your handler.

Static serving includes automatic `index.html` resolution, `.html` extension fallback (e.g. `/about` → `about.html`), common MIME types, gzip/Brotli compression, and path-traversal protection.
Static serving includes automatic `index.html` resolution, `.html` extension fallback (e.g. `/about` → `about.html`), common MIME types, gzip/Brotli compression, and `ETag` / `Last-Modified` validators with `304` conditional-request handling.

Requests are also hardened against path traversal and symlinks that escape the served directory. Dotfiles are never served (`.env`, `.env.local`, `.git/...`), with one exception: [`/.well-known/`](https://datatracker.ietf.org/doc/html/rfc8615) stays reachable so ACME/Let's Encrypt challenges, `security.txt`, and `assetlinks.json` work. Paths under `/.well-known/` are served verbatim, without the `.html` fallback.

For programmatic usage, import the [`serveStatic`](https://github.com/h3js/srvx/blob/main/src/static.ts) middleware:

Expand Down
264 changes: 233 additions & 31 deletions src/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ServerMiddleware } from "./types.ts";
import type { Transform } from "node:stream";

import { extname, join, resolve, sep } from "node:path";
import { readFile, stat } from "node:fs/promises";
import { readFile, stat, realpath } from "node:fs/promises";
import { createReadStream, ReadStream } from "node:fs";
import { FastResponse } from "srvx";
import { createGzip, createBrotliCompress } from "node:zlib";
Expand All @@ -19,6 +19,15 @@ export interface ServeStaticOptions {
*/
methods?: string[];

/**
* Value for the `Cache-Control` response header.
*
* Defaults to a conservative `"public, max-age=0, must-revalidate"` which
* lets clients cache but forces revalidation (via `ETag`/`Last-Modified`)
* on every request. Set to `false` to omit the header entirely.
*/
cacheControl?: string | false;

/**
* A function to modify the HTML content before serving it.
*/
Expand All @@ -39,73 +48,266 @@ const COMMON_MIME_TYPES: Record<string, string> = {
".json": "application/json",
".txt": "text/plain",
".xml": "application/xml",
".wasm": "application/wasm",
".gif": "image/gif",
".ico": "image/vnd.microsoft.icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".avif": "image/avif",
".woff": "font/woff",
".woff2": "font/woff2",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".webm": "video/webm",
".zip": "application/zip",
".gz": "application/gzip",
".br": "application/x-brotli",
".pdf": "application/pdf",
};

/**
* Whether a MIME type benefits from compression. Already-compressed binary
* formats (images, video, audio, archives, fonts) are excluded so we never
* waste CPU re-encoding them.
*/
function isCompressible(mimeType: string): boolean {
const type = mimeType.split(";", 1)[0].trim();
return (
type.startsWith("text/") ||
type === "application/json" ||
type === "application/xml" ||
type === "application/javascript" ||
type === "application/wasm" ||
type === "image/svg+xml" ||
type.endsWith("+json") ||
type.endsWith("+xml")
);
}

/**
* Parse an `Accept-Encoding` header into a `token -> q-value` map, honoring
* q-values (so `br;q=0` disables brotli) and only matching exact tokens (so a
* value like `abbr` never matches `br`).
*/
function parseAcceptEncoding(header: string): Map<string, number> {
const map = new Map<string, number>();
for (const part of header.split(",")) {
const [token, ...params] = part.trim().split(";");
const name = token.trim().toLowerCase();
if (!name) {
continue;
}
let q = 1;
for (const param of params) {
const match = /^q=(\d+(?:\.\d+)?)$/.exec(param.trim());
if (match) {
q = Number.parseFloat(match[1]);
}
}
map.set(name, q);
}
return map;
}

/**
* Negotiate a content encoding from an `Accept-Encoding` header, preferring
* brotli, then gzip, and falling back to identity (`undefined`) when neither is
* acceptable (q=0 / absent).
*/
function negotiateEncoding(header: string): "br" | "gzip" | undefined {
if (!header) {
return undefined;
}
const map = parseAcceptEncoding(header);
const star = map.get("*");
const qOf = (name: string): number => {
const direct = map.get(name);
if (direct !== undefined) {
return direct;
}
return star ?? 0;
};
const brQ = qOf("br");
const gzipQ = qOf("gzip");
if (brQ > 0 && brQ >= gzipQ) {
return "br";
}
if (gzipQ > 0) {
return "gzip";
}
return undefined;
Comment on lines +120 to +141

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file before reading the specific slice.
ast-grep outline src/static.ts --view expanded || true

printf '\n--- src/static.ts (selected lines) ---\n'
sed -n '1,220p' src/static.ts | cat -n

printf '\n--- Search for negotiateEncoding usages and 406 handling ---\n'
rg -n "negotiateEncoding|406|identity|Accept-Encoding|parseAcceptEncoding" src/static.ts src -S

Repository: h3js/srvx

Length of output: 11252


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the parts that decide whether to serve compressed, identity, or 406.
sed -n '120,220p' src/static.ts | cat -n
sed -n '220,340p' src/static.ts | cat -n

Repository: h3js/srvx

Length of output: 8567


🏁 Script executed:

#!/bin/bash
set -euo pipefail
pwd
git ls-files src/static.ts
sed -n '1,360p' src/static.ts | cat -n

Repository: h3js/srvx

Length of output: 12887


🌐 Web query:

RFC 9110 Accept-Encoding identity q=0 *;q=0 no acceptable representation 406

💡 Result:

Under RFC 9110, the Accept-Encoding header field is used by a client to specify the content codings that are acceptable in the response [1][2]. The identity content coding, which represents the original, uncompressed data, is always considered acceptable by default unless it is specifically excluded [3][4]. A client can explicitly exclude the identity coding by including identity;q=0 in the Accept-Encoding header [3]. Similarly, if a client includes the wildcard *;q=0, this also excludes all content codings, including identity, unless a specific, higher q-value is provided for identity (e.g., *;q=0, identity;q=1) [3][5][6]. When a client explicitly excludes all available content codings—for instance, by sending Accept-Encoding: identity;q=0 when the server cannot provide any compressed representations—the server is unable to provide an acceptable representation [3][7][5]. In such cases, the server should respond with a 406 (Not Acceptable) status code to indicate that no suitable representation could be produced [3][1][7]. Essentially, sending identity;q=0 or *;q=0 (without enabling identity) serves as a signal that the client cannot handle uncompressed content, effectively making "uncompressed" an unacceptable state [3][5]. If the server's only available options are also unacceptable to the client based on these preferences, the 406 response is the semantically correct way to communicate this mismatch [3][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Show the static handler section around the encoding decision and response selection.
nl -ba src/static.ts | sed -n '140,320p'

Repository: h3js/srvx

Length of output: 187


Return 406 when identity is disallowed.
undefined still collapses “no acceptable encoding” into an identity 200, so identity;q=0 and *;q=0 can be served uncompressed here, including for non-compressible files where negotiation is skipped. Split that case out and return 406 Not Acceptable instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/static.ts` around lines 120 - 141, The encoding negotiation flow around
negotiateEncoding must distinguish “no compression selected” from identity being
explicitly disallowed. Track identity’s effective quality, including
identity;q=0 and the wildcard *;q=0, and propagate an explicit not-acceptable
result so the request returns 406 instead of falling back to an uncompressed
200, including when compression negotiation is skipped for non-compressible
files.

}

/** Weak `ETag` comparison (RFC 9110): ignore any leading `W/`. */
function etagMatches(ifNoneMatch: string, etag: string): boolean {
if (ifNoneMatch.trim() === "*") {
return true;
}
const normalize = (tag: string) => tag.trim().replace(/^W\//, "");
const target = normalize(etag);
return ifNoneMatch.split(",").some((tag) => normalize(tag) === target);
}

export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => {
const dir = resolve(options.dir) + sep;
const methods = new Set((options.methods || ["GET", "HEAD"]).map((m) => m.toUpperCase()));
const cacheControl =
options.cacheControl === undefined
? "public, max-age=0, must-revalidate"
: options.cacheControl;

// Real (symlink-resolved) base directory, resolved lazily and cached. Used to
// reject files that escape `dir` through a symlink.
let realDir: string | undefined;

return async (req, next) => {
if (!methods.has(req.method)) {
return next();
}
const isHead = req.method === "HEAD";
const url = (req._url ??= new FastURL(req.url));
const path = url.pathname.slice(1).replace(/\/$/, "");

// Percent-decode the pathname so on-disk names with spaces/unicode are
// reachable. Malformed sequences must not crash; fall through to `next()`.
let path: string;
try {
path = decodeURIComponent(url.pathname.slice(1).replace(/\/$/, ""));
} catch {
return next();
}

// Deny any path segment starting with a dot. This is a deliberate denylist
// that blocks dotfiles (`.env`, `.env.local`, `.npmrc.bak`, `.git/...`) and
// dot-segment traversal (`.` / `..`, including once-encoded `%2e` forms
// which are now decoded), so secrets and parent dirs are never served.
//
// A leading `.well-known` (RFC 8615) is the single exemption: it is a
// registered, public-by-design namespace (ACME challenges, `security.txt`,
// `assetlinks.json`) that must stay reachable. Only the first segment is
// exempt, so everything below it is still denied (`/.well-known/.env`), and
// `.well-known` nested anywhere else (`/sub/.well-known/...`) is not
// well-known at all and stays denied too.
const segments = path.split("/");
const isWellKnown = segments[0] === ".well-known";
for (let i = isWellKnown ? 1 : 0; i < segments.length; i++) {
if (segments[i].startsWith(".")) {
return next();
}
}
Comment on lines +175 to +199

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and nearby symbols first.
ast-grep outline src/static.ts --view expanded || true

echo
echo "=== src/static.ts (relevant range) ==="
sed -n '1,260p' src/static.ts

echo
echo "=== Search for path resolution / separator handling in src ==="
rg -n "decodeURIComponent|split\\(\"/\"\\)|startsWith\\(\"\\.\"\\)|path\\.|fs\\.|join\\(|resolve\\(|normalize\\(|sep|backslash|well-known" src

Repository: h3js/srvx

Length of output: 15973


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Probe Node's path semantics relevant to the claim.
node - <<'JS'
const path = require('path');

const samples = [
  'sub%5c.well-known%5cnope.txt',
  'sub\\.well-known\\nope.txt',
  '.well-known\\nope.txt',
  'a/b',
  'a\\b',
];

for (const s of samples) {
  console.log(`INPUT: ${s}`);
  console.log('  posix.split("/"):', s.split('/'));
  console.log('  win32.normalize:', path.win32.normalize(s));
  console.log('  win32.isAbsolute:', path.win32.isAbsolute(s));
  console.log('  win32.parse:', path.win32.parse(s));
  console.log('  win32.resolve("C:\\\\base", s):', path.win32.resolve('C:\\base', s));
  console.log();
}
JS

Repository: h3js/srvx

Length of output: 1562


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the exact file-resolution path in src/static.ts if it uses path joins.
python3 - <<'PY'
from pathlib import Path
p = Path('src/static.ts')
text = p.read_text()
for needle in ['path =', 'segments =', 'fs.', 'createReadStream', 'sendFile', 'join(', 'resolve(', 'normalize(', 'URL(', 'pathname']:
    idx = text.find(needle)
    if idx != -1:
        start = max(0, idx - 250)
        end = min(len(text), idx + 700)
        print(f"\n=== around {needle!r} ===")
        print(text[start:end])
PY

Repository: h3js/srvx

Length of output: 7950


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== src/static.ts lines 1-260 ==="
cat -n src/static.ts | sed -n '1,260p'

Repository: h3js/srvx

Length of output: 10496


🌐 Web query:

Node.js Windows path backslash separator path.win32 normalize decodeURIComponent URL pathname static file serving dotfile bypass

💡 Result:

In Node.js, security vulnerabilities such as dotfile bypass or path traversal can occur when static file servers inconsistently normalize paths or decode URL components [1][2][3]. Path Normalization and Backslashes The path.normalize and path.win32.normalize methods resolve '..' and '.' segments and replace multiple, sequential path separators with the platform-specific separator ('' on Windows) [4]. However, these methods are utilities for path cleaning, not security tools [1][2]. They do not constrain the resulting path to a specific directory [2]. On Windows, vulnerabilities have historically occurred where naive path manipulation or inconsistent normalization allowed attackers to escape intended directories using platform-specific behaviors or reserved device names [5][6][7][8]. URL Decoding and Pathname Handling Vulnerabilities often arise from the order of operations between URL decoding (decodeURIComponent) and path normalization [1][3]. If a raw URL pathname is decoded before it is validated or normalized, an attacker can use URL-encoded sequences (e.g., %2e%2e%2f for../) to bypass security filters that only check for literal '..' strings [1][2]. Conversely, if normalization happens before decoding, malicious characters may be hidden within encoded sequences, bypassing checks designed to block dotfiles or restricted paths [3]. Dotfile Bypass and Static Serving In middleware like express.static or serve-static, the dotfiles option (which can be set to 'deny', 'ignore', or 'allow') relies on checking the path for segments beginning with a '.' [9][10][11]. If the path is not correctly decoded or normalized before this check is performed, an attacker might bypass the restriction [3]. For example, if a system uses a different normalization logic than the security filter, or if the filter fails to account for how the underlying file system interprets encoded characters, access to sensitive files (e.g.,.env) can be granted [1][3]. Recommended Best Practices 1. Never trust user input as a direct file path [1]. 2. Always resolve the final path using path.resolve against a fixed, intended base directory [1][2]. 3. After resolution, verify the resulting path starts with the base directory path (including the path separator) to prevent traversal [1][2]. 4. Decode URL parameters only once and perform all security checks (like dotfile restrictions) on the fully resolved, decoded path [1]. 5. Prefer higher-level abstractions like res.sendFile with an explicit root option, which is designed to constrain access to the specified root automatically [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== src/_url.ts ==="
cat -n src/_url.ts

echo
echo "=== any backslash / pathname normalization in request URL handling ==="
rg -n "backslashes|\\\\|pathname|URL|FastURL|new URL" src/_url.ts src/adapters/_node/url.ts src/adapters/_node/request.ts src/static.ts

Repository: h3js/srvx

Length of output: 13273


Reject decoded backslashes before segment validation. src/static.ts:175-199 — %5c becomes \ after decodeURIComponent(), and path.split("/") won’t catch it before join() treats it as a separator on Windows. Return next() when the decoded path contains \, or normalize separators before the dot-segment check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/static.ts` around lines 175 - 199, Update the decoded path validation in
the static request handler before segment checks to reject any path containing a
backslash, including decoded %5c sequences, by returning next(). Ensure this
validation occurs before path joining or file resolution, while preserving the
existing .well-known exemption and dot-segment checks.


let paths: string[];
if (path === "") {
paths = ["index.html"];
} else if (extname(path) === "") {
paths = [`${path}.html`, `${path}/index.html`];
} else {
} else if (isWellKnown || extname(path) !== "") {
// Well-known URIs are exact identifiers, so the `.html`/`index.html`
// fallback must not apply below `/.well-known/`: ACME challenge tokens
// (`/.well-known/acme-challenge/<token>`) are extensionless and would
// otherwise resolve to `<token>.html` and 404, silently breaking cert
// renewal.
paths = [path];
} else {
paths = [`${path}.html`, `${path}/index.html`];
}
for (const path of paths) {
const filePath = join(dir, path);

for (const candidate of paths) {
const filePath = join(dir, candidate);
// Defense-in-depth: `join` normalization must not escape `dir`.
if (!filePath.startsWith(dir)) {
continue;
}
const fileStat = await stat(filePath).catch(() => null);
if (fileStat?.isFile()) {
const fileExt = extname(filePath);
const headers: HeadersInit = {
"Content-Length": fileStat.size.toString(),
"Content-Type": COMMON_MIME_TYPES[fileExt] || "application/octet-stream",
};
if (options.renderHTML && fileExt === ".html") {
return options.renderHTML({
html: await readFile(filePath, "utf8"),
filename: filePath,
request: req,
});
if (!fileStat?.isFile()) {
continue;
}

// Symlink escape: resolve the real path and ensure it stays inside the
// real base directory before serving.
try {
if (realDir === undefined) {
realDir = (await realpath(resolve(options.dir))) + sep;
}
let stream: ReadStream | Transform = createReadStream(filePath);
const acceptEncoding = req.headers.get("accept-encoding") || "";
if (acceptEncoding.includes("br")) {
headers["Content-Encoding"] = "br";
delete headers["Content-Length"];
headers["Vary"] = "Accept-Encoding";
stream = stream.pipe(createBrotliCompress());
} else if (acceptEncoding.includes("gzip")) {
headers["Content-Encoding"] = "gzip";
const realFile = await realpath(filePath);
if (realFile !== realDir.slice(0, -1) && !realFile.startsWith(realDir)) {
continue;
}
} catch {
continue;
}

const fileExt = extname(filePath);
const contentType = COMMON_MIME_TYPES[fileExt] || "application/octet-stream";

if (options.renderHTML && fileExt === ".html") {
return options.renderHTML({
html: await readFile(filePath, "utf8"),
filename: filePath,
request: req,
});
}
Comment on lines +243 to +249

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Apply HEAD body suppression to rendered HTML responses.

The early renderHTML return bypasses Lines 299-302, so a HEAD request can return the renderer’s body.

Proposed fix
       if (options.renderHTML && fileExt === ".html") {
-        return options.renderHTML({
+        const response = await options.renderHTML({
           html: await readFile(filePath, "utf8"),
           filename: filePath,
           request: req,
         });
+        if (!isHead) {
+          return response;
+        }
+        return new FastResponse(null, {
+          status: response.status,
+          statusText: response.statusText,
+          headers: response.headers,
+        });
       }

Also applies to: 299-302

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/static.ts` around lines 243 - 249, Update the renderHTML branch in the
static request handler so HEAD requests preserve the renderer’s headers/status
but suppress the returned response body, matching the existing HEAD handling
around lines 299-302. Keep the rendered HTML body for non-HEAD requests.


// Validators for conditional requests and caching.
const mtime = fileStat.mtime;
const etag = `W/"${fileStat.size.toString(16)}-${mtime.getTime().toString(16)}"`;
const lastModified = mtime.toUTCString();

const headers: Record<string, string> = {
"Content-Type": contentType,
"Content-Length": fileStat.size.toString(),
ETag: etag,
"Last-Modified": lastModified,
};
if (cacheControl) {
headers["Cache-Control"] = cacheControl;
}

// Compression negotiation (only for compressible types). `Vary` is set on
// both the compressed and the identity variant so caches key correctly.
let encoding: "br" | "gzip" | undefined;
if (isCompressible(contentType)) {
headers["Vary"] = "Accept-Encoding";
encoding = negotiateEncoding(req.headers.get("accept-encoding") || "");
if (encoding) {
headers["Content-Encoding"] = encoding;
// Compressed length is unknown ahead of time.
delete headers["Content-Length"];
headers["Vary"] = "Accept-Encoding";
stream = stream.pipe(createGzip());
}
return new FastResponse(stream as any, { headers });
}

// Conditional requests: `If-None-Match` takes precedence over
// `If-Modified-Since` (RFC 9110). Respond `304` with no body.
const ifNoneMatch = req.headers.get("if-none-match");
const ifModifiedSince = req.headers.get("if-modified-since");
let notModified = false;
if (ifNoneMatch) {
notModified = etagMatches(ifNoneMatch, etag);
} else if (ifModifiedSince) {
const since = Date.parse(ifModifiedSince);
// Compare at second resolution (HTTP dates have no sub-second part).
if (!Number.isNaN(since) && Math.floor(mtime.getTime() / 1000) * 1000 <= since) {
notModified = true;
}
}
if (notModified) {
delete headers["Content-Length"];
delete headers["Content-Encoding"];
return new FastResponse(null, { status: 304, headers });
Comment on lines +281 to +296

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant file and nearby logic.
ast-grep outline src/static.ts --view expanded || true
wc -l src/static.ts
sed -n '240,340p' src/static.ts

Repository: h3js/srvx

Length of output: 3531


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect method handling and the serveStatic control flow around the conditional-response block.
sed -n '154,314p' src/static.ts

Repository: h3js/srvx

Length of output: 6294


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find any explicit method checks or method allowlists in src/static.ts.
rg -n 'method|methods|GET|HEAD|POST|If-None-Match|If-Modified-Since|412|304' src/static.ts

Repository: h3js/srvx

Length of output: 703


Gate conditional responses by method

When methods includes POST or another non-GET/HEAD method, this block still returns 304 for a matching If-None-Match. Those requests should use 412, and If-Modified-Since should be ignored unless the request is GET or HEAD.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/static.ts` around lines 281 - 296, Update the conditional-response logic
around ifNoneMatch, ifModifiedSince, and notModified to distinguish GET/HEAD
from other methods. For GET/HEAD, preserve the existing 304 handling; for
non-GET/HEAD requests, a matching If-None-Match must return 412, and
If-Modified-Since must not participate.

}

// HEAD: send the same headers a GET would, with no body work.
if (isHead) {
return new FastResponse(null, { headers });
}

let stream: ReadStream | Transform = createReadStream(filePath);
if (encoding === "br") {
stream = stream.pipe(createBrotliCompress());
} else if (encoding === "gzip") {
stream = stream.pipe(createGzip());
}
return new FastResponse(stream as any, { headers });
}
return next();
};
Expand Down
Loading
Loading