From 6adb0253585364a42fe24bb90d15c24c77f74489 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 16 Jul 2026 17:55:49 +0000 Subject: [PATCH 01/11] fix(static): contain symlinks, deny dotfiles, and drop on-the-fly compression `serveStatic` had no test coverage despite being a public export (`srvx/static`). Adds a suite (44 tests) and fixes what it surfaced. Symlink escape: `stat()` follows symlinks but the only containment check was lexical (`filePath.startsWith(dir)`), so a link inside `dir` served any file on the host. Re-assert containment against the `realpath`-resolved path. Both sides are resolved, or a legitimately symlinked `dir` (`/var/www` -> `/data/www`) would reject every file. Links resolving inside `dir` are still served. Dotfiles: only bare dotfiles were protected, by accident -- `extname(".env")` is `""`, so `.env` was probed as `.env.html` and missed. Anything with a real extension was served (`.env.production`, `sub/.env.local`, `.git/config.txt`). Deny dot segments by default behind a new `dotfiles` option. `.well-known` gets no exemption; enable `dotfiles` to serve well-known URIs. Compression: `createBrotliCompress()` ran at quality 11 (the maximum) per request with no cache, letting an unauthenticated client amplify cheap requests into heavy CPU work -- including via HEAD, whose body Node discards anyway. Replaced with a precompressed lookup (`app.js.br`, `app.js.gz`) via a new `encodings` option, mirroring h3. Nothing is compressed on the fly. This also fixes `br;q=0` (an explicit refusal) being honored as brotli, `x-gzip`/`brotli` matching as substrings, and restores `Content-Length` on encoded responses. Variants are only looked up for compressible types, so an image or font costs no extra stat calls and omits `Vary`. HEAD: return headers without reading the file. Path resolution: probe the literal path before the `.html` route candidates, so extension-less files (`LICENSE`, `apple-app-site-association`, ACME challenge tokens) are reachable at their exact name. This also makes bare dotfiles reachable under `dotfiles: true`, which the `extname()` quirk above prevented. Adds `.wasm`, `.avif`, `.mp3` and `.gz` MIME types. BREAKING: assets are no longer compressed on the fly -- precompress at build time to keep compressed responses. Dotfiles (including `/.well-known/`) now 404 unless `dotfiles: true`. An extension-less file now takes precedence over its `.html` sibling. Co-Authored-By: Claude Opus 4.8 --- docs/1.guide/4.middleware.md | 9 + src/static.ts | 202 ++++++++++++++++--- test/static.test.ts | 367 +++++++++++++++++++++++++++++++++++ vitest.config.mjs | 7 + 4 files changed, 558 insertions(+), 27 deletions(-) create mode 100644 test/static.test.ts diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index fb00ccb7..40d9a3e1 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -94,8 +94,17 @@ When no file matches the request, it calls `next()` — so your handler acts as - `dir`: The directory to serve files from (required). - `methods`: HTTP methods to serve (default `["GET", "HEAD"]`). Other methods fall through to `next()`. +- `dotfiles`: Serve paths with a segment starting with `.` (default `false`). Dotfiles such as `.env` or `.git/config` fall through to `next()` unless this is enabled. This also covers `/.well-known/`, so enable it if you serve well-known URIs from `dir`. +- `encodings`: Map of `Content-Encoding` to the file extension of its precompressed variant (default `{ br: ".br", gzip: ".gz" }`). Keys are tried in order, so list the preferred encoding first. Pass `{}` to disable. - `renderHTML`: A function receiving `{ request, html, filename }` for every `.html` file, returning the `Response` to send. Use it to inject or template markup before serving. +A request resolves in order: the path itself, then `.html`, then `/index.html`. So `/about` serves `about.html`, while an extension-less file (`LICENSE`, an ACME challenge token) is served at its exact name. + +Files are never compressed on the fly. For `/app.js` with `Accept-Encoding: br`, `app.js.br` is served if it exists (with `Content-Encoding: br`), otherwise `app.js` is served as-is — so precompress assets at build time to serve them compressed. Variants are only looked up for compressible types, so a `.br` next to an image or font is ignored; those responses also omit `Vary: Accept-Encoding`, which compressible ones always set. `renderHTML` routes always read the source file, since a precompressed variant would not match the rendered output. + +> [!NOTE] +> Files are only served from within `dir`. Symlinks are followed, but a symlink resolving outside `dir` falls through to `next()` instead of being served. + > [!NOTE] > Despite the runtime-neutral name, `srvx/static` is **Node-API-only** — it uses `node:fs` and `node:zlib` internally, so it works only on runtimes with Node.js compatibility (Node, Deno, Bun). diff --git a/src/static.ts b/src/static.ts index 40288000..690f025a 100644 --- a/src/static.ts +++ b/src/static.ts @@ -1,11 +1,10 @@ import type { ServerMiddleware } from "./types.ts"; -import type { Transform } from "node:stream"; +import type { Stats } from "node:fs"; import { extname, join, resolve, sep } from "node:path"; -import { readFile, stat } from "node:fs/promises"; -import { createReadStream, ReadStream } from "node:fs"; +import { readFile, realpath, stat } from "node:fs/promises"; +import { createReadStream } from "node:fs"; import { FastResponse } from "srvx"; -import { createGzip, createBrotliCompress } from "node:zlib"; import { FastURL } from "./_url.ts"; export interface ServeStaticOptions { @@ -19,6 +18,24 @@ export interface ServeStaticOptions { */ methods?: string[]; + /** + * Serve dotfiles (paths with a segment starting with `.`, such as `.env` or `.git/config`). + * + * @default false + */ + dotfiles?: boolean; + + /** + * Map of `Content-Encoding` to the file extension of its precompressed variant on disk. + * + * Files are never compressed on the fly: for `/app.js` with `Accept-Encoding: br`, + * `app.js.br` is served if it exists, otherwise `app.js` is served as-is. Keys are + * tried in order, so list the preferred encoding first. Pass `{}` to disable. + * + * @default { br: ".br", gzip: ".gz" } + */ + encodings?: Record; + /** * A function to modify the HTML content before serving it. */ @@ -39,6 +56,7 @@ const COMMON_MIME_TYPES: Record = { ".json": "application/json", ".txt": "text/plain", ".xml": "application/xml", + ".wasm": "application/wasm", ".gif": "image/gif", ".ico": "image/vnd.microsoft.icon", ".jpeg": "image/jpeg", @@ -46,17 +64,113 @@ const COMMON_MIME_TYPES: Record = { ".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", ".pdf": "application/pdf", }; +// Types that benefit from compression. Everything else (images, video, audio, +// archives, fonts) is already compressed, so a `.br`/`.gz` variant would not +// exist and looking for one only costs stat calls. +const isCompressible = (mimeType: string): boolean => + mimeType.startsWith("text/") || + mimeType.endsWith("+json") || + mimeType.endsWith("+xml") || + mimeType === "application/json" || + mimeType === "application/xml" || + mimeType === "application/javascript" || + mimeType === "application/wasm"; + +// A path segment starting with `.` marks a dotfile. `.`/`..` segments never +// reach this check: `join()` resolves them before the path is tested, so +// `/sub/../index.html` is `index.html` here, not a dot segment. +const isDotPath = (relPath: string): boolean => relPath.split(sep).some((s) => s[0] === "."); + +// The uncompressed file, always tried last so it acts as the fallback. +const IDENTITY: [encoding: string, ext: string] = ["", ""]; + +/** + * Encodings from `encodings` the client accepts, in server-preference order. + * + * `q=0` means "not acceptable" and is honored, so `br;q=0, gzip` serves gzip rather than + * brotli. A `*` applies to any encoding not named explicitly. + */ +const parseAcceptEncoding = ( + header: string, + encodings: Record, +): [encoding: string, ext: string][] => { + if (!header) { + return []; + } + const quality = new Map(); + for (const part of header.split(",")) { + const [token, ...params] = part.split(";"); + const name = token!.trim().toLowerCase(); + if (!name) { + continue; + } + let q = 1; + for (const param of params) { + const trimmed = param.trim(); + if (trimmed.startsWith("q=")) { + // A malformed q (`q=abc`) parses to NaN; treat it as refused. + q = Number.parseFloat(trimmed.slice(2)) || 0; + } + } + quality.set(name, q); + } + const wildcard = quality.get("*"); + return Object.entries(encodings).filter(([name]) => (quality.get(name) ?? wildcard ?? 0) > 0); +}; + 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 dotfiles = options.dotfiles === true; + const encodings = options.encodings || { br: ".br", gzip: ".gz" }; + const varyOnEncoding = Object.keys(encodings).length > 0; + + // Real (symlink-resolved) `dir`, used to re-assert containment below. `dir` + // itself may legitimately be a symlink (`/var/www` -> `/data/www`), so both + // sides of the comparison have to be resolved or every file would be + // rejected. Resolved lazily and cached only on success, so a `dir` that is + // created after the first request is still picked up. + let realDir: string | undefined; + const getRealDir = async (): Promise => { + if (realDir === undefined) { + const resolved = await realpath(dir).catch(() => null); + if (resolved === null) { + return dir; + } + realDir = resolved + sep; + } + return realDir; + }; + + // Stat a candidate, rejecting anything that is not a regular file or whose + // resolved path escapes `dir` (see `getRealDir`). + const resolveFile = async (candidate: string): Promise => { + const fileStat = await stat(candidate).catch(() => null); + if (!fileStat?.isFile()) { + return null; + } + // The `startsWith(dir)` check on the caller side is lexical and cannot see + // through symlinks, while `stat()` follows them: a link inside `dir` can + // resolve to any file on the host. Re-assert containment against the + // resolved path, which also covers links in intermediate segments. Links + // staying inside `dir` are still served. + const realPath = await realpath(candidate).catch(() => null); + if (!realPath || !realPath.startsWith(await getRealDir())) { + return null; + } + return fileStat; + }; return async (req, next) => { if (!methods.has(req.method)) { @@ -68,43 +182,77 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { if (path === "") { paths = ["index.html"]; } else if (extname(path) === "") { - paths = [`${path}.html`, `${path}/index.html`]; + // Probe the literal path before the `.html` route candidates, so an + // extension-less file is reachable at its exact name: ACME challenge + // tokens (`/.well-known/acme-challenge/`), `LICENSE`, + // `apple-app-site-association`. This also covers dotfiles, which land + // here because `extname()` reports no extension for a leading-dot name + // (`.env`) and would otherwise only be looked up as `.env.html`. + paths = [path, `${path}.html`, `${path}/index.html`]; } else { paths = [path]; } + const acceptEncodings = parseAcceptEncoding( + req.headers.get("accept-encoding") || "", + encodings, + ); for (const path of paths) { const filePath = join(dir, path); if (!filePath.startsWith(dir)) { continue; } - const fileStat = await stat(filePath).catch(() => null); - if (fileStat?.isFile()) { - const fileExt = extname(filePath); - const headers: HeadersInit = { + if (!dotfiles && isDotPath(filePath.slice(dir.length))) { + continue; + } + const fileExt = extname(filePath); + const contentType = COMMON_MIME_TYPES[fileExt] || "application/octet-stream"; + const renderHTML = fileExt === ".html" ? options.renderHTML : undefined; + // Look for precompressed variants only where one could plausibly exist: + // not for already-compressed types, and not for `renderHTML` routes, + // whose output a variant on disk would not match. + const compressible = !renderHTML && isCompressible(contentType); + for (const [encoding, ext] of compressible ? [...acceptEncodings, IDENTITY] : [IDENTITY]) { + const servePath = filePath + ext; + const fileStat = await resolveFile(servePath); + if (!fileStat) { + continue; + } + // `Content-Type` comes from the base path: the variant's own extension + // is the encoding (`.br`), not the media type. + const headers: Record = { "Content-Length": fileStat.size.toString(), - "Content-Type": COMMON_MIME_TYPES[fileExt] || "application/octet-stream", + "Content-Type": contentType, }; - if (options.renderHTML && fileExt === ".html") { - return options.renderHTML({ - html: await readFile(filePath, "utf8"), - filename: filePath, + if (encoding) { + headers["Content-Encoding"] = encoding; + } + if (varyOnEncoding && compressible) { + // Set on the identity variant too, not just when an encoded one is + // served: a shared cache must key on the header either way. + headers["Vary"] = "Accept-Encoding"; + } + if (renderHTML) { + const rendered = await renderHTML({ + html: await readFile(servePath, "utf8"), + filename: servePath, request: req, }); + if (req.method !== "HEAD") { + return rendered; + } + // A HEAD response carries the same headers as GET, without the body. + return new FastResponse(null, { + status: rendered.status, + statusText: rendered.statusText, + headers: rendered.headers, + }); } - 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"; - delete headers["Content-Length"]; - headers["Vary"] = "Accept-Encoding"; - stream = stream.pipe(createGzip()); + if (req.method === "HEAD") { + // Node discards a HEAD body at the http layer, so reading the file + // would burn I/O for bytes that never reach the wire. + return new FastResponse(null, { headers }); } - return new FastResponse(stream as any, { headers }); + return new FastResponse(createReadStream(servePath) as any, { headers }); } } return next(); diff --git a/test/static.test.ts b/test/static.test.ts new file mode 100644 index 00000000..2499a76a --- /dev/null +++ b/test/static.test.ts @@ -0,0 +1,367 @@ +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { mkdtemp, mkdir, rm, writeFile, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serveStatic, type ServeStaticOptions } from "../src/static.ts"; +import type { ServerRequest } from "../src/types.ts"; + +let tmp: string; +let dir: string; +let linkedDir: string; + +beforeAll(async () => { + tmp = await mkdtemp(join(tmpdir(), "srvx-static-")); + + // /outside/secret.txt is never inside the served root. + await mkdir(join(tmp, "outside"), { recursive: true }); + await writeFile(join(tmp, "outside", "secret.txt"), "TOPSECRET"); + + // /public is the served root. + dir = join(tmp, "public"); + await mkdir(join(dir, "sub"), { recursive: true }); + await writeFile(join(dir, "index.html"), "

index

"); + await writeFile(join(dir, "sub", "inside.txt"), "INSIDE"); + + // Dotfiles: bare, with an extension, nested, and inside a dot directory. + await mkdir(join(dir, ".git"), { recursive: true }); + await writeFile(join(dir, ".env"), "DOTENV"); + await writeFile(join(dir, ".env.production"), "PROD_SECRET"); + await writeFile(join(dir, "sub", ".env.local"), "LOCAL_SECRET"); + await writeFile(join(dir, ".git", "config.txt"), "GIT_CONFIG"); + + // `.well-known` (RFC 8615) is a dot directory and gets no exemption. + await mkdir(join(dir, ".well-known"), { recursive: true }); + await writeFile(join(dir, ".well-known", "security.txt"), "SECURITY_TXT"); + + // Precompressed variants. Contents are markers, not real brotli/gzip: the + // middleware serves the bytes as-is and never decompresses them. + await writeFile(join(dir, "app.js"), "PLAIN_JS"); + await writeFile(join(dir, "app.js.br"), "BROTLI_JS"); + await writeFile(join(dir, "app.js.gz"), "GZIP_JS"); + await writeFile(join(dir, "only-gz.js"), "PLAIN_ONLY_GZ"); + await writeFile(join(dir, "only-gz.js.gz"), "GZIP_ONLY_GZ"); + + // Extension-less files, reachable at their exact name. + await writeFile(join(dir, "LICENSE"), "LICENSE_BODY"); + await writeFile(join(dir, "apple-app-site-association"), "AASA_BODY"); + await mkdir(join(dir, ".well-known", "acme-challenge"), { recursive: true }); + await writeFile(join(dir, ".well-known", "acme-challenge", "tok3n"), "ACME_KEY_AUTH"); + + // An extension-less route that must still resolve to its `.html` file. + await writeFile(join(dir, "about.html"), "

about

"); + + // Already-compressed type: a `.br` next to it must never be looked up. + await writeFile(join(dir, "logo.png"), "PNG_BYTES"); + await writeFile(join(dir, "logo.png.br"), "PNG_BR_SHOULD_BE_IGNORED"); + + // Escaping links: one to a file, one to a directory. + await symlink(join(tmp, "outside", "secret.txt"), join(dir, "escape.txt")); + await symlink(join(tmp, "outside"), join(dir, "escape-dir")); + + // An escaping link reached via the precompressed-variant lookup: the plain + // file is contained, but `.br` points outside the root. + await writeFile(join(dir, "escape-variant.js"), "PLAIN_VARIANT"); + await symlink(join(tmp, "outside", "secret.txt"), join(dir, "escape-variant.js.br")); + + // A link that stays within the root must keep working. + await symlink(join(dir, "sub", "inside.txt"), join(dir, "contained.txt")); + + // A root that is itself a symlink must keep working. + linkedDir = join(tmp, "public-link"); + await symlink(dir, linkedDir); +}); + +afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); +}); + +const req = (path: string) => new Request(`http://localhost${path}`) as unknown as ServerRequest; +const notFound = () => new Response("next()", { status: 404 }); + +const fetchStatic = (path: string, root = dir) => + serveStatic({ dir: root })(req(path), notFound) as Promise; + +const fetchWithDotfiles = (path: string) => + serveStatic({ dir, dotfiles: true })(req(path), notFound) as Promise; + +const fetchWith = (path: string, init: RequestInit, opts: Partial = {}) => + serveStatic({ dir, ...opts })( + new Request(`http://localhost${path}`, init) as unknown as ServerRequest, + notFound, + ) as Promise; + +const fetchEncoded = (path: string, acceptEncoding: string) => + fetchWith(path, { headers: { "accept-encoding": acceptEncoding } }); + +describe("serveStatic", () => { + test("serves a file", async () => { + const res = await fetchStatic("/sub/inside.txt"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("INSIDE"); + }); + + test("serves index.html for /", async () => { + const res = await fetchStatic("/"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toContain("index"); + }); + + describe("symlinks", () => { + test("does not serve a symlink escaping the root", async () => { + const res = await fetchStatic("/escape.txt"); + expect(res.status).toBe(404); + await expect(res.text()).resolves.not.toContain("TOPSECRET"); + }); + + test("does not serve through a symlinked directory escaping the root", async () => { + const res = await fetchStatic("/escape-dir/secret.txt"); + expect(res.status).toBe(404); + await expect(res.text()).resolves.not.toContain("TOPSECRET"); + }); + + test("serves a symlink contained within the root", async () => { + const res = await fetchStatic("/contained.txt"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("INSIDE"); + }); + + test("serves files when dir is itself a symlink", async () => { + const res = await fetchStatic("/sub/inside.txt", linkedDir); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("INSIDE"); + }); + + test("still rejects an escaping symlink when dir is itself a symlink", async () => { + const res = await fetchStatic("/escape.txt", linkedDir); + expect(res.status).toBe(404); + }); + }); + + describe("dotfiles", () => { + test.each([ + ["/.env", "DOTENV"], + ["/.env.production", "PROD_SECRET"], + ["/sub/.env.local", "LOCAL_SECRET"], + ["/.git/config.txt", "GIT_CONFIG"], + ])("does not serve %s by default", async (path, secret) => { + const res = await fetchStatic(path); + expect(res.status).toBe(404); + await expect(res.text()).resolves.not.toContain(secret); + }); + + test.each([ + ["/.env", "DOTENV"], + ["/.env.production", "PROD_SECRET"], + ["/sub/.env.local", "LOCAL_SECRET"], + ["/.git/config.txt", "GIT_CONFIG"], + ])("serves %s with dotfiles: true", async (path, contents) => { + const res = await fetchWithDotfiles(path); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe(contents); + }); + + test("gives .well-known no exemption, serving it only with dotfiles: true", async () => { + // `.well-known` (RFC 8615) is a dot directory like any other, so serving + // well-known URIs from `dir` requires opting in. + expect((await fetchStatic("/.well-known/security.txt")).status).toBe(404); + + const res = await fetchWithDotfiles("/.well-known/security.txt"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("SECURITY_TXT"); + }); + + test("does not mistake resolved `..` segments for dotfiles", async () => { + // `join()` resolves these to `/index.html` before the dotfile check. + for (const path of ["/sub/../index.html", "/./index.html"]) { + const res = await fetchStatic(path); + expect(res.status, path).toBe(200); + await expect(res.text()).resolves.toContain("index"); + } + }); + }); + + describe("precompressed lookup", () => { + test("prefers brotli when both variants exist", async () => { + const res = await fetchEncoded("/app.js", "gzip, br"); + expect(res.headers.get("content-encoding")).toBe("br"); + expect(res.headers.get("content-type")).toBe("text/javascript"); + await expect(res.text()).resolves.toBe("BROTLI_JS"); + }); + + test("falls back to gzip when brotli is not accepted", async () => { + const res = await fetchEncoded("/app.js", "gzip"); + expect(res.headers.get("content-encoding")).toBe("gzip"); + await expect(res.text()).resolves.toBe("GZIP_JS"); + }); + + test("falls back to the plain file when no variant is accepted", async () => { + const res = await fetchEncoded("/app.js", ""); + expect(res.headers.get("content-encoding")).toBe(null); + await expect(res.text()).resolves.toBe("PLAIN_JS"); + }); + + test("falls back to the plain file when no variant exists on disk", async () => { + const res = await fetchEncoded("/index.html", "br"); + expect(res.headers.get("content-encoding")).toBe(null); + await expect(res.text()).resolves.toContain("index"); + }); + + test("skips a missing variant and uses the next accepted one", async () => { + const res = await fetchEncoded("/only-gz.js", "br, gzip"); + expect(res.headers.get("content-encoding")).toBe("gzip"); + await expect(res.text()).resolves.toBe("GZIP_ONLY_GZ"); + }); + + test("honors q=0 as a refusal", async () => { + const res = await fetchEncoded("/app.js", "br;q=0, gzip"); + expect(res.headers.get("content-encoding")).toBe("gzip"); + await expect(res.text()).resolves.toBe("GZIP_JS"); + }); + + test("honors an explicit q ranking", async () => { + const res = await fetchEncoded("/app.js", "br;q=1.0"); + expect(res.headers.get("content-encoding")).toBe("br"); + await expect(res.text()).resolves.toBe("BROTLI_JS"); + }); + + test("supports the * wildcard", async () => { + const res = await fetchEncoded("/app.js", "*"); + expect(res.headers.get("content-encoding")).toBe("br"); + }); + + test("does not match an encoding as a substring", async () => { + // "x-gzip" must not satisfy "gzip", nor "brotli" satisfy "br". + const res = await fetchEncoded("/app.js", "x-gzip, brotli"); + expect(res.headers.get("content-encoding")).toBe(null); + await expect(res.text()).resolves.toBe("PLAIN_JS"); + }); + + test("sets Vary: Accept-Encoding whenever variants are configured", async () => { + expect((await fetchEncoded("/app.js", "br")).headers.get("vary")).toBe("Accept-Encoding"); + // Also on the uncompressed response: caches must key on the header. + expect((await fetchEncoded("/index.html", "")).headers.get("vary")).toBe("Accept-Encoding"); + }); + + test("sets Content-Length to the served variant's size", async () => { + const res = await fetchEncoded("/app.js", "br"); + expect(res.headers.get("content-length")).toBe(String("BROTLI_JS".length)); + }); + + test("serves the plain file with encodings: {}", async () => { + const res = await fetchWith( + "/app.js", + { headers: { "accept-encoding": "br" } }, + { + encodings: {}, + }, + ); + expect(res.headers.get("content-encoding")).toBe(null); + expect(res.headers.get("vary")).toBe(null); + await expect(res.text()).resolves.toBe("PLAIN_JS"); + }); + + test("rejects a variant escaping the root and falls back to the plain file", async () => { + // `escape-variant.js.br` symlinks outside the root; the plain file does not. + const res = await fetchEncoded("/escape-variant.js", "br"); + expect(res.status).toBe(200); + expect(res.headers.get("content-encoding")).toBe(null); + const body = await res.text(); + expect(body).toBe("PLAIN_VARIANT"); + expect(body).not.toContain("TOPSECRET"); + }); + }); + + describe("extension-less paths", () => { + test.each([ + ["/LICENSE", "LICENSE_BODY"], + ["/apple-app-site-association", "AASA_BODY"], + ])("serves %s at its exact name", async (path, contents) => { + const res = await fetchStatic(path); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe(contents); + }); + + test("still resolves an extension-less route to its .html file", async () => { + const res = await fetchStatic("/about"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toContain("about"); + }); + + test("serves an ACME challenge token with dotfiles: true", async () => { + // Extension-less and under a dot directory: needs both the literal probe + // and the dotfiles opt-in. + expect((await fetchStatic("/.well-known/acme-challenge/tok3n")).status).toBe(404); + + const res = await fetchWithDotfiles("/.well-known/acme-challenge/tok3n"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("ACME_KEY_AUTH"); + }); + }); + + describe("incompressible types", () => { + test("never serves a variant for an already-compressed type", async () => { + const res = await fetchEncoded("/logo.png", "br"); + expect(res.headers.get("content-type")).toBe("image/png"); + expect(res.headers.get("content-encoding")).toBe(null); + await expect(res.text()).resolves.toBe("PNG_BYTES"); + }); + + test("omits Vary for an already-compressed type", async () => { + expect((await fetchEncoded("/logo.png", "br")).headers.get("vary")).toBe(null); + // ...but still sets it for compressible types. + expect((await fetchEncoded("/index.html", "br")).headers.get("vary")).toBe("Accept-Encoding"); + }); + + test.each([ + ["/mod.wasm", "application/wasm"], + ["/pic.avif", "image/avif"], + ["/song.mp3", "audio/mpeg"], + ["/bundle.gz", "application/gzip"], + ])("maps %s to %s", async (path, type) => { + await writeFile(join(dir, path.slice(1)), "X"); + const res = await fetchStatic(path); + expect(res.headers.get("content-type")).toBe(type); + }); + }); + + describe("HEAD", () => { + test("returns headers with no body", async () => { + const res = await fetchWith("/app.js", { method: "HEAD" }); + expect(res.status).toBe(200); + expect(res.headers.get("content-length")).toBe(String("PLAIN_JS".length)); + expect(res.headers.get("content-type")).toBe("text/javascript"); + await expect(res.text()).resolves.toBe(""); + }); + + test("reports the variant's headers without a body", async () => { + const res = await fetchWith("/app.js", { + method: "HEAD", + headers: { "accept-encoding": "br" }, + }); + expect(res.headers.get("content-encoding")).toBe("br"); + expect(res.headers.get("content-length")).toBe(String("BROTLI_JS".length)); + await expect(res.text()).resolves.toBe(""); + }); + + test("returns no body for a renderHTML route", async () => { + const opts = { + renderHTML: ({ html }: { html: string }) => + new Response(`${html}`, { headers: { "x-rendered": "1" } }), + }; + const get = await fetchWith("/index.html", {}, opts); + await expect(get.text()).resolves.toContain(""); + + const head = await fetchWith("/index.html", { method: "HEAD" }, opts); + expect(head.status).toBe(200); + expect(head.headers.get("x-rendered")).toBe("1"); + await expect(head.text()).resolves.toBe(""); + }); + }); + + test("does not serve traversal outside the root", async () => { + for (const path of ["/../outside/secret.txt", "/%2e%2e%2foutside%2fsecret.txt"]) { + const res = await fetchStatic(path); + expect(res.status, path).toBe(404); + } + }); +}); diff --git a/vitest.config.mjs b/vitest.config.mjs index 65494d9b..87a31f74 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -3,6 +3,13 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { typecheck: { enabled: true }, + // `src/static.ts` imports `FastResponse` from "srvx" at runtime to pick up + // the adapter matching the runtime (via the `exports` conditions). That + // self-reference resolves through `dist/`, which `pnpm test` does not + // build, so point it at the node adapter the tests run on. + alias: [ + { find: /^srvx$/, replacement: new URL("src/adapters/node.ts", import.meta.url).pathname }, + ], // Some tests rely on short real-time timers (~100ms). Under the full suite // (lint + typecheck + coverage + parallel forks) the machine can be starved // enough to stretch those past the defaults, causing load-only flakes From ca291e898662140a9025d4c78435253b8d4a9c5f Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 09:44:02 +0000 Subject: [PATCH 02/11] fix(static): decode request paths once, reject malformed encoding with 400 - Single decodeURI pass from the wire form so encoded names (hello%20world.txt, caf%C3%A9.txt, 50%25.txt) resolve; %2F/%3F/%23 stay encoded so an encoded separator never becomes a separator. - Malformed percent-encoding (/foo%, /%ZZ) now answers 400 like nginx/serve-static instead of falling through. - HEAD on a renderHTML route cancels the unused rendered body. - TODO note on /sub -> /sub/ redirect handling. Co-Authored-By: Claude Fable 5 --- src/static.ts | 26 ++++++++++++++++++- test/static.test.ts | 63 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/static.ts b/src/static.ts index 690f025a..bacd436b 100644 --- a/src/static.ts +++ b/src/static.ts @@ -177,11 +177,32 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { return next(); } const url = (req._url ??= new FastURL(req.url)); - const path = url.pathname.slice(1).replace(/\/$/, ""); + let path = url.pathname.slice(1).replace(/\/$/, ""); + if (path.includes("%")) { + // `url.pathname` keeps the wire encoding, so decode exactly once here or + // any name a client must encode (`hello world.txt`, `café.txt`) is + // unreachable. `decodeURI`, not `decodeURIComponent`: it keeps `%2F`, + // `%3F` and `%23` encoded, so an encoded separator never becomes a + // separator. Dot segments (including `%2e` forms) were already resolved + // by the URL parser, and whatever a single decode can still surface + // (`%5C` on Windows, a double-encoded `..` decoding to the literal + // `%2e%2e`) is caught by the containment and dotfile checks below, + // which all run on the decoded, joined path. + try { + path = decodeURI(path); + } catch { + // Malformed encoding (`/foo%`, `/%ZZ`): reject like nginx/serve-static + // do rather than guessing at a lookup for a raw `%` name. + return new FastResponse("Bad Request", { status: 400 }); + } + } let paths: string[]; if (path === "") { paths = ["index.html"]; } else if (extname(path) === "") { + // TODO: consider answering `/sub` with a 303 redirect to `/sub/` instead + // of serving `sub/index.html` in place (nginx sends 301): without the + // trailing slash, relative links inside that index resolve against `/`. // Probe the literal path before the `.html` route candidates, so an // extension-less file is reachable at its exact name: ACME challenge // tokens (`/.well-known/acme-challenge/`), `LICENSE`, @@ -241,6 +262,9 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { return rendered; } // A HEAD response carries the same headers as GET, without the body. + // Cancel the unused body so a stream-backed rendered response + // releases its underlying resource instead of waiting for GC. + await rendered.body?.cancel().catch(() => {}); return new FastResponse(null, { status: rendered.status, statusText: rendered.statusText, diff --git a/test/static.test.ts b/test/static.test.ts index 2499a76a..244707c5 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -50,6 +50,11 @@ beforeAll(async () => { // An extension-less route that must still resolve to its `.html` file. await writeFile(join(dir, "about.html"), "

about

"); + // Names that only appear percent-encoded on the wire. + await writeFile(join(dir, "hello world.txt"), "SPACE_NAME"); + await writeFile(join(dir, "café.txt"), "UNICODE_NAME"); + await writeFile(join(dir, "50%.txt"), "PERCENT_NAME"); + // Already-compressed type: a `.br` next to it must never be looked up. await writeFile(join(dir, "logo.png"), "PNG_BYTES"); await writeFile(join(dir, "logo.png.br"), "PNG_BR_SHOULD_BE_IGNORED"); @@ -356,6 +361,64 @@ describe("serveStatic", () => { expect(head.headers.get("x-rendered")).toBe("1"); await expect(head.text()).resolves.toBe(""); }); + + test("cancels the unused rendered body", async () => { + let cancelled = false; + const opts = { + renderHTML: () => + new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + ), + }; + const head = await fetchWith("/index.html", { method: "HEAD" }, opts); + await expect(head.text()).resolves.toBe(""); + expect(cancelled).toBe(true); + }); + }); + + describe("percent-encoded paths", () => { + test("decodes the pathname once for the lookup", async () => { + const res = await fetchStatic("/hello%20world.txt"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("SPACE_NAME"); + }); + + test("decodes non-ASCII names", async () => { + await expect(fetchStatic("/caf%C3%A9.txt").then((r) => r.text())).resolves.toBe( + "UNICODE_NAME", + ); + }); + + test("decodes an encoded literal percent", async () => { + await expect(fetchStatic("/50%25.txt").then((r) => r.text())).resolves.toBe("PERCENT_NAME"); + }); + + test("keeps an encoded separator encoded", async () => { + // `%2F` must not become a path separator: the decoded lookup is for a + // file literally named `sub%2Finside.txt`, which does not exist. + expect((await fetchStatic("/sub%2Finside.txt")).status).toBe(404); + }); + + test("applies the dotfile policy to the decoded name", async () => { + expect((await fetchStatic("/%2Eenv")).status).toBe(404); + await expect(fetchWithDotfiles("/%2Eenv").then((r) => r.text())).resolves.toBe("DOTENV"); + }); + + test("does not decode twice", async () => { + // `%252e%252e` decodes once to the harmless literal `%2e%2e`. + const res = await fetchStatic("/%252e%252e/outside/secret.txt"); + expect(res.status).toBe(404); + }); + + test("rejects malformed encoding with 400", async () => { + for (const path of ["/foo%", "/%ZZ"]) { + expect((await fetchStatic(path)).status, path).toBe(400); + } + }); }); test("does not serve traversal outside the root", async () => { From 883ac76521611a2f10f7e257e28841bb10537f34 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 10:30:24 +0000 Subject: [PATCH 03/11] up --- docs/1.guide/4.middleware.md | 6 +- src/static.ts | 59 ++++++++++----- test/static.test.ts | 140 +++++++++++++++++++++++++++-------- 3 files changed, 157 insertions(+), 48 deletions(-) diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index 40d9a3e1..8267ab33 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -94,7 +94,7 @@ When no file matches the request, it calls `next()` — so your handler acts as - `dir`: The directory to serve files from (required). - `methods`: HTTP methods to serve (default `["GET", "HEAD"]`). Other methods fall through to `next()`. -- `dotfiles`: Serve paths with a segment starting with `.` (default `false`). Dotfiles such as `.env` or `.git/config` fall through to `next()` unless this is enabled. This also covers `/.well-known/`, so enable it if you serve well-known URIs from `dir`. +- `dotfiles`: Dot segments (path segments starting with `.`) that may be served (default `[".well-known"]`). A path containing any other dot segment — `/.env`, `/.git/config` — falls through to `next()`. Pass `true` to serve every dot segment, or `false` (or `[]`) to serve none, including `/.well-known/`. - `encodings`: Map of `Content-Encoding` to the file extension of its precompressed variant (default `{ br: ".br", gzip: ".gz" }`). Keys are tried in order, so list the preferred encoding first. Pass `{}` to disable. - `renderHTML`: A function receiving `{ request, html, filename }` for every `.html` file, returning the `Response` to send. Use it to inject or template markup before serving. @@ -102,11 +102,13 @@ A request resolves in order: the path itself, then `.html`, then `/i Files are never compressed on the fly. For `/app.js` with `Accept-Encoding: br`, `app.js.br` is served if it exists (with `Content-Encoding: br`), otherwise `app.js` is served as-is — so precompress assets at build time to serve them compressed. Variants are only looked up for compressible types, so a `.br` next to an image or font is ignored; those responses also omit `Vary: Accept-Encoding`, which compressible ones always set. `renderHTML` routes always read the source file, since a precompressed variant would not match the rendered output. +`/.well-known/` is served by default because [RFC 8615](https://www.rfc-editor.org/rfc/rfc8615) reserves it for public metadata: ACME HTTP-01 challenges and `security.txt` live there. Allow-listing is by exact segment name, so `[".well-known"]` serves neither a sibling sharing its prefix (`.well-known-backup`) nor a dot segment nested under it (`.well-known/.env`). + > [!NOTE] > Files are only served from within `dir`. Symlinks are followed, but a symlink resolving outside `dir` falls through to `next()` instead of being served. > [!NOTE] -> Despite the runtime-neutral name, `srvx/static` is **Node-API-only** — it uses `node:fs` and `node:zlib` internally, so it works only on runtimes with Node.js compatibility (Node, Deno, Bun). +> Despite the runtime-neutral name, `srvx/static` is **Node-API-only** — it uses `node:fs` internally, so it works only on runtimes with Node.js compatibility (Node, Deno, Bun). See [Serving static files](/guide/cli#serving-static-files) for the equivalent CLI flag. diff --git a/src/static.ts b/src/static.ts index bacd436b..313da227 100644 --- a/src/static.ts +++ b/src/static.ts @@ -19,11 +19,14 @@ export interface ServeStaticOptions { methods?: string[]; /** - * Serve dotfiles (paths with a segment starting with `.`, such as `.env` or `.git/config`). + * Dot segments (a path segment starting with `.`, such as `.env` or `.git`) that may be served. * - * @default false + * An array allow-lists segments by exact name; a path containing any other dot segment falls + * through to `next()`. `true` serves every dot segment, `false` (or `[]`) none. + * + * @default [".well-known"] */ - dotfiles?: boolean; + dotfiles?: boolean | string[]; /** * Map of `Content-Encoding` to the file extension of its precompressed variant on disk. @@ -87,10 +90,11 @@ const isCompressible = (mimeType: string): boolean => mimeType === "application/javascript" || mimeType === "application/wasm"; -// A path segment starting with `.` marks a dotfile. `.`/`..` segments never -// reach this check: `join()` resolves them before the path is tested, so -// `/sub/../index.html` is `index.html` here, not a dot segment. -const isDotPath = (relPath: string): boolean => relPath.split(sep).some((s) => s[0] === "."); +// RFC 8615 reserves `/.well-known/` for public metadata, so it is served by +// default: ACME HTTP-01 challenges and `security.txt` live there, and gating +// them behind an all-or-nothing opt-in would mean publishing `.env`/`.git` to +// renew a certificate. Every other dot segment stays hidden. +const DEFAULT_DOTFILES = [".well-known"]; // The uncompressed file, always tried last so it acts as the fallback. const IDENTITY: [encoding: string, ext: string] = ["", ""]; @@ -132,7 +136,22 @@ const parseAcceptEncoding = ( 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 dotfiles = options.dotfiles === true; + // `?? DEFAULT_DOTFILES` and not `||`: an explicit `false` must stay `false`. + const dotfiles = options.dotfiles ?? DEFAULT_DOTFILES; + const allowAllDots = dotfiles === true; + const allowedDots = new Set(Array.isArray(dotfiles) ? dotfiles : []); + + // Deny a path with a dot segment that is not allow-listed. Matching is by + // exact segment, so allowing `.well-known` exposes neither a sibling that + // merely shares its prefix (`.well-known-backup`) nor a dot segment nested + // under it (`.well-known/.env`). + // + // `.`/`..` segments never reach this check: `join()` resolves them before the + // path is tested, so `/sub/../index.html` is `index.html` here, not a dot + // segment. + const isDeniedDotPath = (relPath: string): boolean => + !allowAllDots && relPath.split(sep).some((s) => s[0] === "." && !allowedDots.has(s)); + const encodings = options.encodings || { br: ".br", gzip: ".gz" }; const varyOnEncoding = Object.keys(encodings).length > 0; @@ -183,11 +202,16 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // any name a client must encode (`hello world.txt`, `café.txt`) is // unreachable. `decodeURI`, not `decodeURIComponent`: it keeps `%2F`, // `%3F` and `%23` encoded, so an encoded separator never becomes a - // separator. Dot segments (including `%2e` forms) were already resolved - // by the URL parser, and whatever a single decode can still surface - // (`%5C` on Windows, a double-encoded `..` decoding to the literal - // `%2e%2e`) is caught by the containment and dotfile checks below, - // which all run on the decoded, joined path. + // separator. + // + // Nothing below relies on the pathname arriving normalized. `FastURL` + // does resolve dot segments in practice (`_needsNormRE` in `_url.ts` + // deopts `.`, `..` and their `%2e` forms to the native parser), but that + // is an invariant of another module, and decoding can surface a dot + // segment after it has already run (`%252e%252e` -> `%2e%2e`, `%5C` on + // Windows). So containment rests only on `join()` + `startsWith(dir)` + // below, which resolve and re-check whatever actually reaches them; the + // "unresolved pathname" tests feed a raw `/../` straight in to pin that. try { path = decodeURI(path); } catch { @@ -206,9 +230,10 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // Probe the literal path before the `.html` route candidates, so an // extension-less file is reachable at its exact name: ACME challenge // tokens (`/.well-known/acme-challenge/`), `LICENSE`, - // `apple-app-site-association`. This also covers dotfiles, which land - // here because `extname()` reports no extension for a leading-dot name - // (`.env`) and would otherwise only be looked up as `.env.html`. + // `apple-app-site-association`. This also covers allow-listed dotfiles, + // which land here because `extname()` reports no extension for a + // leading-dot name (`.env`) and would otherwise only be looked up as + // `.env.html`. paths = [path, `${path}.html`, `${path}/index.html`]; } else { paths = [path]; @@ -222,7 +247,7 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { if (!filePath.startsWith(dir)) { continue; } - if (!dotfiles && isDotPath(filePath.slice(dir.length))) { + if (isDeniedDotPath(filePath.slice(dir.length))) { continue; } const fileExt = extname(filePath); diff --git a/test/static.test.ts b/test/static.test.ts index 244707c5..60a01229 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, mkdir, rm, writeFile, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { serveStatic, type ServeStaticOptions } from "../src/static.ts"; +import { FastURL } from "../src/_url.ts"; import type { ServerRequest } from "../src/types.ts"; let tmp: string; @@ -29,10 +30,17 @@ beforeAll(async () => { await writeFile(join(dir, "sub", ".env.local"), "LOCAL_SECRET"); await writeFile(join(dir, ".git", "config.txt"), "GIT_CONFIG"); - // `.well-known` (RFC 8615) is a dot directory and gets no exemption. + // `.well-known` (RFC 8615) is allow-listed by default. await mkdir(join(dir, ".well-known"), { recursive: true }); await writeFile(join(dir, ".well-known", "security.txt"), "SECURITY_TXT"); + // A dot segment nested under an allow-listed one is still a dot segment. + await writeFile(join(dir, ".well-known", ".env"), "WELLKNOWN_SECRET"); + + // A sibling that merely shares the `.well-known` prefix must not be matched. + await mkdir(join(dir, ".well-known-backup"), { recursive: true }); + await writeFile(join(dir, ".well-known-backup", "secret.txt"), "BACKUP_SECRET"); + // Precompressed variants. Contents are markers, not real brotli/gzip: the // middleware serves the bytes as-is and never decompresses them. await writeFile(join(dir, "app.js"), "PLAIN_JS"); @@ -86,6 +94,21 @@ const notFound = () => new Response("next()", { status: 404 }); const fetchStatic = (path: string, root = dir) => serveStatic({ dir: root })(req(path), notFound) as Promise; +// `new Request()` collapses dot segments in its constructor, so a request built +// through it hands the middleware an already-resolved pathname and can never +// exercise the containment check. `FastURL`'s origin-form fast path returns the +// target verbatim (`_searchNeedsNormRE` in `_url.ts` does not deopt on `..`), +// which is the one way an unresolved pathname reaches `static.ts` — so build +// `_url` directly to test the check rather than the test harness. +const rawReq = (path: string) => { + const request = new Request("http://localhost/") as unknown as ServerRequest; + request._url = new FastURL(path); + return request; +}; + +const fetchRaw = (path: string) => + serveStatic({ dir })(rawReq(path), notFound) as Promise; + const fetchWithDotfiles = (path: string) => serveStatic({ dir, dotfiles: true })(req(path), notFound) as Promise; @@ -165,23 +188,49 @@ describe("serveStatic", () => { await expect(res.text()).resolves.toBe(contents); }); - test("gives .well-known no exemption, serving it only with dotfiles: true", async () => { - // `.well-known` (RFC 8615) is a dot directory like any other, so serving - // well-known URIs from `dir` requires opting in. - expect((await fetchStatic("/.well-known/security.txt")).status).toBe(404); - - const res = await fetchWithDotfiles("/.well-known/security.txt"); + test("serves an arbitrary allow-listed segment and nothing else", async () => { + const opts = { dotfiles: [".git"] }; + const res = await fetchWith("/.git/config.txt", {}, opts); expect(res.status).toBe(200); - await expect(res.text()).resolves.toBe("SECURITY_TXT"); + await expect(res.text()).resolves.toBe("GIT_CONFIG"); + expect((await fetchWith("/.env", {}, opts)).status).toBe(404); }); - test("does not mistake resolved `..` segments for dotfiles", async () => { - // `join()` resolves these to `/index.html` before the dotfile check. - for (const path of ["/sub/../index.html", "/./index.html"]) { - const res = await fetchStatic(path); - expect(res.status, path).toBe(200); - await expect(res.text()).resolves.toContain("index"); - } + describe(".well-known", () => { + test("is served by default", async () => { + const res = await fetchStatic("/.well-known/security.txt"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("SECURITY_TXT"); + }); + + test("serves an ACME challenge token by default", async () => { + // Extension-less and under a dot directory, so this needs both the + // literal probe and the default allow-list. Renewing a certificate must + // not require `dotfiles: true`, which would also publish `.env`/`.git`. + const res = await fetchStatic("/.well-known/acme-challenge/tok3n"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("ACME_KEY_AUTH"); + }); + + test("matches by exact segment, not by prefix", async () => { + const res = await fetchStatic("/.well-known-backup/secret.txt"); + expect(res.status).toBe(404); + await expect(res.text()).resolves.not.toContain("BACKUP_SECRET"); + }); + + test("does not serve a dot segment nested under it", async () => { + const res = await fetchStatic("/.well-known/.env"); + expect(res.status).toBe(404); + await expect(res.text()).resolves.not.toContain("WELLKNOWN_SECRET"); + }); + + test.each([ + ["false", false], + ["[]", []], + ])("is hidden with dotfiles: %s", async (_label, dotfiles) => { + const res = await fetchWith("/.well-known/security.txt", {}, { dotfiles }); + expect(res.status).toBe(404); + }); }); }); @@ -291,16 +340,6 @@ describe("serveStatic", () => { expect(res.status).toBe(200); await expect(res.text()).resolves.toContain("about"); }); - - test("serves an ACME challenge token with dotfiles: true", async () => { - // Extension-less and under a dot directory: needs both the literal probe - // and the dotfiles opt-in. - expect((await fetchStatic("/.well-known/acme-challenge/tok3n")).status).toBe(404); - - const res = await fetchWithDotfiles("/.well-known/acme-challenge/tok3n"); - expect(res.status).toBe(200); - await expect(res.text()).resolves.toBe("ACME_KEY_AUTH"); - }); }); describe("incompressible types", () => { @@ -408,6 +447,15 @@ describe("serveStatic", () => { await expect(fetchWithDotfiles("/%2Eenv").then((r) => r.text())).resolves.toBe("DOTENV"); }); + test("applies the dotfile allow-list to the decoded name", async () => { + // The allow-list is matched after decoding, so an encoded `.well-known` + // is neither denied as an unknown dot segment nor let through unchecked. + await expect(fetchStatic("/%2Ewell-known/security.txt").then((r) => r.text())).resolves.toBe( + "SECURITY_TXT", + ); + expect((await fetchStatic("/%2Ewell-known/%2Eenv")).status).toBe(404); + }); + test("does not decode twice", async () => { // `%252e%252e` decodes once to the harmless literal `%2e%2e`. const res = await fetchStatic("/%252e%252e/outside/secret.txt"); @@ -421,10 +469,44 @@ describe("serveStatic", () => { }); }); - test("does not serve traversal outside the root", async () => { - for (const path of ["/../outside/secret.txt", "/%2e%2e%2foutside%2fsecret.txt"]) { - const res = await fetchStatic(path); + describe("unresolved pathname", () => { + // Every request here bypasses `new Request()` — see `rawReq`. Without that, + // the pathname arrives already collapsed and these assert nothing: they pass + // against a `serveStatic` with both containment checks deleted. + test.each([ + "/../outside/secret.txt", + "/sub/../../outside/secret.txt", + "/../../../../../../etc/passwd", + ])("serves no traversal from a raw %s", async (path) => { + const res = await fetchRaw(path); expect(res.status, path).toBe(404); - } + await expect(res.text()).resolves.not.toContain("TOPSECRET"); + }); + + test.each(["/sub/../index.html", "/./index.html"])( + "serves %s, which resolves back inside the root", + async (path) => { + // Not every dot segment escapes, and `join()` collapses these before the + // dotfile check, so they must not be read as dotfiles either. + const res = await fetchRaw(path); + expect(res.status, path).toBe(200); + await expect(res.text()).resolves.toContain("index"); + }, + ); + + test("does not serve a raw traversal into an allow-listed dot segment", async () => { + const res = await fetchRaw("/.well-known/../../outside/secret.txt"); + expect(res.status).toBe(404); + await expect(res.text()).resolves.not.toContain("TOPSECRET"); + }); + }); + + test("keeps an encoded separator from becoming a separator", async () => { + // `%2f` survives `decodeURI`, so this stays a single literal filename rather + // than traversing. Reaches the middleware verbatim: `new Request()` only + // collapses real separators. + const res = await fetchStatic("/%2e%2e%2foutside%2fsecret.txt"); + expect(res.status).toBe(404); + await expect(res.text()).resolves.not.toContain("TOPSECRET"); }); }); From 0fcdf4a5b15e691c110cab0abf42009ac6763129 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 10:37:30 +0000 Subject: [PATCH 04/11] fix(static): allow-list dotfiles, gate variants on the identity file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dotfiles` becomes `boolean | string[]`, defaulting to `[".well-known"]`. RFC 8615 reserves that namespace for public metadata, so ACME HTTP-01 challenges and `security.txt` now work out of the box. The previous all-or-nothing `dotfiles: true` meant renewing a certificate also published `.env` and `.git/config` — the files the default exists to hide. Matching is by exact segment, so `[".well-known"]` covers neither a prefix sibling (`.well-known-backup`) nor a nested dot segment (`.well-known/.env`). Split `resolveFile` into `statFile` (existence) and `isContained` (the realpath boundary), and gate variant lookup on the identity file. A precompressed variant is only reachable next to a real file, which a client accepting no encoding needs anyway, so an orphan `.br` was never servable in practice. Measured per request: a miss drops 7 -> 3 syscalls and a variant hit costs 2 -> 3. Misses are worth the trade — the middleware falls through to the app on every unmatched route. The lexical `startsWith(dir)` check is a pre-filter, not the boundary (`isContained` is); say so, since nothing distinguishes it in tests. Tests: 52 -> 64. The traversal tests asserted nothing — `new Request()` collapses `..` in its constructor, so they passed against a `serveStatic` with both containment checks deleted. They now build `_url` directly via `FastURL`'s origin-form fast path, the one way an unresolved pathname reaches here, and fail when either check is removed. Also correct the decode comment, which claimed the URL parser had already resolved dot segments: it does, but that is `_url.ts`'s invariant, and decoding can surface dot segments after it runs — so containment rests only on `join()` + `startsWith(dir)`. Drop the stale `node:zlib` mention from the docs; this branch removed that import. Co-Authored-By: Claude Opus 4.8 --- src/static.ts | 146 ++++++++++++++++++++++++++------------------ test/static.test.ts | 10 +++ 2 files changed, 96 insertions(+), 60 deletions(-) diff --git a/src/static.ts b/src/static.ts index 313da227..675a882a 100644 --- a/src/static.ts +++ b/src/static.ts @@ -96,9 +96,6 @@ const isCompressible = (mimeType: string): boolean => // renew a certificate. Every other dot segment stays hidden. const DEFAULT_DOTFILES = [".well-known"]; -// The uncompressed file, always tried last so it acts as the fallback. -const IDENTITY: [encoding: string, ext: string] = ["", ""]; - /** * Encodings from `encodings` the client accepts, in server-preference order. * @@ -172,23 +169,21 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { return realDir; }; - // Stat a candidate, rejecting anything that is not a regular file or whose - // resolved path escapes `dir` (see `getRealDir`). - const resolveFile = async (candidate: string): Promise => { + // Existence and type only. Containment costs a `realpath` (see `isContained`) + // and is only worth paying for the candidate actually served. + const statFile = async (candidate: string): Promise => { const fileStat = await stat(candidate).catch(() => null); - if (!fileStat?.isFile()) { - return null; - } - // The `startsWith(dir)` check on the caller side is lexical and cannot see - // through symlinks, while `stat()` follows them: a link inside `dir` can - // resolve to any file on the host. Re-assert containment against the - // resolved path, which also covers links in intermediate segments. Links - // staying inside `dir` are still served. + return fileStat?.isFile() ? fileStat : null; + }; + + // The containment boundary. `stat()` follows symlinks and the lexical + // `startsWith(dir)` pre-filter cannot see through them, so a link inside + // `dir` can resolve to any file on the host. Re-assert against the resolved + // path, which also covers links in intermediate segments. Links staying + // inside `dir` are still served. + const isContained = async (candidate: string): Promise => { const realPath = await realpath(candidate).catch(() => null); - if (!realPath || !realPath.startsWith(await getRealDir())) { - return null; - } - return fileStat; + return realPath !== null && realPath.startsWith(await getRealDir()); }; return async (req, next) => { @@ -244,12 +239,27 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { ); for (const path of paths) { const filePath = join(dir, path); + // A cheap pre-filter, not the containment boundary — `isContained` is. + // This rejects an obvious escape without spending a syscall, and it is + // what makes `slice(dir.length)` below an actual relative path. if (!filePath.startsWith(dir)) { continue; } if (isDeniedDotPath(filePath.slice(dir.length))) { continue; } + // The identity file gates the candidate: a client that accepts no + // encoding needs it regardless, so a variant without one beside it is + // already a broken deploy. Probing it first costs one extra stat when a + // variant then wins (2 -> 3 syscalls for `/app.js` + `br`), but keeps a + // miss at one syscall per candidate rather than one per accepted encoding + // (7 -> 3 for `/nope`). Misses are worth the trade: the middleware falls + // through to the app on every unmatched route, so all non-static traffic + // pays that path, as does anything probing for `.env`/`.git`. + const identityStat = await statFile(filePath); + if (!identityStat) { + continue; + } const fileExt = extname(filePath); const contentType = COMMON_MIME_TYPES[fileExt] || "application/octet-stream"; const renderHTML = fileExt === ".html" ? options.renderHTML : undefined; @@ -257,52 +267,68 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // not for already-compressed types, and not for `renderHTML` routes, // whose output a variant on disk would not match. const compressible = !renderHTML && isCompressible(contentType); - for (const [encoding, ext] of compressible ? [...acceptEncodings, IDENTITY] : [IDENTITY]) { - const servePath = filePath + ext; - const fileStat = await resolveFile(servePath); - if (!fileStat) { - continue; - } - // `Content-Type` comes from the base path: the variant's own extension - // is the encoding (`.br`), not the media type. - const headers: Record = { - "Content-Length": fileStat.size.toString(), - "Content-Type": contentType, - }; - if (encoding) { - headers["Content-Encoding"] = encoding; - } - if (varyOnEncoding && compressible) { - // Set on the identity variant too, not just when an encoded one is - // served: a shared cache must key on the header either way. - headers["Vary"] = "Accept-Encoding"; - } - if (renderHTML) { - const rendered = await renderHTML({ - html: await readFile(servePath, "utf8"), - filename: servePath, - request: req, - }); - if (req.method !== "HEAD") { - return rendered; + + let encoding = ""; + let servePath = filePath; + let fileStat = identityStat; + if (compressible) { + for (const [name, ext] of acceptEncodings) { + const variantPath = filePath + ext; + const variantStat = await statFile(variantPath); + // An escaping variant is skipped rather than fatal: the identity file + // below still serves, provided it is itself contained. + if (variantStat && (await isContained(variantPath))) { + encoding = name; + servePath = variantPath; + fileStat = variantStat; + break; } - // A HEAD response carries the same headers as GET, without the body. - // Cancel the unused body so a stream-backed rendered response - // releases its underlying resource instead of waiting for GC. - await rendered.body?.cancel().catch(() => {}); - return new FastResponse(null, { - status: rendered.status, - statusText: rendered.statusText, - headers: rendered.headers, - }); } - if (req.method === "HEAD") { - // Node discards a HEAD body at the http layer, so reading the file - // would burn I/O for bytes that never reach the wire. - return new FastResponse(null, { headers }); + } + // Only the bytes actually sent need containing, and a variant that won + // above is already checked. + if (!encoding && !(await isContained(filePath))) { + continue; + } + // `Content-Type` comes from the base path: the variant's own extension + // is the encoding (`.br`), not the media type. + const headers: Record = { + "Content-Length": fileStat.size.toString(), + "Content-Type": contentType, + }; + if (encoding) { + headers["Content-Encoding"] = encoding; + } + if (varyOnEncoding && compressible) { + // Set on the identity variant too, not just when an encoded one is + // served: a shared cache must key on the header either way. + headers["Vary"] = "Accept-Encoding"; + } + if (renderHTML) { + const rendered = await renderHTML({ + html: await readFile(servePath, "utf8"), + filename: servePath, + request: req, + }); + if (req.method !== "HEAD") { + return rendered; } - return new FastResponse(createReadStream(servePath) as any, { headers }); + // A HEAD response carries the same headers as GET, without the body. + // Cancel the unused body so a stream-backed rendered response + // releases its underlying resource instead of waiting for GC. + await rendered.body?.cancel().catch(() => {}); + return new FastResponse(null, { + status: rendered.status, + statusText: rendered.statusText, + headers: rendered.headers, + }); + } + if (req.method === "HEAD") { + // Node discards a HEAD body at the http layer, so reading the file + // would burn I/O for bytes that never reach the wire. + return new FastResponse(null, { headers }); } + return new FastResponse(createReadStream(servePath) as any, { headers }); } return next(); }; diff --git a/test/static.test.ts b/test/static.test.ts index 60a01229..f682a748 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -314,6 +314,16 @@ describe("serveStatic", () => { await expect(res.text()).resolves.toBe("PLAIN_JS"); }); + test("ignores a variant with no identity file beside it", async () => { + // The identity file gates the lookup, so an orphan `.br` is not a route. + // Nothing is lost: a client accepting no encoding could not be served it + // anyway, so shipping one without its source is already a broken deploy. + await writeFile(join(dir, "orphan.js.br"), "ORPHAN_BR"); + const res = await fetchEncoded("/orphan.js", "br"); + expect(res.status).toBe(404); + await expect(res.text()).resolves.not.toContain("ORPHAN_BR"); + }); + test("rejects a variant escaping the root and falls back to the plain file", async () => { // `escape-variant.js.br` symlinks outside the root; the plain file does not. const res = await fetchEncoded("/escape-variant.js", "br"); From 99da2057076da3aef3ced6c2102ac627a1a45b30 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 11:27:43 +0000 Subject: [PATCH 05/11] up --- docs/1.guide/4.middleware.md | 6 +- src/static.ts | 200 +++++++---------- test/static.test.ts | 421 +++++++++++++++++++++-------------- 3 files changed, 339 insertions(+), 288 deletions(-) diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index 8267ab33..4f725bbc 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -96,7 +96,7 @@ When no file matches the request, it calls `next()` — so your handler acts as - `methods`: HTTP methods to serve (default `["GET", "HEAD"]`). Other methods fall through to `next()`. - `dotfiles`: Dot segments (path segments starting with `.`) that may be served (default `[".well-known"]`). A path containing any other dot segment — `/.env`, `/.git/config` — falls through to `next()`. Pass `true` to serve every dot segment, or `false` (or `[]`) to serve none, including `/.well-known/`. - `encodings`: Map of `Content-Encoding` to the file extension of its precompressed variant (default `{ br: ".br", gzip: ".gz" }`). Keys are tried in order, so list the preferred encoding first. Pass `{}` to disable. -- `renderHTML`: A function receiving `{ request, html, filename }` for every `.html` file, returning the `Response` to send. Use it to inject or template markup before serving. +- `renderHTML`: A function receiving `{ request, html, filename }` for every HTML file (`.html`, `.htm`), returning the `Response` to send. Use it to inject or template markup before serving. A request resolves in order: the path itself, then `.html`, then `/index.html`. So `/about` serves `about.html`, while an extension-less file (`LICENSE`, an ACME challenge token) is served at its exact name. @@ -104,8 +104,10 @@ Files are never compressed on the fly. For `/app.js` with `Accept-Encoding: br`, `/.well-known/` is served by default because [RFC 8615](https://www.rfc-editor.org/rfc/rfc8615) reserves it for public metadata: ACME HTTP-01 challenges and `security.txt` live there. Allow-listing is by exact segment name, so `[".well-known"]` serves neither a sibling sharing its prefix (`.well-known-backup`) nor a dot segment nested under it (`.well-known/.env`). +Text responses declare `charset=utf-8`; without it a browser decodes them with a fallback of its own choosing, mangling any non-ASCII byte the file does not declare inline. + > [!NOTE] -> Files are only served from within `dir`. Symlinks are followed, but a symlink resolving outside `dir` falls through to `next()` instead of being served. +> Files are only served from within `dir`, and both rules above are re-checked against the path a symlink actually resolves to. Symlinks are followed, but one resolving outside `dir` — or onto a dot segment `dotfiles` hides, such as `public.txt` → `.env` — falls through to `next()` instead of being served. > [!NOTE] > Despite the runtime-neutral name, `srvx/static` is **Node-API-only** — it uses `node:fs` internally, so it works only on runtimes with Node.js compatibility (Node, Deno, Bun). diff --git a/src/static.ts b/src/static.ts index 675a882a..894c5aab 100644 --- a/src/static.ts +++ b/src/static.ts @@ -78,32 +78,26 @@ const COMMON_MIME_TYPES: Record = { ".pdf": "application/pdf", }; -// Types that benefit from compression. Everything else (images, video, audio, -// archives, fonts) is already compressed, so a `.br`/`.gz` variant would not -// exist and looking for one only costs stat calls. +// Types that benefit from compression — everything else (images, video, audio, +// archives, fonts) is already compressed and would not have a `.br`/`.gz` variant. const isCompressible = (mimeType: string): boolean => mimeType.startsWith("text/") || mimeType.endsWith("+json") || mimeType.endsWith("+xml") || mimeType === "application/json" || mimeType === "application/xml" || - mimeType === "application/javascript" || mimeType === "application/wasm"; -// RFC 8615 reserves `/.well-known/` for public metadata, so it is served by -// default: ACME HTTP-01 challenges and `security.txt` live there, and gating -// them behind an all-or-nothing opt-in would mean publishing `.env`/`.git` to -// renew a certificate. Every other dot segment stays hidden. +// RFC 8615 reserves `/.well-known/` for public metadata (ACME HTTP-01 +// challenges, `security.txt`), so it is the only dot segment served by default. const DEFAULT_DOTFILES = [".well-known"]; /** * Encodings from `encodings` the client accepts, in server-preference order. - * - * `q=0` means "not acceptable" and is honored, so `br;q=0, gzip` serves gzip rather than - * brotli. A `*` applies to any encoding not named explicitly. + * `q=0` is honored as "not acceptable"; `*` applies to encodings not named explicitly. */ const parseAcceptEncoding = ( - header: string, + header: string | null, encodings: Record, ): [encoding: string, ext: string][] => { if (!header) { @@ -120,7 +114,7 @@ const parseAcceptEncoding = ( for (const param of params) { const trimmed = param.trim(); if (trimmed.startsWith("q=")) { - // A malformed q (`q=abc`) parses to NaN; treat it as refused. + // A malformed q (`q=abc`) parses to NaN: treat it as refused. q = Number.parseFloat(trimmed.slice(2)) || 0; } } @@ -130,33 +124,30 @@ const parseAcceptEncoding = ( return Object.entries(encodings).filter(([name]) => (quality.get(name) ?? wildcard ?? 0) > 0); }; +// Append `sep` so prefix checks only match at a segment boundary (`/srv/www` +// must not also match `/srv/www-backup`). Roots already end with `sep`. +const asPrefix = (path: string): string => (path.endsWith(sep) ? path : path + sep); + export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { - const dir = resolve(options.dir) + sep; + const dir = asPrefix(resolve(options.dir)); const methods = new Set((options.methods || ["GET", "HEAD"]).map((m) => m.toUpperCase())); - // `?? DEFAULT_DOTFILES` and not `||`: an explicit `false` must stay `false`. const dotfiles = options.dotfiles ?? DEFAULT_DOTFILES; const allowAllDots = dotfiles === true; const allowedDots = new Set(Array.isArray(dotfiles) ? dotfiles : []); - // Deny a path with a dot segment that is not allow-listed. Matching is by - // exact segment, so allowing `.well-known` exposes neither a sibling that - // merely shares its prefix (`.well-known-backup`) nor a dot segment nested - // under it (`.well-known/.env`). - // - // `.`/`..` segments never reach this check: `join()` resolves them before the - // path is tested, so `/sub/../index.html` is `index.html` here, not a dot - // segment. + // Deny paths with a non-allow-listed dot segment. Matching is by exact + // segment, so allowing `.well-known` exposes neither `.well-known-backup` + // nor `.well-known/.env`. (`.`/`..` never reach this check: `join()` + // resolves them first.) const isDeniedDotPath = (relPath: string): boolean => !allowAllDots && relPath.split(sep).some((s) => s[0] === "." && !allowedDots.has(s)); const encodings = options.encodings || { br: ".br", gzip: ".gz" }; const varyOnEncoding = Object.keys(encodings).length > 0; - // Real (symlink-resolved) `dir`, used to re-assert containment below. `dir` - // itself may legitimately be a symlink (`/var/www` -> `/data/www`), so both - // sides of the comparison have to be resolved or every file would be - // rejected. Resolved lazily and cached only on success, so a `dir` that is - // created after the first request is still picked up. + // Symlink-resolved `dir` for containment checks — `dir` itself may + // legitimately be a symlink. Resolved lazily and cached only on success, so + // a `dir` created after the first request is still picked up. let realDir: string | undefined; const getRealDir = async (): Promise => { if (realDir === undefined) { @@ -164,26 +155,28 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { if (resolved === null) { return dir; } - realDir = resolved + sep; + realDir = asPrefix(resolved); } return realDir; }; - // Existence and type only. Containment costs a `realpath` (see `isContained`) - // and is only worth paying for the candidate actually served. const statFile = async (candidate: string): Promise => { const fileStat = await stat(candidate).catch(() => null); return fileStat?.isFile() ? fileStat : null; }; - // The containment boundary. `stat()` follows symlinks and the lexical - // `startsWith(dir)` pre-filter cannot see through them, so a link inside - // `dir` can resolve to any file on the host. Re-assert against the resolved - // path, which also covers links in intermediate segments. Links staying - // inside `dir` are still served. - const isContained = async (candidate: string): Promise => { + // The real security boundary. The handler's checks are lexical while + // `stat()` follows symlinks, so a link inside `dir` could escape the root + // (`escape.txt` -> `/etc/passwd`) or alias a denied dot path to an allowed + // name (`public.txt` -> `.env`). Re-assert both invariants against the + // resolved path; links that stay inside `dir` on an allowed path still work. + const isServable = async (candidate: string): Promise => { const realPath = await realpath(candidate).catch(() => null); - return realPath !== null && realPath.startsWith(await getRealDir()); + if (realPath === null) { + return false; + } + const root = await getRealDir(); + return realPath.startsWith(root) && !isDeniedDotPath(realPath.slice(root.length)); }; return async (req, next) => { @@ -193,25 +186,16 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { const url = (req._url ??= new FastURL(req.url)); let path = url.pathname.slice(1).replace(/\/$/, ""); if (path.includes("%")) { - // `url.pathname` keeps the wire encoding, so decode exactly once here or - // any name a client must encode (`hello world.txt`, `café.txt`) is - // unreachable. `decodeURI`, not `decodeURIComponent`: it keeps `%2F`, - // `%3F` and `%23` encoded, so an encoded separator never becomes a - // separator. - // - // Nothing below relies on the pathname arriving normalized. `FastURL` - // does resolve dot segments in practice (`_needsNormRE` in `_url.ts` - // deopts `.`, `..` and their `%2e` forms to the native parser), but that - // is an invariant of another module, and decoding can surface a dot - // segment after it has already run (`%252e%252e` -> `%2e%2e`, `%5C` on - // Windows). So containment rests only on `join()` + `startsWith(dir)` - // below, which resolve and re-check whatever actually reaches them; the - // "unresolved pathname" tests feed a raw `/../` straight in to pin that. + // Decode the wire encoding exactly once, or names a client must encode + // (`café.txt`) are unreachable. `decodeURI` (not `decodeURIComponent`) + // keeps `%2F`/`%3F`/`%23` encoded, so an encoded separator never becomes + // a separator. Containment does not rely on the pathname arriving + // normalized: `join()` + `startsWith(dir)` below re-check whatever + // reaches them, including dot segments that decoding surfaces. try { path = decodeURI(path); } catch { - // Malformed encoding (`/foo%`, `/%ZZ`): reject like nginx/serve-static - // do rather than guessing at a lookup for a raw `%` name. + // Malformed encoding (`/foo%`, `/%ZZ`): reject like nginx/serve-static. return new FastResponse("Bad Request", { status: 400 }); } } @@ -219,65 +203,51 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { if (path === "") { paths = ["index.html"]; } else if (extname(path) === "") { - // TODO: consider answering `/sub` with a 303 redirect to `/sub/` instead - // of serving `sub/index.html` in place (nginx sends 301): without the + // TODO: consider answering `/sub` with a redirect to `/sub/` instead of + // serving `sub/index.html` in place (nginx sends 301): without the // trailing slash, relative links inside that index resolve against `/`. - // Probe the literal path before the `.html` route candidates, so an - // extension-less file is reachable at its exact name: ACME challenge - // tokens (`/.well-known/acme-challenge/`), `LICENSE`, - // `apple-app-site-association`. This also covers allow-listed dotfiles, - // which land here because `extname()` reports no extension for a - // leading-dot name (`.env`) and would otherwise only be looked up as - // `.env.html`. + // + // The literal path comes first so an extension-less file is reachable at + // its exact name: ACME challenge tokens, `LICENSE`, and allow-listed + // dotfiles like `.env` (which `extname()` reports as extension-less). paths = [path, `${path}.html`, `${path}/index.html`]; } else { paths = [path]; } - const acceptEncodings = parseAcceptEncoding( - req.headers.get("accept-encoding") || "", - encodings, - ); - for (const path of paths) { - const filePath = join(dir, path); - // A cheap pre-filter, not the containment boundary — `isContained` is. - // This rejects an obvious escape without spending a syscall, and it is - // what makes `slice(dir.length)` below an actual relative path. - if (!filePath.startsWith(dir)) { + // Parsed lazily: unmatched routes (all non-static traffic) never need it. + let acceptEncodings: [encoding: string, ext: string][] | undefined; + for (const candidate of paths) { + const filePath = join(dir, candidate); + // Cheap lexical pre-filter — `isServable` is the real boundary. Also + // guarantees `slice(dir.length)` yields an actual relative path. + if (!filePath.startsWith(dir) || isDeniedDotPath(filePath.slice(dir.length))) { continue; } - if (isDeniedDotPath(filePath.slice(dir.length))) { - continue; - } - // The identity file gates the candidate: a client that accepts no - // encoding needs it regardless, so a variant without one beside it is - // already a broken deploy. Probing it first costs one extra stat when a - // variant then wins (2 -> 3 syscalls for `/app.js` + `br`), but keeps a - // miss at one syscall per candidate rather than one per accepted encoding - // (7 -> 3 for `/nope`). Misses are worth the trade: the middleware falls - // through to the app on every unmatched route, so all non-static traffic - // pays that path, as does anything probing for `.env`/`.git`. + // The identity file gates the candidate: a variant without one beside it + // is a broken deploy, and probing it first keeps a miss (all unmatched + // traffic) at one syscall per candidate instead of one per encoding. const identityStat = await statFile(filePath); if (!identityStat) { continue; } - const fileExt = extname(filePath); - const contentType = COMMON_MIME_TYPES[fileExt] || "application/octet-stream"; - const renderHTML = fileExt === ".html" ? options.renderHTML : undefined; - // Look for precompressed variants only where one could plausibly exist: - // not for already-compressed types, and not for `renderHTML` routes, - // whose output a variant on disk would not match. + const contentType = COMMON_MIME_TYPES[extname(filePath)] || "application/octet-stream"; + // Keyed off the resolved type so `.htm` renders like `.html`. + const renderHTML = contentType === "text/html" ? options.renderHTML : undefined; + // No variant lookup for already-compressed types, nor for `renderHTML` + // routes, whose output a variant on disk would not match. const compressible = !renderHTML && isCompressible(contentType); let encoding = ""; let servePath = filePath; let fileStat = identityStat; if (compressible) { + acceptEncodings ??= parseAcceptEncoding(req.headers.get("accept-encoding"), encodings); for (const [name, ext] of acceptEncodings) { const variantPath = filePath + ext; const variantStat = await statFile(variantPath); - // An escaping variant is skipped rather than fatal: the identity file - // below still serves, provided it is itself contained. - if (variantStat && (await isContained(variantPath))) { + // An unservable variant (escapes the root, or resolves onto a denied + // dot path) is skipped, not fatal: the identity file can still serve. + if (variantStat && (await isServable(variantPath))) { encoding = name; servePath = variantPath; fileStat = variantStat; @@ -285,25 +255,10 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { } } } - // Only the bytes actually sent need containing, and a variant that won - // above is already checked. - if (!encoding && !(await isContained(filePath))) { + // Only the bytes actually sent need checking; a winning variant already was. + if (!encoding && !(await isServable(filePath))) { continue; } - // `Content-Type` comes from the base path: the variant's own extension - // is the encoding (`.br`), not the media type. - const headers: Record = { - "Content-Length": fileStat.size.toString(), - "Content-Type": contentType, - }; - if (encoding) { - headers["Content-Encoding"] = encoding; - } - if (varyOnEncoding && compressible) { - // Set on the identity variant too, not just when an encoded one is - // served: a shared cache must key on the header either way. - headers["Vary"] = "Accept-Encoding"; - } if (renderHTML) { const rendered = await renderHTML({ html: await readFile(servePath, "utf8"), @@ -313,9 +268,8 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { if (req.method !== "HEAD") { return rendered; } - // A HEAD response carries the same headers as GET, without the body. - // Cancel the unused body so a stream-backed rendered response - // releases its underlying resource instead of waiting for GC. + // HEAD carries GET's headers without the body; cancel the unused body + // so a stream-backed rendered response releases its resource. await rendered.body?.cancel().catch(() => {}); return new FastResponse(null, { status: rendered.status, @@ -323,9 +277,25 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { headers: rendered.headers, }); } + // `Content-Type` comes from the base path: the variant's own extension + // is the encoding (`.br`), not the media type. Text carries an explicit + // charset so browsers do not decode non-ASCII with a guessed fallback. + const headers: Record = { + "Content-Length": fileStat.size.toString(), + "Content-Type": contentType.startsWith("text/") + ? `${contentType}; charset=utf-8` + : contentType, + }; + if (encoding) { + headers["Content-Encoding"] = encoding; + } + if (varyOnEncoding && compressible) { + // Also set on the identity response: shared caches must key on the + // header either way. + headers["Vary"] = "Accept-Encoding"; + } if (req.method === "HEAD") { - // Node discards a HEAD body at the http layer, so reading the file - // would burn I/O for bytes that never reach the wire. + // Node discards a HEAD body at the http layer; skip the file I/O. return new FastResponse(null, { headers }); } return new FastResponse(createReadStream(servePath) as any, { headers }); diff --git a/test/static.test.ts b/test/static.test.ts index f682a748..e2a52649 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, beforeAll, afterAll } from "vitest"; import { mkdtemp, mkdir, rm, writeFile, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, parse, relative, sep } from "node:path"; import { serveStatic, type ServeStaticOptions } from "../src/static.ts"; import { FastURL } from "../src/_url.ts"; import type { ServerRequest } from "../src/types.ts"; @@ -23,6 +23,9 @@ beforeAll(async () => { await writeFile(join(dir, "index.html"), "

index

"); await writeFile(join(dir, "sub", "inside.txt"), "INSIDE"); + // A nested index, reached by naming its directory rather than the file. + await writeFile(join(dir, "sub", "index.html"), "

sub index

"); + // Dotfiles: bare, with an extension, nested, and inside a dot directory. await mkdir(join(dir, ".git"), { recursive: true }); await writeFile(join(dir, ".env"), "DOTENV"); @@ -49,6 +52,9 @@ beforeAll(async () => { await writeFile(join(dir, "only-gz.js"), "PLAIN_ONLY_GZ"); await writeFile(join(dir, "only-gz.js.gz"), "GZIP_ONLY_GZ"); + // A variant with no identity file beside it. + await writeFile(join(dir, "orphan.js.br"), "ORPHAN_BR"); + // Extension-less files, reachable at their exact name. await writeFile(join(dir, "LICENSE"), "LICENSE_BODY"); await writeFile(join(dir, "apple-app-site-association"), "AASA_BODY"); @@ -58,11 +64,19 @@ beforeAll(async () => { // An extension-less route that must still resolve to its `.html` file. await writeFile(join(dir, "about.html"), "

about

"); + // `.htm` maps to `text/html` just as `.html` does. + await writeFile(join(dir, "page.htm"), "

htm

"); + // Names that only appear percent-encoded on the wire. await writeFile(join(dir, "hello world.txt"), "SPACE_NAME"); await writeFile(join(dir, "café.txt"), "UNICODE_NAME"); await writeFile(join(dir, "50%.txt"), "PERCENT_NAME"); + // Extensions whose MIME mapping is pinned below; the contents never matter. + for (const name of ["mod.wasm", "pic.avif", "song.mp3", "bundle.gz"]) { + await writeFile(join(dir, name), "X"); + } + // Already-compressed type: a `.br` next to it must never be looked up. await writeFile(join(dir, "logo.png"), "PNG_BYTES"); await writeFile(join(dir, "logo.png.br"), "PNG_BR_SHOULD_BE_IGNORED"); @@ -79,6 +93,16 @@ beforeAll(async () => { // A link that stays within the root must keep working. await symlink(join(dir, "sub", "inside.txt"), join(dir, "contained.txt")); + // Links that stay inside the root but land on a dot path: containment alone + // serves these, since the name they are requested under has no dot segment. + await symlink(join(dir, ".env"), join(dir, "alias-dotfile.txt")); + await symlink(join(dir, ".git"), join(dir, "alias-dotdir")); + await symlink(join(dir, ".well-known", "security.txt"), join(dir, "alias-allowed.txt")); + + // The same alias reached through the precompressed-variant lookup. + await writeFile(join(dir, "alias-variant.js"), "PLAIN_ALIAS_VARIANT"); + await symlink(join(dir, ".env"), join(dir, "alias-variant.js.br")); + // A root that is itself a symlink must keep working. linkedDir = join(tmp, "public-link"); await symlink(dir, linkedDir); @@ -88,11 +112,23 @@ afterAll(async () => { await rm(tmp, { recursive: true, force: true }); }); -const req = (path: string) => new Request(`http://localhost${path}`) as unknown as ServerRequest; +const req = (path: string, init?: RequestInit) => + new Request(`http://localhost${path}`, init) as unknown as ServerRequest; const notFound = () => new Response("next()", { status: 404 }); -const fetchStatic = (path: string, root = dir) => - serveStatic({ dir: root })(req(path), notFound) as Promise; +const fetchStatic = (path: string, opts: Partial = {}, init?: RequestInit) => + serveStatic({ dir, ...opts })(req(path, init), notFound) as Promise; + +const fetchEncoded = (path: string, acceptEncoding: string, opts: Partial = {}) => + fetchStatic(path, opts, { headers: { "accept-encoding": acceptEncoding } }); + +// Every denial falls through to the app rather than being answered here, so the +// sentinel body from `notFound()` is what proves the middleware declined. It is +// strictly stronger than asserting the secret is absent. +const expectNext = async (res: Response, label?: string) => { + expect(res.status, label).toBe(404); + await expect(res.text()).resolves.toBe("next()"); +}; // `new Request()` collapses dot segments in its constructor, so a request built // through it hands the middleware an already-resolved pathname and can never @@ -109,18 +145,6 @@ const rawReq = (path: string) => { const fetchRaw = (path: string) => serveStatic({ dir })(rawReq(path), notFound) as Promise; -const fetchWithDotfiles = (path: string) => - serveStatic({ dir, dotfiles: true })(req(path), notFound) as Promise; - -const fetchWith = (path: string, init: RequestInit, opts: Partial = {}) => - serveStatic({ dir, ...opts })( - new Request(`http://localhost${path}`, init) as unknown as ServerRequest, - notFound, - ) as Promise; - -const fetchEncoded = (path: string, acceptEncoding: string) => - fetchWith(path, { headers: { "accept-encoding": acceptEncoding } }); - describe("serveStatic", () => { test("serves a file", async () => { const res = await fetchStatic("/sub/inside.txt"); @@ -134,17 +158,62 @@ describe("serveStatic", () => { await expect(res.text()).resolves.toContain("index"); }); + test("serves from a dir that is a filesystem root", async () => { + // A root already ends at a segment boundary, so the `dir + sep` prefix must + // not become `//` — nothing would match it and every request would 404. + // `dotfiles: true` because the host path above `tmpdir()` may itself hold a + // dot segment (`~/.cache/...`), which is not what this pins. + const { root } = parse(dir); + const urlPath = "/" + relative(root, join(dir, "index.html")).split(sep).join("/"); + const res = await fetchStatic(urlPath, { dir: root, dotfiles: true }); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toContain("index"); + }); + + describe("methods", () => { + test("falls through for a method outside the default GET/HEAD", async () => { + await expectNext(await fetchStatic("/app.js", {}, { method: "POST" })); + }); + + test("serves a method named in `methods`", async () => { + const res = await fetchStatic("/app.js", { methods: ["POST"] }, { method: "POST" }); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("PLAIN_JS"); + }); + + test("matches the method case-insensitively", async () => { + const res = await fetchStatic("/app.js", { methods: ["post"] }, { method: "POST" }); + expect(res.status).toBe(200); + }); + + test("no longer serves GET when `methods` omits it", async () => { + await expectNext(await fetchStatic("/app.js", { methods: ["POST"] })); + }); + }); + + describe("directory routes", () => { + test("serves /index.html for an extension-less directory route", async () => { + const res = await fetchStatic("/sub"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toContain("sub index"); + }); + + test.each(["/sub/", "/app.js/"])("ignores a trailing slash on %s", async (path) => { + // The strip matters most for a file: `stat("app.js/")` is ENOTDIR, so + // without it the route 404s. A directory would resolve either way, since + // `join()` collapses the `sub//index.html` candidate. + const res = await fetchStatic(path); + expect(res.status, path).toBe(200); + }); + }); + describe("symlinks", () => { test("does not serve a symlink escaping the root", async () => { - const res = await fetchStatic("/escape.txt"); - expect(res.status).toBe(404); - await expect(res.text()).resolves.not.toContain("TOPSECRET"); + await expectNext(await fetchStatic("/escape.txt")); }); test("does not serve through a symlinked directory escaping the root", async () => { - const res = await fetchStatic("/escape-dir/secret.txt"); - expect(res.status).toBe(404); - await expect(res.text()).resolves.not.toContain("TOPSECRET"); + await expectNext(await fetchStatic("/escape-dir/secret.txt")); }); test("serves a symlink contained within the root", async () => { @@ -154,46 +223,71 @@ describe("serveStatic", () => { }); test("serves files when dir is itself a symlink", async () => { - const res = await fetchStatic("/sub/inside.txt", linkedDir); + const res = await fetchStatic("/sub/inside.txt", { dir: linkedDir }); expect(res.status).toBe(200); await expect(res.text()).resolves.toBe("INSIDE"); }); test("still rejects an escaping symlink when dir is itself a symlink", async () => { - const res = await fetchStatic("/escape.txt", linkedDir); - expect(res.status).toBe(404); + await expectNext(await fetchStatic("/escape.txt", { dir: linkedDir })); + }); + + describe("aliasing a dot path", () => { + // The dotfile policy reads the request path, containment reads the + // resolved one. A link inside the root that lands on a hidden dot path + // satisfies containment, so the policy has to be re-checked after + // resolving or the link publishes what it names. + const DOT_ALIASES = [ + ["/alias-dotfile.txt", "DOTENV"], + ["/alias-dotdir/config.txt", "GIT_CONFIG"], + ]; + + test.each(DOT_ALIASES)("does not serve %s, which resolves onto a denied dot path", async (path) => { + await expectNext(await fetchStatic(path!)); + }); + + test.each(DOT_ALIASES)("serves %s with dotfiles: true", async (path, contents) => { + // The policy is what hides these, not containment: both links resolve + // inside the root, so lifting the policy must serve them. + await expect(fetchStatic(path!, { dotfiles: true }).then((r) => r.text())).resolves.toBe( + contents, + ); + }); + + test("serves an alias whose target is allow-listed", async () => { + // Resolving must re-apply the policy, not blanket-deny every link that + // lands on a dot segment. + const res = await fetchStatic("/alias-allowed.txt"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("SECURITY_TXT"); + }); }); }); describe("dotfiles", () => { - test.each([ + const DOT_PATHS = [ ["/.env", "DOTENV"], ["/.env.production", "PROD_SECRET"], ["/sub/.env.local", "LOCAL_SECRET"], ["/.git/config.txt", "GIT_CONFIG"], - ])("does not serve %s by default", async (path, secret) => { - const res = await fetchStatic(path); - expect(res.status).toBe(404); - await expect(res.text()).resolves.not.toContain(secret); + ]; + + test.each(DOT_PATHS)("does not serve %s by default", async (path) => { + await expectNext(await fetchStatic(path!)); }); - test.each([ - ["/.env", "DOTENV"], - ["/.env.production", "PROD_SECRET"], - ["/sub/.env.local", "LOCAL_SECRET"], - ["/.git/config.txt", "GIT_CONFIG"], - ])("serves %s with dotfiles: true", async (path, contents) => { - const res = await fetchWithDotfiles(path); + test.each(DOT_PATHS)("serves %s with dotfiles: true", async (path, contents) => { + const res = await fetchStatic(path!, { dotfiles: true }); expect(res.status).toBe(200); await expect(res.text()).resolves.toBe(contents); }); test("serves an arbitrary allow-listed segment and nothing else", async () => { const opts = { dotfiles: [".git"] }; - const res = await fetchWith("/.git/config.txt", {}, opts); + const res = await fetchStatic("/.git/config.txt", opts); expect(res.status).toBe(200); await expect(res.text()).resolves.toBe("GIT_CONFIG"); - expect((await fetchWith("/.env", {}, opts)).status).toBe(404); + expect((await fetchStatic("/.env", opts)).status).toBe(404); }); describe(".well-known", () => { @@ -213,81 +307,53 @@ describe("serveStatic", () => { }); test("matches by exact segment, not by prefix", async () => { - const res = await fetchStatic("/.well-known-backup/secret.txt"); - expect(res.status).toBe(404); - await expect(res.text()).resolves.not.toContain("BACKUP_SECRET"); + await expectNext(await fetchStatic("/.well-known-backup/secret.txt")); }); test("does not serve a dot segment nested under it", async () => { - const res = await fetchStatic("/.well-known/.env"); - expect(res.status).toBe(404); - await expect(res.text()).resolves.not.toContain("WELLKNOWN_SECRET"); + await expectNext(await fetchStatic("/.well-known/.env")); }); test.each([ ["false", false], ["[]", []], ])("is hidden with dotfiles: %s", async (_label, dotfiles) => { - const res = await fetchWith("/.well-known/security.txt", {}, { dotfiles }); + const res = await fetchStatic("/.well-known/security.txt", { dotfiles }); expect(res.status).toBe(404); }); }); }); describe("precompressed lookup", () => { - test("prefers brotli when both variants exist", async () => { - const res = await fetchEncoded("/app.js", "gzip, br"); - expect(res.headers.get("content-encoding")).toBe("br"); - expect(res.headers.get("content-type")).toBe("text/javascript"); - await expect(res.text()).resolves.toBe("BROTLI_JS"); - }); - - test("falls back to gzip when brotli is not accepted", async () => { - const res = await fetchEncoded("/app.js", "gzip"); - expect(res.headers.get("content-encoding")).toBe("gzip"); - await expect(res.text()).resolves.toBe("GZIP_JS"); - }); - - test("falls back to the plain file when no variant is accepted", async () => { - const res = await fetchEncoded("/app.js", ""); - expect(res.headers.get("content-encoding")).toBe(null); - await expect(res.text()).resolves.toBe("PLAIN_JS"); - }); - - test("falls back to the plain file when no variant exists on disk", async () => { - const res = await fetchEncoded("/index.html", "br"); - expect(res.headers.get("content-encoding")).toBe(null); - await expect(res.text()).resolves.toContain("index"); - }); - - test("skips a missing variant and uses the next accepted one", async () => { - const res = await fetchEncoded("/only-gz.js", "br, gzip"); - expect(res.headers.get("content-encoding")).toBe("gzip"); - await expect(res.text()).resolves.toBe("GZIP_ONLY_GZ"); - }); - - test("honors q=0 as a refusal", async () => { - const res = await fetchEncoded("/app.js", "br;q=0, gzip"); - expect(res.headers.get("content-encoding")).toBe("gzip"); - await expect(res.text()).resolves.toBe("GZIP_JS"); - }); - - test("honors an explicit q ranking", async () => { - const res = await fetchEncoded("/app.js", "br;q=1.0"); - expect(res.headers.get("content-encoding")).toBe("br"); - await expect(res.text()).resolves.toBe("BROTLI_JS"); - }); - - test("supports the * wildcard", async () => { - const res = await fetchEncoded("/app.js", "*"); - expect(res.headers.get("content-encoding")).toBe("br"); - }); - - test("does not match an encoding as a substring", async () => { + // (file, Accept-Encoding) -> which bytes are served. `/app.js` has both a + // `.br` and a `.gz` beside it; `/only-gz.js` only a `.gz`; `/index.html` + // neither. + const VARIANT_CASES: { + why: string; + accept: string; + enc: string | null; + body: string; + path?: string; + }[] = [ + { why: "prefers brotli when both variants exist", accept: "gzip, br", enc: "br", body: "BROTLI_JS" }, + { why: "falls back to gzip when brotli is not accepted", accept: "gzip", enc: "gzip", body: "GZIP_JS" }, + { why: "falls back to the plain file when no variant is accepted", accept: "", enc: null, body: "PLAIN_JS" }, + { why: "honors q=0 as a refusal", accept: "br;q=0, gzip", enc: "gzip", body: "GZIP_JS" }, + { why: "honors an explicit q ranking", accept: "br;q=1.0", enc: "br", body: "BROTLI_JS" }, + { why: "treats a malformed q as a refusal", accept: "br;q=abc", enc: null, body: "PLAIN_JS" }, + { why: "supports the * wildcard", accept: "*", enc: "br", body: "BROTLI_JS" }, // "x-gzip" must not satisfy "gzip", nor "brotli" satisfy "br". - const res = await fetchEncoded("/app.js", "x-gzip, brotli"); - expect(res.headers.get("content-encoding")).toBe(null); - await expect(res.text()).resolves.toBe("PLAIN_JS"); + { why: "does not match an encoding as a substring", accept: "x-gzip, brotli", enc: null, body: "PLAIN_JS" }, + // An empty token must be skipped rather than parsed as an encoding. + { why: "ignores empty tokens", accept: ", , br", enc: "br", body: "BROTLI_JS" }, + { why: "skips a missing variant and uses the next accepted one", accept: "br, gzip", enc: "gzip", body: "GZIP_ONLY_GZ", path: "/only-gz.js" }, + { why: "falls back to the plain file when no variant exists on disk", accept: "br", enc: null, body: "

index

", path: "/index.html" }, + ]; + + test.each(VARIANT_CASES)("$why", async ({ accept, enc, body, path = "/app.js" }) => { + const res = await fetchEncoded(path, accept); + expect(res.headers.get("content-encoding")).toBe(enc); + await expect(res.text()).resolves.toBe(body); }); test("sets Vary: Accept-Encoding whenever variants are configured", async () => { @@ -302,13 +368,7 @@ describe("serveStatic", () => { }); test("serves the plain file with encodings: {}", async () => { - const res = await fetchWith( - "/app.js", - { headers: { "accept-encoding": "br" } }, - { - encodings: {}, - }, - ); + const res = await fetchEncoded("/app.js", "br", { encodings: {} }); expect(res.headers.get("content-encoding")).toBe(null); expect(res.headers.get("vary")).toBe(null); await expect(res.text()).resolves.toBe("PLAIN_JS"); @@ -318,20 +378,22 @@ describe("serveStatic", () => { // The identity file gates the lookup, so an orphan `.br` is not a route. // Nothing is lost: a client accepting no encoding could not be served it // anyway, so shipping one without its source is already a broken deploy. - await writeFile(join(dir, "orphan.js.br"), "ORPHAN_BR"); - const res = await fetchEncoded("/orphan.js", "br"); - expect(res.status).toBe(404); - await expect(res.text()).resolves.not.toContain("ORPHAN_BR"); + await expectNext(await fetchEncoded("/orphan.js", "br")); }); - test("rejects a variant escaping the root and falls back to the plain file", async () => { - // `escape-variant.js.br` symlinks outside the root; the plain file does not. - const res = await fetchEncoded("/escape-variant.js", "br"); + test.each([ + // Links out of the root, and onto a denied dot path respectively. Both + // plain files stay put, and a variant that is not servable is skipped + // rather than fatal — the identity file below it still serves. + ["/escape-variant.js", "PLAIN_VARIANT", "TOPSECRET"], + ["/alias-variant.js", "PLAIN_ALIAS_VARIANT", "DOTENV"], + ])("falls back to the plain file when %s's variant is not servable", async (path, body, secret) => { + const res = await fetchEncoded(path!, "br"); expect(res.status).toBe(200); expect(res.headers.get("content-encoding")).toBe(null); - const body = await res.text(); - expect(body).toBe("PLAIN_VARIANT"); - expect(body).not.toContain("TOPSECRET"); + const text = await res.text(); + expect(text).toBe(body); + expect(text).not.toContain(secret); }); }); @@ -340,7 +402,7 @@ describe("serveStatic", () => { ["/LICENSE", "LICENSE_BODY"], ["/apple-app-site-association", "AASA_BODY"], ])("serves %s at its exact name", async (path, contents) => { - const res = await fetchStatic(path); + const res = await fetchStatic(path!); expect(res.status).toBe(200); await expect(res.text()).resolves.toBe(contents); }); @@ -365,33 +427,67 @@ describe("serveStatic", () => { // ...but still sets it for compressible types. expect((await fetchEncoded("/index.html", "br")).headers.get("vary")).toBe("Accept-Encoding"); }); + }); + + describe("renderHTML", () => { + const opts = { + renderHTML: ({ html }: { html: string }) => new Response(`${html}`), + }; + + test.each(["/index.html", "/page.htm"])("renders %s", async (path) => { + // Both map to `text/html`, so both render: an extension the MIME table + // treats as HTML must not be served as raw markup. + const res = await fetchStatic(path, opts); + await expect(res.text()).resolves.toContain(""); + }); + + test("does not render a non-HTML file", async () => { + const res = await fetchStatic("/sub/inside.txt", opts); + await expect(res.text()).resolves.toBe("INSIDE"); + }); + }); + describe("Content-Type", () => { test.each([ + // Text carries an explicit charset... + ["/index.html", "text/html; charset=utf-8"], + ["/sub/inside.txt", "text/plain; charset=utf-8"], + ["/app.js", "text/javascript; charset=utf-8"], + // ...while non-text bytes have none to declare. + ["/logo.png", "image/png"], + ["/LICENSE", "application/octet-stream"], + // Straight from the MIME table. ["/mod.wasm", "application/wasm"], ["/pic.avif", "image/avif"], ["/song.mp3", "audio/mpeg"], ["/bundle.gz", "application/gzip"], - ])("maps %s to %s", async (path, type) => { - await writeFile(join(dir, path.slice(1)), "X"); - const res = await fetchStatic(path); - expect(res.headers.get("content-type")).toBe(type); + ])("declares %s as %s", async (path, type) => { + expect((await fetchStatic(path!)).headers.get("content-type")).toBe(type); + }); + + test("declares a charset alongside Content-Encoding", async () => { + // The charset describes the decoded bytes, so a variant keeps it. + const res = await fetchEncoded("/app.js", "br"); + expect(res.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); + expect(res.headers.get("content-encoding")).toBe("br"); }); }); describe("HEAD", () => { test("returns headers with no body", async () => { - const res = await fetchWith("/app.js", { method: "HEAD" }); + const res = await fetchStatic("/app.js", {}, { method: "HEAD" }); expect(res.status).toBe(200); expect(res.headers.get("content-length")).toBe(String("PLAIN_JS".length)); - expect(res.headers.get("content-type")).toBe("text/javascript"); + expect(res.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); await expect(res.text()).resolves.toBe(""); }); test("reports the variant's headers without a body", async () => { - const res = await fetchWith("/app.js", { - method: "HEAD", - headers: { "accept-encoding": "br" }, - }); + const res = await fetchStatic( + "/app.js", + {}, + { method: "HEAD", headers: { "accept-encoding": "br" } }, + ); expect(res.headers.get("content-encoding")).toBe("br"); expect(res.headers.get("content-length")).toBe(String("BROTLI_JS".length)); await expect(res.text()).resolves.toBe(""); @@ -402,10 +498,10 @@ describe("serveStatic", () => { renderHTML: ({ html }: { html: string }) => new Response(`${html}`, { headers: { "x-rendered": "1" } }), }; - const get = await fetchWith("/index.html", {}, opts); + const get = await fetchStatic("/index.html", opts); await expect(get.text()).resolves.toContain(""); - const head = await fetchWith("/index.html", { method: "HEAD" }, opts); + const head = await fetchStatic("/index.html", opts, { method: "HEAD" }); expect(head.status).toBe(200); expect(head.headers.get("x-rendered")).toBe("1"); await expect(head.text()).resolves.toBe(""); @@ -423,38 +519,39 @@ describe("serveStatic", () => { }), ), }; - const head = await fetchWith("/index.html", { method: "HEAD" }, opts); + const head = await fetchStatic("/index.html", opts, { method: "HEAD" }); await expect(head.text()).resolves.toBe(""); expect(cancelled).toBe(true); }); }); describe("percent-encoded paths", () => { - test("decodes the pathname once for the lookup", async () => { - const res = await fetchStatic("/hello%20world.txt"); + test.each([ + ["/hello%20world.txt", "SPACE_NAME"], + ["/caf%C3%A9.txt", "UNICODE_NAME"], + ["/50%25.txt", "PERCENT_NAME"], + ])("decodes %s exactly once for the lookup", async (path, contents) => { + const res = await fetchStatic(path!); expect(res.status).toBe(200); - await expect(res.text()).resolves.toBe("SPACE_NAME"); - }); - - test("decodes non-ASCII names", async () => { - await expect(fetchStatic("/caf%C3%A9.txt").then((r) => r.text())).resolves.toBe( - "UNICODE_NAME", - ); - }); - - test("decodes an encoded literal percent", async () => { - await expect(fetchStatic("/50%25.txt").then((r) => r.text())).resolves.toBe("PERCENT_NAME"); + await expect(res.text()).resolves.toBe(contents); }); - test("keeps an encoded separator encoded", async () => { - // `%2F` must not become a path separator: the decoded lookup is for a - // file literally named `sub%2Finside.txt`, which does not exist. - expect((await fetchStatic("/sub%2Finside.txt")).status).toBe(404); + test.each([ + // The decoded lookup is for a file literally named `sub%2Finside.txt`. + "/sub%2Finside.txt", + // `%2f` survives `decodeURI`, so this stays a single literal filename + // rather than traversing. Reaches the middleware verbatim: `new Request()` + // only collapses real separators. + "/%2e%2e%2foutside%2fsecret.txt", + ])("keeps the encoded separator in %s encoded", async (path) => { + await expectNext(await fetchStatic(path), path); }); test("applies the dotfile policy to the decoded name", async () => { expect((await fetchStatic("/%2Eenv")).status).toBe(404); - await expect(fetchWithDotfiles("/%2Eenv").then((r) => r.text())).resolves.toBe("DOTENV"); + await expect(fetchStatic("/%2Eenv", { dotfiles: true }).then((r) => r.text())).resolves.toBe( + "DOTENV", + ); }); test("applies the dotfile allow-list to the decoded name", async () => { @@ -468,14 +565,11 @@ describe("serveStatic", () => { test("does not decode twice", async () => { // `%252e%252e` decodes once to the harmless literal `%2e%2e`. - const res = await fetchStatic("/%252e%252e/outside/secret.txt"); - expect(res.status).toBe(404); + await expectNext(await fetchStatic("/%252e%252e/outside/secret.txt")); }); - test("rejects malformed encoding with 400", async () => { - for (const path of ["/foo%", "/%ZZ"]) { - expect((await fetchStatic(path)).status, path).toBe(400); - } + test.each(["/foo%", "/%ZZ"])("rejects the malformed encoding %s with 400", async (path) => { + expect((await fetchStatic(path)).status, path).toBe(400); }); }); @@ -487,10 +581,10 @@ describe("serveStatic", () => { "/../outside/secret.txt", "/sub/../../outside/secret.txt", "/../../../../../../etc/passwd", + // A traversal that starts inside an allow-listed dot segment. + "/.well-known/../../outside/secret.txt", ])("serves no traversal from a raw %s", async (path) => { - const res = await fetchRaw(path); - expect(res.status, path).toBe(404); - await expect(res.text()).resolves.not.toContain("TOPSECRET"); + await expectNext(await fetchRaw(path), path); }); test.each(["/sub/../index.html", "/./index.html"])( @@ -503,20 +597,5 @@ describe("serveStatic", () => { await expect(res.text()).resolves.toContain("index"); }, ); - - test("does not serve a raw traversal into an allow-listed dot segment", async () => { - const res = await fetchRaw("/.well-known/../../outside/secret.txt"); - expect(res.status).toBe(404); - await expect(res.text()).resolves.not.toContain("TOPSECRET"); - }); - }); - - test("keeps an encoded separator from becoming a separator", async () => { - // `%2f` survives `decodeURI`, so this stays a single literal filename rather - // than traversing. Reaches the middleware verbatim: `new Request()` only - // collapses real separators. - const res = await fetchStatic("/%2e%2e%2foutside%2fsecret.txt"); - expect(res.status).toBe(404); - await expect(res.text()).resolves.not.toContain("TOPSECRET"); }); }); From 00d2bcf9ffd95542ba08f964c2f787daa16f3455 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:28:38 +0000 Subject: [PATCH 06/11] chore: apply automated updates --- test/static.test.ts | 79 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/test/static.test.ts b/test/static.test.ts index e2a52649..6be36d1e 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -119,8 +119,11 @@ const notFound = () => new Response("next()", { status: 404 }); const fetchStatic = (path: string, opts: Partial = {}, init?: RequestInit) => serveStatic({ dir, ...opts })(req(path, init), notFound) as Promise; -const fetchEncoded = (path: string, acceptEncoding: string, opts: Partial = {}) => - fetchStatic(path, opts, { headers: { "accept-encoding": acceptEncoding } }); +const fetchEncoded = ( + path: string, + acceptEncoding: string, + opts: Partial = {}, +) => fetchStatic(path, opts, { headers: { "accept-encoding": acceptEncoding } }); // Every denial falls through to the app rather than being answered here, so the // sentinel body from `notFound()` is what proves the middleware declined. It is @@ -242,9 +245,12 @@ describe("serveStatic", () => { ["/alias-dotdir/config.txt", "GIT_CONFIG"], ]; - test.each(DOT_ALIASES)("does not serve %s, which resolves onto a denied dot path", async (path) => { - await expectNext(await fetchStatic(path!)); - }); + test.each(DOT_ALIASES)( + "does not serve %s, which resolves onto a denied dot path", + async (path) => { + await expectNext(await fetchStatic(path!)); + }, + ); test.each(DOT_ALIASES)("serves %s with dotfiles: true", async (path, contents) => { // The policy is what hides these, not containment: both links resolve @@ -335,19 +341,51 @@ describe("serveStatic", () => { body: string; path?: string; }[] = [ - { why: "prefers brotli when both variants exist", accept: "gzip, br", enc: "br", body: "BROTLI_JS" }, - { why: "falls back to gzip when brotli is not accepted", accept: "gzip", enc: "gzip", body: "GZIP_JS" }, - { why: "falls back to the plain file when no variant is accepted", accept: "", enc: null, body: "PLAIN_JS" }, + { + why: "prefers brotli when both variants exist", + accept: "gzip, br", + enc: "br", + body: "BROTLI_JS", + }, + { + why: "falls back to gzip when brotli is not accepted", + accept: "gzip", + enc: "gzip", + body: "GZIP_JS", + }, + { + why: "falls back to the plain file when no variant is accepted", + accept: "", + enc: null, + body: "PLAIN_JS", + }, { why: "honors q=0 as a refusal", accept: "br;q=0, gzip", enc: "gzip", body: "GZIP_JS" }, { why: "honors an explicit q ranking", accept: "br;q=1.0", enc: "br", body: "BROTLI_JS" }, { why: "treats a malformed q as a refusal", accept: "br;q=abc", enc: null, body: "PLAIN_JS" }, { why: "supports the * wildcard", accept: "*", enc: "br", body: "BROTLI_JS" }, // "x-gzip" must not satisfy "gzip", nor "brotli" satisfy "br". - { why: "does not match an encoding as a substring", accept: "x-gzip, brotli", enc: null, body: "PLAIN_JS" }, + { + why: "does not match an encoding as a substring", + accept: "x-gzip, brotli", + enc: null, + body: "PLAIN_JS", + }, // An empty token must be skipped rather than parsed as an encoding. { why: "ignores empty tokens", accept: ", , br", enc: "br", body: "BROTLI_JS" }, - { why: "skips a missing variant and uses the next accepted one", accept: "br, gzip", enc: "gzip", body: "GZIP_ONLY_GZ", path: "/only-gz.js" }, - { why: "falls back to the plain file when no variant exists on disk", accept: "br", enc: null, body: "

index

", path: "/index.html" }, + { + why: "skips a missing variant and uses the next accepted one", + accept: "br, gzip", + enc: "gzip", + body: "GZIP_ONLY_GZ", + path: "/only-gz.js", + }, + { + why: "falls back to the plain file when no variant exists on disk", + accept: "br", + enc: null, + body: "

index

", + path: "/index.html", + }, ]; test.each(VARIANT_CASES)("$why", async ({ accept, enc, body, path = "/app.js" }) => { @@ -387,14 +425,17 @@ describe("serveStatic", () => { // rather than fatal — the identity file below it still serves. ["/escape-variant.js", "PLAIN_VARIANT", "TOPSECRET"], ["/alias-variant.js", "PLAIN_ALIAS_VARIANT", "DOTENV"], - ])("falls back to the plain file when %s's variant is not servable", async (path, body, secret) => { - const res = await fetchEncoded(path!, "br"); - expect(res.status).toBe(200); - expect(res.headers.get("content-encoding")).toBe(null); - const text = await res.text(); - expect(text).toBe(body); - expect(text).not.toContain(secret); - }); + ])( + "falls back to the plain file when %s's variant is not servable", + async (path, body, secret) => { + const res = await fetchEncoded(path!, "br"); + expect(res.status).toBe(200); + expect(res.headers.get("content-encoding")).toBe(null); + const text = await res.text(); + expect(text).toBe(body); + expect(text).not.toContain(secret); + }, + ); }); describe("extension-less paths", () => { From bd9395f8f3598cf63d5643d630e401b21f5b525f Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 11:54:27 +0000 Subject: [PATCH 07/11] fix(static): only probe the index for trailing-slash routes, serve via a single opened fd - A slash-terminated URL names a directory: `/sub/` resolves only `sub/index.html`, no longer `sub.html` or a file named `sub` (breaking). - Serving opens the file once and streams that fd: fstat + realpath containment + an inode comparison pin the served bytes to the checked path, closing the stat-then-createReadStream symlink swap race. - Test harness drains unconsumed response bodies: undici does not propagate `body.cancel()` to a wrapped Node readable, so status/header -only tests leaked the file handle until GC. Co-Authored-By: Claude Fable 5 --- docs/1.guide/4.middleware.md | 2 +- src/static.ts | 92 +++++++++++++++++++++++++++--------- test/static.test.ts | 42 +++++++++++++--- 3 files changed, 105 insertions(+), 31 deletions(-) diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index 4f725bbc..3b9d3fb4 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -98,7 +98,7 @@ When no file matches the request, it calls `next()` — so your handler acts as - `encodings`: Map of `Content-Encoding` to the file extension of its precompressed variant (default `{ br: ".br", gzip: ".gz" }`). Keys are tried in order, so list the preferred encoding first. Pass `{}` to disable. - `renderHTML`: A function receiving `{ request, html, filename }` for every HTML file (`.html`, `.htm`), returning the `Response` to send. Use it to inject or template markup before serving. -A request resolves in order: the path itself, then `.html`, then `/index.html`. So `/about` serves `about.html`, while an extension-less file (`LICENSE`, an ACME challenge token) is served at its exact name. +A request resolves in order: the path itself, then `.html`, then `/index.html`. So `/about` serves `about.html`, while an extension-less file (`LICENSE`, an ACME challenge token) is served at its exact name. A trailing-slash request names a directory, so `/sub/` resolves only `sub/index.html` — never `sub.html` or a file named `sub`. Files are never compressed on the fly. For `/app.js` with `Accept-Encoding: br`, `app.js.br` is served if it exists (with `Content-Encoding: br`), otherwise `app.js` is served as-is — so precompress assets at build time to serve them compressed. Variants are only looked up for compressible types, so a `.br` next to an image or font is ignored; those responses also omit `Vary: Accept-Encoding`, which compressible ones always set. `renderHTML` routes always read the source file, since a precompressed variant would not match the rendered output. diff --git a/src/static.ts b/src/static.ts index 894c5aab..4884a453 100644 --- a/src/static.ts +++ b/src/static.ts @@ -1,9 +1,9 @@ import type { ServerMiddleware } from "./types.ts"; import type { Stats } from "node:fs"; +import type { FileHandle } from "node:fs/promises"; import { extname, join, resolve, sep } from "node:path"; -import { readFile, realpath, stat } from "node:fs/promises"; -import { createReadStream } from "node:fs"; +import { open, realpath, stat } from "node:fs/promises"; import { FastResponse } from "srvx"; import { FastURL } from "./_url.ts"; @@ -128,6 +128,8 @@ const parseAcceptEncoding = ( // must not also match `/srv/www-backup`). Roots already end with `sep`. const asPrefix = (path: string): string => (path.endsWith(sep) ? path : path + sep); +type ServableFile = { handle: FileHandle; size: number }; + export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { const dir = asPrefix(resolve(options.dir)); const methods = new Set((options.methods || ["GET", "HEAD"]).map((m) => m.toUpperCase())); @@ -165,18 +167,39 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { return fileStat?.isFile() ? fileStat : null; }; - // The real security boundary. The handler's checks are lexical while - // `stat()` follows symlinks, so a link inside `dir` could escape the root - // (`escape.txt` -> `/etc/passwd`) or alias a denied dot path to an allowed - // name (`public.txt` -> `.env`). Re-assert both invariants against the - // resolved path; links that stay inside `dir` on an allowed path still work. - const isServable = async (candidate: string): Promise => { - const realPath = await realpath(candidate).catch(() => null); - if (realPath === null) { - return false; + // The real security boundary, and the only way a served file is opened. The + // handler's checks are lexical while the filesystem follows symlinks, so a + // link inside `dir` could escape the root (`escape.txt` -> `/etc/passwd`) or + // alias a denied dot path to an allowed name (`public.txt` -> `.env`): both + // invariants are re-asserted against the resolved path. Links that stay + // inside `dir` on an allowed path still work. + // + // The bytes served come from the fd opened here, and the final inode + // comparison pins that fd to the path that passed the checks — with a + // check-then-`createReadStream(path)` sequence, a symlink swap in between + // would serve a file the checks never saw. + const openServable = async (candidate: string): Promise => { + const handle = await open(candidate).catch(() => null); + if (handle === null) { + return null; + } + try { + const fileStat = await handle.stat(); + const realPath = fileStat.isFile() ? await realpath(candidate).catch(() => null) : null; + if (realPath !== null) { + const root = await getRealDir(); + if (realPath.startsWith(root) && !isDeniedDotPath(realPath.slice(root.length))) { + const realStat = await stat(realPath).catch(() => null); + if (realStat && realStat.ino === fileStat.ino && realStat.dev === fileStat.dev) { + return { handle, size: fileStat.size }; + } + } + } + } catch { + // fall through to close } - const root = await getRealDir(); - return realPath.startsWith(root) && !isDeniedDotPath(realPath.slice(root.length)); + await handle.close().catch(() => {}); + return null; }; return async (req, next) => { @@ -184,7 +207,14 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { return next(); } const url = (req._url ??= new FastURL(req.url)); - let path = url.pathname.slice(1).replace(/\/$/, ""); + let path = url.pathname.slice(1); + // `/sub/` names a directory, so only its index is probed: serving `sub.html` + // or a file named `sub` there would mint a second URL for them, with + // relative links inside resolving against the wrong base. + const trailingSlash = path.endsWith("/"); + if (trailingSlash) { + path = path.replace(/\/+$/, ""); + } if (path.includes("%")) { // Decode the wire encoding exactly once, or names a client must encode // (`café.txt`) are unreachable. `decodeURI` (not `decodeURIComponent`) @@ -202,6 +232,8 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { let paths: string[]; if (path === "") { paths = ["index.html"]; + } else if (trailingSlash) { + paths = [`${path}/index.html`]; } else if (extname(path) === "") { // TODO: consider answering `/sub` with a redirect to `/sub/` instead of // serving `sub/index.html` in place (nginx sends 301): without the @@ -239,29 +271,41 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { let encoding = ""; let servePath = filePath; - let fileStat = identityStat; + let file: ServableFile | null = null; if (compressible) { acceptEncodings ??= parseAcceptEncoding(req.headers.get("accept-encoding"), encodings); for (const [name, ext] of acceptEncodings) { const variantPath = filePath + ext; - const variantStat = await statFile(variantPath); + // `stat` before `open`: a missing variant should cost one syscall, + // and opening a non-file (a FIFO) can block. + if (!(await statFile(variantPath))) { + continue; + } // An unservable variant (escapes the root, or resolves onto a denied // dot path) is skipped, not fatal: the identity file can still serve. - if (variantStat && (await isServable(variantPath))) { + const variant = await openServable(variantPath); + if (variant) { encoding = name; servePath = variantPath; - fileStat = variantStat; + file = variant; break; } } } // Only the bytes actually sent need checking; a winning variant already was. - if (!encoding && !(await isServable(filePath))) { + file ??= await openServable(filePath); + if (!file) { continue; } if (renderHTML) { + let html: string; + try { + html = await file.handle.readFile("utf8"); + } finally { + await file.handle.close().catch(() => {}); + } const rendered = await renderHTML({ - html: await readFile(servePath, "utf8"), + html, filename: servePath, request: req, }); @@ -281,7 +325,7 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // is the encoding (`.br`), not the media type. Text carries an explicit // charset so browsers do not decode non-ASCII with a guessed fallback. const headers: Record = { - "Content-Length": fileStat.size.toString(), + "Content-Length": file.size.toString(), "Content-Type": contentType.startsWith("text/") ? `${contentType}; charset=utf-8` : contentType, @@ -295,10 +339,12 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { headers["Vary"] = "Accept-Encoding"; } if (req.method === "HEAD") { - // Node discards a HEAD body at the http layer; skip the file I/O. + // Node discards a HEAD body at the http layer; skip the read entirely. + await file.handle.close().catch(() => {}); return new FastResponse(null, { headers }); } - return new FastResponse(createReadStream(servePath) as any, { headers }); + // The stream closes the handle when it ends or errors (`autoClose`). + return new FastResponse(file.handle.createReadStream() as any, { headers }); } return next(); }; diff --git a/test/static.test.ts b/test/static.test.ts index 6be36d1e..a0681809 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import { describe, test, expect, beforeAll, afterAll, afterEach } from "vitest"; import { mkdtemp, mkdir, rm, writeFile, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, parse, relative, sep } from "node:path"; @@ -116,8 +116,28 @@ const req = (path: string, init?: RequestInit) => new Request(`http://localhost${path}`, init) as unknown as ServerRequest; const notFound = () => new Response("next()", { status: 404 }); +// Served bodies are backed by an open file handle that a real server drains to +// the socket. Tests that only look at the status or headers must still release +// it, or the run leaks handles until GC — so every response is registered and +// any unconsumed body cancelled after each test. +const responses: Response[] = []; +const track = async (res: Promise) => { + const resolved = await res; + responses.push(resolved); + return resolved; +}; +afterEach(async () => { + for (const res of responses.splice(0)) { + if (!res.bodyUsed) { + // Drained, not `body.cancel()`-ed: undici does not propagate a cancel + // to a wrapped Node readable, so only a read releases the handle. + await res.arrayBuffer().catch(() => {}); + } + } +}); + const fetchStatic = (path: string, opts: Partial = {}, init?: RequestInit) => - serveStatic({ dir, ...opts })(req(path, init), notFound) as Promise; + track(serveStatic({ dir, ...opts })(req(path, init), notFound) as Promise); const fetchEncoded = ( path: string, @@ -146,7 +166,7 @@ const rawReq = (path: string) => { }; const fetchRaw = (path: string) => - serveStatic({ dir })(rawReq(path), notFound) as Promise; + track(serveStatic({ dir })(rawReq(path), notFound) as Promise); describe("serveStatic", () => { test("serves a file", async () => { @@ -201,13 +221,21 @@ describe("serveStatic", () => { await expect(res.text()).resolves.toContain("sub index"); }); - test.each(["/sub/", "/app.js/"])("ignores a trailing slash on %s", async (path) => { - // The strip matters most for a file: `stat("app.js/")` is ENOTDIR, so - // without it the route 404s. A directory would resolve either way, since - // `join()` collapses the `sub//index.html` candidate. + test.each(["/sub/", "/sub//"])("serves /index.html for %s", async (path) => { const res = await fetchStatic(path); expect(res.status, path).toBe(200); + await expect(res.text()).resolves.toContain("sub index"); }); + + test.each(["/app.js/", "/sub/inside.txt/", "/about/"])( + "only probes the index for a slash-terminated URL (%s)", + async (path) => { + // `/app.js/` must not serve `app.js`, nor `/about/` serve `about.html`: + // a directory URL names the index or nothing, or the same file gains a + // second URL whose relative links resolve against the wrong base. + await expectNext(await fetchStatic(path)); + }, + ); }); describe("symlinks", () => { From 3a6ccfe5b38de7eb95ed47100b0abadcf4bef75a Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 12:07:36 +0000 Subject: [PATCH 08/11] up --- src/static.ts | 93 ++++++++++++++++++++++++++++----------------------- 1 file changed, 51 insertions(+), 42 deletions(-) diff --git a/src/static.ts b/src/static.ts index 4884a453..da7a6ff1 100644 --- a/src/static.ts +++ b/src/static.ts @@ -78,52 +78,10 @@ const COMMON_MIME_TYPES: Record = { ".pdf": "application/pdf", }; -// Types that benefit from compression — everything else (images, video, audio, -// archives, fonts) is already compressed and would not have a `.br`/`.gz` variant. -const isCompressible = (mimeType: string): boolean => - mimeType.startsWith("text/") || - mimeType.endsWith("+json") || - mimeType.endsWith("+xml") || - mimeType === "application/json" || - mimeType === "application/xml" || - mimeType === "application/wasm"; - // RFC 8615 reserves `/.well-known/` for public metadata (ACME HTTP-01 // challenges, `security.txt`), so it is the only dot segment served by default. const DEFAULT_DOTFILES = [".well-known"]; -/** - * Encodings from `encodings` the client accepts, in server-preference order. - * `q=0` is honored as "not acceptable"; `*` applies to encodings not named explicitly. - */ -const parseAcceptEncoding = ( - header: string | null, - encodings: Record, -): [encoding: string, ext: string][] => { - if (!header) { - return []; - } - const quality = new Map(); - for (const part of header.split(",")) { - const [token, ...params] = part.split(";"); - const name = token!.trim().toLowerCase(); - if (!name) { - continue; - } - let q = 1; - for (const param of params) { - const trimmed = param.trim(); - if (trimmed.startsWith("q=")) { - // A malformed q (`q=abc`) parses to NaN: treat it as refused. - q = Number.parseFloat(trimmed.slice(2)) || 0; - } - } - quality.set(name, q); - } - const wildcard = quality.get("*"); - return Object.entries(encodings).filter(([name]) => (quality.get(name) ?? wildcard ?? 0) > 0); -}; - // Append `sep` so prefix checks only match at a segment boundary (`/srv/www` // must not also match `/srv/www-backup`). Roots already end with `sep`. const asPrefix = (path: string): string => (path.endsWith(sep) ? path : path + sep); @@ -131,8 +89,12 @@ const asPrefix = (path: string): string => (path.endsWith(sep) ? path : path + s type ServableFile = { handle: FileHandle; size: number }; export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { + // `resolve()` also converts separators (`C:/assets` -> `C:\assets`), and + // `join()`/`realpath()` only emit native ones, so every path a `sep`-based + // check sees is already in platform form. const dir = asPrefix(resolve(options.dir)); const methods = new Set((options.methods || ["GET", "HEAD"]).map((m) => m.toUpperCase())); + const dotfiles = options.dotfiles ?? DEFAULT_DOTFILES; const allowAllDots = dotfiles === true; const allowedDots = new Set(Array.isArray(dotfiles) ? dotfiles : []); @@ -349,3 +311,50 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { return next(); }; }; + +// --- internal --- + +// Types that benefit from compression — everything else (images, video, audio, +// archives, fonts) is already compressed and would not have a `.br`/`.gz` variant. +function isCompressible(mimeType: string): boolean { + return ( + mimeType.startsWith("text/") || + mimeType.endsWith("+json") || + mimeType.endsWith("+xml") || + mimeType === "application/json" || + mimeType === "application/xml" || + mimeType === "application/wasm" + ); +} + +/** + * Encodings from `encodings` the client accepts, in server-preference order. + * `q=0` is honored as "not acceptable"; `*` applies to encodings not named explicitly. + */ +function parseAcceptEncoding( + header: string | null, + encodings: Record, +): [encoding: string, ext: string][] { + if (!header) { + return []; + } + const quality = new Map(); + for (const part of header.split(",")) { + const [token, ...params] = part.split(";"); + const name = token!.trim().toLowerCase(); + if (!name) { + continue; + } + let q = 1; + for (const param of params) { + const trimmed = param.trim(); + if (trimmed.startsWith("q=")) { + // A malformed q (`q=abc`) parses to NaN: treat it as refused. + q = Number.parseFloat(trimmed.slice(2)) || 0; + } + } + quality.set(name, q); + } + const wildcard = quality.get("*"); + return Object.entries(encodings).filter(([name]) => (quality.get(name) ?? wildcard ?? 0) > 0); +} From b525bc682e42577cc5931f67530cc1b00eb4875f Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 12:34:14 +0000 Subject: [PATCH 09/11] fix(static): open with O_NONBLOCK so a swapped-in FIFO cannot stall the pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stat()` reports a candidate's mode, but `open()` acts on it a syscall later, and `open()` on a FIFO waits for a writer that may never come. An attacker who can write into the served root — the same one the symlink checks already assume — can swap a regular file for a pipe in that window and park a libuv threadpool thread. The pool is 4 threads by default, so a handful of wins stall every fs operation in the process. O_NONBLOCK closes the window rather than narrowing it: the open returns immediately and the `fstat` mode check already in `openServable` declines the pipe. Reads of regular files ignore the flag, and Windows has no O_NONBLOCK because `open()` cannot block this way there. A FIFO merely sitting in the root never reaches `open()` — `statFile` rejects it first — so covering this means reproducing the lost race, which a lying `stat` stands in for. `vi.mock` is file-wide, hence a separate test file; it carries a guard test so the FIFO case cannot pass for the wrong reason. Co-Authored-By: Claude Opus 4.8 --- src/static.ts | 16 ++++++-- test/static-nonblock.test.ts | 74 ++++++++++++++++++++++++++++++++++++ test/static.test.ts | 38 ++++++++++++++++++ 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 test/static-nonblock.test.ts diff --git a/src/static.ts b/src/static.ts index da7a6ff1..f3ce51c8 100644 --- a/src/static.ts +++ b/src/static.ts @@ -3,6 +3,7 @@ import type { Stats } from "node:fs"; import type { FileHandle } from "node:fs/promises"; import { extname, join, resolve, sep } from "node:path"; +import { constants } from "node:fs"; import { open, realpath, stat } from "node:fs/promises"; import { FastResponse } from "srvx"; import { FastURL } from "./_url.ts"; @@ -86,6 +87,15 @@ const DEFAULT_DOTFILES = [".well-known"]; // must not also match `/srv/www-backup`). Roots already end with `sep`. const asPrefix = (path: string): string => (path.endsWith(sep) ? path : path + sep); +// A candidate is only known to be a regular file once it is open (the check is an +// `fstat` on the fd), and `open()` on a FIFO blocks until a writer arrives. Without +// `O_NONBLOCK` a pipe swapped in for a file — before the `stat` that precedes an +// `open`, or between the two — parks a libuv threadpool thread indefinitely, and the +// pool is 4 threads by default, so a handful of requests stall every fs op in the +// process. Reads of regular files ignore the flag. Windows has no `O_NONBLOCK` and +// cannot block this way: the `?? 0` leaves plain `O_RDONLY` there. +const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0); + type ServableFile = { handle: FileHandle; size: number }; export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { @@ -141,7 +151,7 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // check-then-`createReadStream(path)` sequence, a symlink swap in between // would serve a file the checks never saw. const openServable = async (candidate: string): Promise => { - const handle = await open(candidate).catch(() => null); + const handle = await open(candidate, OPEN_FLAGS).catch(() => null); if (handle === null) { return null; } @@ -238,8 +248,8 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { acceptEncodings ??= parseAcceptEncoding(req.headers.get("accept-encoding"), encodings); for (const [name, ext] of acceptEncodings) { const variantPath = filePath + ext; - // `stat` before `open`: a missing variant should cost one syscall, - // and opening a non-file (a FIFO) can block. + // `stat` before `open`: a missing variant (the common case) should cost + // one syscall rather than an `open`/`fstat`/`close`. if (!(await statFile(variantPath))) { continue; } diff --git a/test/static-nonblock.test.ts b/test/static-nonblock.test.ts new file mode 100644 index 00000000..247deb2b --- /dev/null +++ b/test/static-nonblock.test.ts @@ -0,0 +1,74 @@ +import { describe, test, expect, beforeAll, afterAll, vi } from "vitest"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ServerRequest } from "../src/types.ts"; + +// `serveStatic` reads a candidate's mode with `stat()` and opens it a syscall +// later, so an attacker who can write into the root can swap a regular file for +// a FIFO in between and `open()` will wait for a writer that never comes. That +// window is too narrow to hit deterministically, so it is reproduced here by +// making every `stat()` report a regular file whatever is really on disk — the +// state the middleware believes it is in after losing the race. `FileHandle.stat()` +// is a method on the returned handle, not this module binding, so the mode check +// inside `openServable` stays honest. +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + stat: async (path: Parameters[0]) => { + const stats = await actual.stat(path); // still throws ENOENT for a real miss + stats.isFile = () => true; + return stats; + }, + }; +}); + +const { serveStatic } = await import("../src/static.ts"); + +let tmp: string; +let dir: string; + +beforeAll(async () => { + tmp = await mkdtemp(join(tmpdir(), "srvx-nonblock-")); + dir = join(tmp, "public"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "real.bin"), "REAL"); +}); + +afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); +}); + +const fetchStatic = (path: string) => + serveStatic({ dir })( + new Request(`http://localhost${path}`) as unknown as ServerRequest, + () => new Response("next()", { status: 404 }), + ) as Promise; + +// `mkfifo` is POSIX-only, and `O_NONBLOCK` does not exist on Windows because +// `open()` cannot block this way there. +describe.skipIf(process.platform === "win32")("open() cannot block on a non-regular file", () => { + test("the lying stat still serves a genuine regular file", async () => { + // Guards the mock itself: if this broke, the FIFO test below would pass for + // the wrong reason. + const res = await fetchStatic("/real.bin"); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("REAL"); + }); + + // `.bin` is incompressible, so no variant probing runs before the open. + test("declines a FIFO that stat claimed was a regular file", async () => { + const fifo = join(dir, "swapped.bin"); + execFileSync("mkfifo", [fifo]); + try { + // Without O_NONBLOCK this never settles and the test times out. + const res = await fetchStatic("/swapped.bin"); + expect(res.status).toBe(404); + await expect(res.text()).resolves.toBe("next()"); + } finally { + await rm(fifo, { force: true }); + } + }); +}); diff --git a/test/static.test.ts b/test/static.test.ts index a0681809..9617d76f 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, beforeAll, afterAll, afterEach } from "vitest"; import { mkdtemp, mkdir, rm, writeFile, symlink } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join, parse, relative, sep } from "node:path"; import { serveStatic, type ServeStaticOptions } from "../src/static.ts"; @@ -298,6 +299,43 @@ describe("serveStatic", () => { }); }); + // `mkfifo` is POSIX-only; Windows has no FIFO to open in the first place. + // + // These pin the ordinary outcome — a pipe sitting in the root is refused by the + // `stat()` mode check, before any `open()`. The harder case, where that check is + // won by a swap and `open()` itself meets the FIFO, needs a lying `stat` to + // reproduce and lives in `static-nonblock.test.ts`. + describe.skipIf(process.platform === "win32")("non-regular files", () => { + test("declines a FIFO", async () => { + const fifo = join(dir, "pipe.txt"); + execFileSync("mkfifo", [fifo]); + try { + await expectNext(await fetchStatic("/pipe.txt")); + } finally { + await rm(fifo, { force: true }); + } + }); + + // Its own identity file rather than `app.js`, whose `.br` the shared fixture + // already occupies. + test("declines a FIFO standing in for a precompressed variant", async () => { + const identity = join(dir, "piped.js"); + const fifo = `${identity}.br`; + await writeFile(identity, "PIPED_JS"); + execFileSync("mkfifo", [fifo]); + try { + // The identity file still serves; only the variant is unusable. + const res = await fetchEncoded("/piped.js", "br"); + expect(res.status).toBe(200); + expect(res.headers.get("content-encoding")).toBe(null); + await expect(res.text()).resolves.toBe("PIPED_JS"); + } finally { + await rm(fifo, { force: true }); + await rm(identity, { force: true }); + } + }); + }); + describe("dotfiles", () => { const DOT_PATHS = [ ["/.env", "DOTENV"], From 0a52cced42eff8f2d68df8df21a946df78e810df Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 12:54:42 +0000 Subject: [PATCH 10/11] feat(static): compress on the fly when no precompressed variant exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores on-the-fly compression, which this branch had removed wholesale to close a CPU amplification vector. The vector was the *quality*, not the feature: `createBrotliCompress()` defaults to BROTLI_DEFAULT_QUALITY (11), the maximum, which costs ~12x quality 4 for a few percent of size — per request, uncached, on the same 4-thread libuv pool as every stat/open here. Compression also inverts what normally makes a large file self-limiting: the response gets smaller while the server burns CPU proportional to the *uncompressed* size, so the request stops paying for itself in bandwidth. Hence the two bounds, not just the lower quality. - brotli at quality 4, with BROTLI_PARAM_SIZE_HINT from the fstat'd size - only between 1 KiB (encoded output can exceed the input) and 10 MiB (precompress instead — a build affords a better ratio anyway) - a variant on disk always wins, since it costs no CPU - HEAD skips it entirely: Node discards the body at the http layer, so the headers describe what GET would send and the bytes are never produced - `compress: false` serves only what is on disk; `encodings: {}` now skips the disk lookup while still compressing, rather than disabling everything `pipeline()` rather than `stream.pipe(encoded)`: `pipe` leaves the source running when the destination is destroyed, so a client disconnecting mid-response strands the fd until GC. Measured with 9 MiB of random bytes (slow enough to brotli that the abort lands mid-stream): `pipe` strands 6 fds over 20 aborts and Node warns "Closing file descriptor N on garbage collection"; `pipeline` strands none. Co-Authored-By: Claude Opus 4.8 --- docs/1.guide/4.middleware.md | 9 ++- src/static.ts | 152 +++++++++++++++++++++++++++++----- test/static.test.ts | 153 ++++++++++++++++++++++++++++++++++- 3 files changed, 288 insertions(+), 26 deletions(-) diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index 3b9d3fb4..80f504f3 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -95,12 +95,17 @@ When no file matches the request, it calls `next()` — so your handler acts as - `dir`: The directory to serve files from (required). - `methods`: HTTP methods to serve (default `["GET", "HEAD"]`). Other methods fall through to `next()`. - `dotfiles`: Dot segments (path segments starting with `.`) that may be served (default `[".well-known"]`). A path containing any other dot segment — `/.env`, `/.git/config` — falls through to `next()`. Pass `true` to serve every dot segment, or `false` (or `[]`) to serve none, including `/.well-known/`. -- `encodings`: Map of `Content-Encoding` to the file extension of its precompressed variant (default `{ br: ".br", gzip: ".gz" }`). Keys are tried in order, so list the preferred encoding first. Pass `{}` to disable. +- `encodings`: Map of `Content-Encoding` to the file extension of its precompressed variant (default `{ br: ".br", gzip: ".gz" }`). Keys are tried in order, so list the preferred encoding first. Pass `{}` to skip the lookup entirely; `compress` is unaffected. +- `compress`: Compress a response on the fly when no precompressed variant is found (default `true`). Pass `false` to serve only what is already on disk. - `renderHTML`: A function receiving `{ request, html, filename }` for every HTML file (`.html`, `.htm`), returning the `Response` to send. Use it to inject or template markup before serving. A request resolves in order: the path itself, then `.html`, then `/index.html`. So `/about` serves `about.html`, while an extension-less file (`LICENSE`, an ACME challenge token) is served at its exact name. A trailing-slash request names a directory, so `/sub/` resolves only `sub/index.html` — never `sub.html` or a file named `sub`. -Files are never compressed on the fly. For `/app.js` with `Accept-Encoding: br`, `app.js.br` is served if it exists (with `Content-Encoding: br`), otherwise `app.js` is served as-is — so precompress assets at build time to serve them compressed. Variants are only looked up for compressible types, so a `.br` next to an image or font is ignored; those responses also omit `Vary: Accept-Encoding`, which compressible ones always set. `renderHTML` routes always read the source file, since a precompressed variant would not match the rendered output. +For `/app.js` with `Accept-Encoding: br`, `app.js.br` is served if it exists (with `Content-Encoding: br`); with no variant on disk, `app.js` is compressed as it is sent. A variant always wins, because it costs no CPU — so precompressing at build time remains the cheapest way to serve compressed assets, and a build can afford a better ratio than a per-request encode can justify. Pass `compress: false` to serve only what is on disk, or `encodings: {}` to skip the lookup and always compress on the fly. + +Brotli compresses at quality 4 rather than the `node:zlib` default of 11, which costs roughly 12x the CPU for a few percent of size. Only files between 1 KiB and 10 MiB are compressed on the fly: below that the encoded body can come out larger than the input, and above it the CPU spent per request outweighs the bandwidth saved — precompress large assets instead. On-the-fly responses are chunked, since the encoded length is not known until the bytes exist, while a variant declares the `Content-Length` it has on disk. + +Compression applies to compressible types only, so a `.br` next to an image or font is ignored, and those responses omit `Vary: Accept-Encoding` — which compressible ones always set, including uncompressed ones, as a shared cache must key on the header either way. `renderHTML` routes are never compressed and always read the source file: a variant on disk would not match the rendered output, and the `Response` the hook returns is the caller's to encode. `/.well-known/` is served by default because [RFC 8615](https://www.rfc-editor.org/rfc/rfc8615) reserves it for public metadata: ACME HTTP-01 challenges and `security.txt` live there. Allow-listing is by exact segment name, so `[".well-known"]` serves neither a sibling sharing its prefix (`.well-known-backup`) nor a dot segment nested under it (`.well-known/.env`). diff --git a/src/static.ts b/src/static.ts index f3ce51c8..562c4490 100644 --- a/src/static.ts +++ b/src/static.ts @@ -1,10 +1,13 @@ import type { ServerMiddleware } from "./types.ts"; import type { Stats } from "node:fs"; import type { FileHandle } from "node:fs/promises"; +import type { Transform } from "node:stream"; import { extname, join, resolve, sep } from "node:path"; import { constants } from "node:fs"; import { open, realpath, stat } from "node:fs/promises"; +import { pipeline } from "node:stream"; +import { constants as zlibConstants, createBrotliCompress, createGzip } from "node:zlib"; import { FastResponse } from "srvx"; import { FastURL } from "./_url.ts"; @@ -32,14 +35,25 @@ export interface ServeStaticOptions { /** * Map of `Content-Encoding` to the file extension of its precompressed variant on disk. * - * Files are never compressed on the fly: for `/app.js` with `Accept-Encoding: br`, - * `app.js.br` is served if it exists, otherwise `app.js` is served as-is. Keys are - * tried in order, so list the preferred encoding first. Pass `{}` to disable. + * For `/app.js` with `Accept-Encoding: br`, `app.js.br` is served if it exists. Keys are + * tried in order, so list the preferred encoding first. Pass `{}` to skip the lookup + * entirely — `compress` is unaffected, so on-the-fly encoding still applies. * * @default { br: ".br", gzip: ".gz" } */ encodings?: Record; + /** + * Compress a response on the fly when no precompressed variant is found. + * + * Applies to compressible types only, and only to files between 1 KiB and 10 MiB — + * precompress anything larger. A variant from `encodings` always wins, as it costs no + * CPU. Pass `false` to serve only what is already on disk. + * + * @default true + */ + compress?: boolean; + /** * A function to modify the HTML content before serving it. */ @@ -83,6 +97,40 @@ const COMMON_MIME_TYPES: Record = { // challenges, `security.txt`), so it is the only dot segment served by default. const DEFAULT_DOTFILES = [".well-known"]; +const DEFAULT_ENCODINGS: Record = { br: ".br", gzip: ".gz" }; + +// `createBrotliCompress()` defaults to BROTLI_DEFAULT_QUALITY (11), the maximum, +// which costs roughly 12x quality 4 for a few percent of size — per request, with +// nothing cached. Quality 4 is what CDNs encode at dynamically. The bill is not +// only this request's latency: zlib streams run on the same 4-thread libuv pool +// as every `stat`/`open` here, so an over-tuned quality stalls the fs work too. +const BROTLI_QUALITY = 4; + +// Under a TCP segment there is nothing to win: the encoded body can come out +// larger than the input, and it costs a round of CPU to find that out. +const COMPRESS_MIN_SIZE = 1024; + +// Compression inverts what normally makes a large file self-limiting — the +// response gets *smaller* while the server burns CPU proportional to the +// *uncompressed* size, so the request stops paying for itself in bandwidth. +// Past this, serve the bytes as-is and let a build step precompress them. +const COMPRESS_MAX_SIZE = 10 * 1024 * 1024; + +const COMPRESSORS: Record Transform> = { + br: (sizeHint) => + createBrotliCompress({ + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY, + // Known here and never a guess, so brotli can size its window and + // allocations up front rather than growing them as the stream runs. + [zlibConstants.BROTLI_PARAM_SIZE_HINT]: sizeHint, + }, + }), + // zlib's own default level (6). gzip is cheap enough at any level — roughly an + // order of magnitude under brotli — that the default needs no walking back. + gzip: () => createGzip(), +}; + // Append `sep` so prefix checks only match at a segment boundary (`/srv/www` // must not also match `/srv/www-backup`). Roots already end with `sep`. const asPrefix = (path: string): string => (path.endsWith(sep) ? path : path + sep); @@ -98,6 +146,14 @@ const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0); type ServableFile = { handle: FileHandle; size: number }; +// An encoding this middleware can answer with: by serving a precompressed variant +// beside the file (`ext`), by encoding on the fly (`compressor`), or either. +type EncodingSpec = { + name: string; + ext?: string; + compressor?: (sizeHint: number) => Transform; +}; + export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // `resolve()` also converts separators (`C:/assets` -> `C:\assets`), and // `join()`/`realpath()` only emit native ones, so every path a `sep`-based @@ -116,8 +172,26 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { const isDeniedDotPath = (relPath: string): boolean => !allowAllDots && relPath.split(sep).some((s) => s[0] === "." && !allowedDots.has(s)); - const encodings = options.encodings || { br: ".br", gzip: ".gz" }; - const varyOnEncoding = Object.keys(encodings).length > 0; + const encodings = options.encodings || DEFAULT_ENCODINGS; + const compress = options.compress ?? true; + + // Encodings served, in server-preference order. `encodings` leads: its order is + // the documented preference, and a variant on disk costs no CPU. An encoding + // reachable only by compressing follows, so `encodings: {}` with `compress` is + // "never probe the disk, always encode on the fly" rather than a dead option. + const served: EncodingSpec[] = [ + ...Object.entries(encodings).map(([name, ext]) => ({ + name, + ext, + compressor: compress ? COMPRESSORS[name] : undefined, + })), + ...(compress + ? Object.keys(COMPRESSORS) + .filter((name) => !(name in encodings)) + .map((name) => ({ name, compressor: COMPRESSORS[name] })) + : []), + ]; + const varyOnEncoding = served.length > 0; // Symlink-resolved `dir` for containment checks — `dir` itself may // legitimately be a symlink. Resolved lazily and cached only on success, so @@ -219,7 +293,7 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { paths = [path]; } // Parsed lazily: unmatched routes (all non-static traffic) never need it. - let acceptEncodings: [encoding: string, ext: string][] | undefined; + let acceptEncodings: EncodingSpec[] | undefined; for (const candidate of paths) { const filePath = join(dir, candidate); // Cheap lexical pre-filter — `isServable` is the real boundary. Also @@ -237,17 +311,21 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { const contentType = COMMON_MIME_TYPES[extname(filePath)] || "application/octet-stream"; // Keyed off the resolved type so `.htm` renders like `.html`. const renderHTML = contentType === "text/html" ? options.renderHTML : undefined; - // No variant lookup for already-compressed types, nor for `renderHTML` - // routes, whose output a variant on disk would not match. + // Already-compressed types gain nothing from either path. `renderHTML` + // routes are excluded too: a variant on disk would not match the rendered + // output, and the rendered `Response` is the caller's to encode. const compressible = !renderHTML && isCompressible(contentType); let encoding = ""; let servePath = filePath; let file: ServableFile | null = null; if (compressible) { - acceptEncodings ??= parseAcceptEncoding(req.headers.get("accept-encoding"), encodings); - for (const [name, ext] of acceptEncodings) { - const variantPath = filePath + ext; + acceptEncodings ??= parseAcceptEncoding(req.headers.get("accept-encoding"), served); + for (const spec of acceptEncodings) { + if (!spec.ext) { + continue; + } + const variantPath = filePath + spec.ext; // `stat` before `open`: a missing variant (the common case) should cost // one syscall rather than an `open`/`fstat`/`close`. if (!(await statFile(variantPath))) { @@ -257,7 +335,7 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // dot path) is skipped, not fatal: the identity file can still serve. const variant = await openServable(variantPath); if (variant) { - encoding = name; + encoding = spec.name; servePath = variantPath; file = variant; break; @@ -269,6 +347,23 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { if (!file) { continue; } + // Nothing precompressed on disk: encode on the fly instead, provided the + // client takes an encoding we can produce and the file is in the size band + // where spending CPU is worth it. `file.size` (an `fstat` on the fd we are + // about to read) is the size actually served, not the earlier probe's. + let compressor: ((sizeHint: number) => Transform) | undefined; + if ( + compressible && + !encoding && + file.size >= COMPRESS_MIN_SIZE && + file.size <= COMPRESS_MAX_SIZE + ) { + const spec = acceptEncodings!.find((s) => s.compressor); + if (spec) { + encoding = spec.name; + compressor = spec.compressor; + } + } if (renderHTML) { let html: string; try { @@ -297,11 +392,15 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // is the encoding (`.br`), not the media type. Text carries an explicit // charset so browsers do not decode non-ASCII with a guessed fallback. const headers: Record = { - "Content-Length": file.size.toString(), "Content-Type": contentType.startsWith("text/") ? `${contentType}; charset=utf-8` : contentType, }; + // An encoded length is only known once the bytes exist, so an on-the-fly + // response is chunked. A variant's length is just its size on disk. + if (!compressor) { + headers["Content-Length"] = file.size.toString(); + } if (encoding) { headers["Content-Encoding"] = encoding; } @@ -311,12 +410,26 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { headers["Vary"] = "Accept-Encoding"; } if (req.method === "HEAD") { - // Node discards a HEAD body at the http layer; skip the read entirely. + // Node discards a HEAD body at the http layer, so skip the read — and + // with it the compression a GET would pay for. The headers still + // describe what GET would send, chunked encoding included. await file.handle.close().catch(() => {}); return new FastResponse(null, { headers }); } // The stream closes the handle when it ends or errors (`autoClose`). - return new FastResponse(file.handle.createReadStream() as any, { headers }); + const stream = file.handle.createReadStream(); + if (!compressor) { + return new FastResponse(stream as any, { headers }); + } + // `pipeline` rather than `stream.pipe(encoded)`: `pipe` leaves the source + // running if the destination errors or is destroyed, so a client that + // disconnects mid-response would strand the fd until GC. `pipeline` tears + // down both. Errors land in the callback (an aborted response is routine) + // and destroy the streams, which surfaces to the client as a truncated + // body — the response headers are long gone by then. + const encoded = compressor(file.size); + pipeline(stream, encoded, () => {}); + return new FastResponse(encoded as any, { headers }); } return next(); }; @@ -338,13 +451,10 @@ function isCompressible(mimeType: string): boolean { } /** - * Encodings from `encodings` the client accepts, in server-preference order. + * Encodings from `served` the client accepts, in server-preference order. * `q=0` is honored as "not acceptable"; `*` applies to encodings not named explicitly. */ -function parseAcceptEncoding( - header: string | null, - encodings: Record, -): [encoding: string, ext: string][] { +function parseAcceptEncoding(header: string | null, served: EncodingSpec[]): EncodingSpec[] { if (!header) { return []; } @@ -366,5 +476,5 @@ function parseAcceptEncoding( quality.set(name, q); } const wildcard = quality.get("*"); - return Object.entries(encodings).filter(([name]) => (quality.get(name) ?? wildcard ?? 0) > 0); + return served.filter(({ name }) => (quality.get(name) ?? wildcard ?? 0) > 0); } diff --git a/test/static.test.ts b/test/static.test.ts index 9617d76f..40c131b5 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -1,8 +1,9 @@ import { describe, test, expect, beforeAll, afterAll, afterEach } from "vitest"; -import { mkdtemp, mkdir, rm, writeFile, symlink } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, writeFile, symlink, truncate } from "node:fs/promises"; import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join, parse, relative, sep } from "node:path"; +import { brotliDecompressSync, gunzipSync } from "node:zlib"; import { serveStatic, type ServeStaticOptions } from "../src/static.ts"; import { FastURL } from "../src/_url.ts"; import type { ServerRequest } from "../src/types.ts"; @@ -11,6 +12,19 @@ let tmp: string; let dir: string; let linkedDir: string; +// Over the 1 KiB floor under which nothing is compressed on the fly, and +// compressible enough that an encoded body is unmistakably smaller. +const BIG_JS = `/* ${"payload;".repeat(256)} */\n`; + +// Stands in for a precompressed `.gz` on disk (never real gzip — the middleware +// serves those bytes as-is). Padded over the floor as well, so that a test +// reaching it is decided by variant-over-on-the-fly precedence and not by the +// size check quietly refusing to compress a short variant. +const BIG_GZ_MARKER = `GZIP_BIG_FROM_DISK ${"z".repeat(2048)}`; + +// The ceiling past which a file is served as-is rather than compressed per request. +const COMPRESS_MAX_SIZE = 10 * 1024 * 1024; + beforeAll(async () => { tmp = await mkdtemp(join(tmpdir(), "srvx-static-")); @@ -56,6 +70,26 @@ beforeAll(async () => { // A variant with no identity file beside it. await writeFile(join(dir, "orphan.js.br"), "ORPHAN_BR"); + // On-the-fly compression fixtures. Everything above is deliberately under the + // 1 KiB floor, so only these are ever encoded per request. + // + // `big.js` has no variant beside it: encoding it here is the only way it is + // served compressed. `big-gz.js` has one, so it pins the precedence between + // the two paths. + await writeFile(join(dir, "big.js"), BIG_JS); + await writeFile(join(dir, "big-gz.js"), BIG_JS); + await writeFile(join(dir, "big-gz.js.gz"), BIG_GZ_MARKER); + + // The two size bounds, from either side. `truncate` extends `huge.js` with + // zeros sparsely, so a 10 MiB size costs neither the write nor the disk. + await writeFile(join(dir, "small.js"), "s".repeat(1023)); + await writeFile(join(dir, "huge.js"), "// huge\n"); + await truncate(join(dir, "huge.js"), COMPRESS_MAX_SIZE + 1); + + // Compressible-sized bodies behind a type and a route that must not encode. + await writeFile(join(dir, "big.png"), BIG_JS); + await writeFile(join(dir, "big.html"), `

${"x".repeat(2048)}

`); + // Extension-less files, reachable at their exact name. await writeFile(join(dir, "LICENSE"), "LICENSE_BODY"); await writeFile(join(dir, "apple-app-site-association"), "AASA_BODY"); @@ -471,10 +505,11 @@ describe("serveStatic", () => { expect(res.headers.get("content-length")).toBe(String("BROTLI_JS".length)); }); - test("serves the plain file with encodings: {}", async () => { + test("skips the lookup with encodings: {}", async () => { + // `/app.js` is under the size floor for on-the-fly compression, so with no + // variant lookup there is nothing left to serve but the plain bytes. const res = await fetchEncoded("/app.js", "br", { encodings: {} }); expect(res.headers.get("content-encoding")).toBe(null); - expect(res.headers.get("vary")).toBe(null); await expect(res.text()).resolves.toBe("PLAIN_JS"); }); @@ -504,6 +539,118 @@ describe("serveStatic", () => { ); }); + describe("on-the-fly compression", () => { + const decode: Record Buffer> = { + br: brotliDecompressSync, + gzip: gunzipSync, + }; + + test.each(["br", "gzip"])("encodes with %s when no variant exists", async (enc) => { + const res = await fetchEncoded("/big.js", enc); + expect(res.headers.get("content-encoding")).toBe(enc); + // Decoding the body is what proves it was really encoded, rather than the + // plain bytes sent under an encoding header. + const body = Buffer.from(await res.arrayBuffer()); + expect(decode[enc]!(body).toString()).toBe(BIG_JS); + expect(body.length).toBeLessThan(BIG_JS.length); + }); + + test("prefers brotli over gzip", async () => { + const res = await fetchEncoded("/big.js", "gzip, br"); + expect(res.headers.get("content-encoding")).toBe("br"); + }); + + test("honors q=0 as a refusal", async () => { + const res = await fetchEncoded("/big.js", "br;q=0, gzip"); + expect(res.headers.get("content-encoding")).toBe("gzip"); + }); + + test("omits Content-Length, unknown until the bytes are encoded", async () => { + const res = await fetchEncoded("/big.js", "br"); + expect(res.headers.get("content-length")).toBe(null); + }); + + test("sets Vary: Accept-Encoding", async () => { + expect((await fetchEncoded("/big.js", "br")).headers.get("vary")).toBe("Accept-Encoding"); + }); + + test("prefers a variant on disk over encoding on the fly", async () => { + // `/big-gz.js` is over the floor and has a `.gz` beside it. Brotli is + // accepted and ranks first, but a variant costs no CPU, so the `.gz` wins + // — and its marker contents are what prove it was not encoded here. Both + // the variant and the file under it are over the floor, so nothing but + // precedence decides this. + const res = await fetchEncoded("/big-gz.js", "br, gzip"); + expect(res.headers.get("content-encoding")).toBe("gzip"); + await expect(res.text()).resolves.toBe(BIG_GZ_MARKER); + }); + + test.each([ + ["under the size floor", "/small.js"], + ["over the size ceiling", "/huge.js"], + ])("serves a file %s as-is", async (_why, path) => { + const res = await fetchEncoded(path!, "br"); + expect(res.status).toBe(200); + expect(res.headers.get("content-encoding")).toBe(null); + // Unencoded, so the length is known and must still be declared. + expect(res.headers.get("content-length")).not.toBe(null); + }); + + test("never encodes an already-compressed type", async () => { + const res = await fetchEncoded("/big.png", "br"); + expect(res.headers.get("content-encoding")).toBe(null); + expect(res.headers.get("vary")).toBe(null); + await expect(res.text()).resolves.toBe(BIG_JS); + }); + + test("does not encode a renderHTML route", async () => { + // The rendered `Response` belongs to the caller; encoding it would mean + // rewriting a body this middleware does not own. + const res = await fetchStatic( + "/big.html", + { renderHTML: ({ html }: { html: string }) => new Response(html) }, + { headers: { "accept-encoding": "br" } }, + ); + expect(res.headers.get("content-encoding")).toBe(null); + }); + + test("serves the plain file with compress: false", async () => { + const res = await fetchEncoded("/big.js", "br", { compress: false }); + expect(res.headers.get("content-encoding")).toBe(null); + await expect(res.text()).resolves.toBe(BIG_JS); + }); + + test("encodes without probing the disk with encodings: {}", async () => { + // `/big-gz.js` has a `.gz` the lookup would have served. With no encodings + // configured there is no lookup, so the response is encoded here instead — + // which the disk variant's marker contents would give away. + const res = await fetchEncoded("/big-gz.js", "gzip", { encodings: {} }); + expect(res.headers.get("content-encoding")).toBe("gzip"); + expect(gunzipSync(Buffer.from(await res.arrayBuffer())).toString()).toBe(BIG_JS); + }); + + test("serves nothing encoded with encodings: {} and compress: false", async () => { + const res = await fetchEncoded("/big.js", "br", { encodings: {}, compress: false }); + expect(res.headers.get("content-encoding")).toBe(null); + expect(res.headers.get("vary")).toBe(null); + await expect(res.text()).resolves.toBe(BIG_JS); + }); + + test("reports HEAD headers without encoding a body", async () => { + const res = await fetchStatic( + "/big.js", + {}, + { method: "HEAD", headers: { "accept-encoding": "br" } }, + ); + // Exactly what GET would send: encoded, and chunked rather than declaring + // a length... + expect(res.headers.get("content-encoding")).toBe("br"); + expect(res.headers.get("content-length")).toBe(null); + // ...except that the bytes are never produced. + await expect(res.text()).resolves.toBe(""); + }); + }); + describe("extension-less paths", () => { test.each([ ["/LICENSE", "LICENSE_BODY"], From 241d75442f4a24e83e2ca9b654c5d191a3e08413 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 15:27:06 +0000 Subject: [PATCH 11/11] feat(static): make the precompressed-variant lookup opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `encodings` now defaults off (`false`) rather than `{ br, gzip }`. Most deployments ship no precompressed files, so probing for one is a stat that always misses — on every compressible request, and `serveStatic` falls through to the app on every unmatched route, so the whole non-static surface paid it. On-the-fly compression (added in the previous commit, on by default) already covers the compressible case, so the disk lookup is pure overhead until a build actually produces variants. `encodings: true` uses the former default map; a `Record` still customizes per-encoding extensions. A variant, when configured, still takes precedence over on-the-fly — it costs no CPU. The two switches stay independent: `encodings: true` + `compress: false` is "disk variants only", and the default (`encodings` off, `compress` on) is "always encode on the fly". Co-Authored-By: Claude Opus 4.8 --- docs/1.guide/4.middleware.md | 6 ++--- src/static.ts | 28 ++++++++++---------- test/static.test.ts | 50 ++++++++++++++++++++++++------------ 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index 80f504f3..96fb795f 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -95,13 +95,13 @@ When no file matches the request, it calls `next()` — so your handler acts as - `dir`: The directory to serve files from (required). - `methods`: HTTP methods to serve (default `["GET", "HEAD"]`). Other methods fall through to `next()`. - `dotfiles`: Dot segments (path segments starting with `.`) that may be served (default `[".well-known"]`). A path containing any other dot segment — `/.env`, `/.git/config` — falls through to `next()`. Pass `true` to serve every dot segment, or `false` (or `[]`) to serve none, including `/.well-known/`. -- `encodings`: Map of `Content-Encoding` to the file extension of its precompressed variant (default `{ br: ".br", gzip: ".gz" }`). Keys are tried in order, so list the preferred encoding first. Pass `{}` to skip the lookup entirely; `compress` is unaffected. -- `compress`: Compress a response on the fly when no precompressed variant is found (default `true`). Pass `false` to serve only what is already on disk. +- `encodings`: Serve precompressed variants from disk (default `false`). Pass `true` for `{ br: ".br", gzip: ".gz" }`, or a map setting the extension per encoding (keys tried in order, preferred first). Off by default because most deployments ship no precompressed files, so the lookup is a `stat` that always misses. +- `compress`: Compress a response on the fly when no precompressed variant is served (default `true`). Pass `false` to serve only what is already on disk. - `renderHTML`: A function receiving `{ request, html, filename }` for every HTML file (`.html`, `.htm`), returning the `Response` to send. Use it to inject or template markup before serving. A request resolves in order: the path itself, then `.html`, then `/index.html`. So `/about` serves `about.html`, while an extension-less file (`LICENSE`, an ACME challenge token) is served at its exact name. A trailing-slash request names a directory, so `/sub/` resolves only `sub/index.html` — never `sub.html` or a file named `sub`. -For `/app.js` with `Accept-Encoding: br`, `app.js.br` is served if it exists (with `Content-Encoding: br`); with no variant on disk, `app.js` is compressed as it is sent. A variant always wins, because it costs no CPU — so precompressing at build time remains the cheapest way to serve compressed assets, and a build can afford a better ratio than a per-request encode can justify. Pass `compress: false` to serve only what is on disk, or `encodings: {}` to skip the lookup and always compress on the fly. +By default a compressible response is compressed on the fly as it is sent. Enabling `encodings` adds a disk lookup that takes precedence: for `/app.js` with `Accept-Encoding: br`, `app.js.br` is served if it exists (with `Content-Encoding: br`), and only a missing variant falls back to on-the-fly. A variant always wins because it costs no CPU, and a build can afford a better ratio than a per-request encode can justify — so `encodings: true` plus a build step is the cheapest way to serve maximum-quality compressed assets. The two switches are independent: `compress: false` serves only what is on disk, and `encodings` off with `compress` on always compresses on the fly. Brotli compresses at quality 4 rather than the `node:zlib` default of 11, which costs roughly 12x the CPU for a few percent of size. Only files between 1 KiB and 10 MiB are compressed on the fly: below that the encoded body can come out larger than the input, and above it the CPU spent per request outweighs the bandwidth saved — precompress large assets instead. On-the-fly responses are chunked, since the encoded length is not known until the bytes exist, while a variant declares the `Content-Length` it has on disk. diff --git a/src/static.ts b/src/static.ts index 562c4490..e06aed21 100644 --- a/src/static.ts +++ b/src/static.ts @@ -33,22 +33,24 @@ export interface ServeStaticOptions { dotfiles?: boolean | string[]; /** - * Map of `Content-Encoding` to the file extension of its precompressed variant on disk. + * Serve precompressed variants from disk. Off by default: most deployments ship none, + * so probing for one is a `stat` that always misses, on every compressible request. * - * For `/app.js` with `Accept-Encoding: br`, `app.js.br` is served if it exists. Keys are - * tried in order, so list the preferred encoding first. Pass `{}` to skip the lookup - * entirely — `compress` is unaffected, so on-the-fly encoding still applies. + * `true` uses `{ br: ".br", gzip: ".gz" }`; a map sets the extension per encoding (keys + * tried in order, so list the preferred encoding first). For `/app.js` with + * `Accept-Encoding: br`, `app.js.br` is served if it exists. A variant always wins over + * on-the-fly `compress`, as it costs no CPU. `false` (the default) skips the lookup. * - * @default { br: ".br", gzip: ".gz" } + * @default false */ - encodings?: Record; + encodings?: boolean | Record; /** - * Compress a response on the fly when no precompressed variant is found. + * Compress a response on the fly when no precompressed variant is served. * * Applies to compressible types only, and only to files between 1 KiB and 10 MiB — - * precompress anything larger. A variant from `encodings` always wins, as it costs no - * CPU. Pass `false` to serve only what is already on disk. + * precompress anything larger. Pass `false` to serve only what is already on disk (with + * `encodings` off too, nothing is ever compressed). * * @default true */ @@ -172,12 +174,12 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { const isDeniedDotPath = (relPath: string): boolean => !allowAllDots && relPath.split(sep).some((s) => s[0] === "." && !allowedDots.has(s)); - const encodings = options.encodings || DEFAULT_ENCODINGS; + const encodings = options.encodings === true ? DEFAULT_ENCODINGS : options.encodings || {}; const compress = options.compress ?? true; - // Encodings served, in server-preference order. `encodings` leads: its order is - // the documented preference, and a variant on disk costs no CPU. An encoding - // reachable only by compressing follows, so `encodings: {}` with `compress` is + // Encodings served, in server-preference order. Disk variants lead: their order + // is the documented preference, and a variant costs no CPU. An encoding reachable + // only by compressing follows, so the default (no `encodings`, `compress` on) is // "never probe the disk, always encode on the fly" rather than a dead option. const served: EncodingSpec[] = [ ...Object.entries(encodings).map(([name, ext]) => ({ diff --git a/test/static.test.ts b/test/static.test.ts index 40c131b5..9427f0c8 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -180,6 +180,14 @@ const fetchEncoded = ( opts: Partial = {}, ) => fetchStatic(path, opts, { headers: { "accept-encoding": acceptEncoding } }); +// The precompressed-variant lookup is opt-in (off by default), so any test that +// exercises it enables it. On-the-fly compression keeps its own default. +const fetchVariant = ( + path: string, + acceptEncoding: string, + opts: Partial = {}, +) => fetchEncoded(path, acceptEncoding, { encodings: true, ...opts }); + // Every denial falls through to the app rather than being answered here, so the // sentinel body from `notFound()` is what proves the middleware declined. It is // strictly stronger than asserting the secret is absent. @@ -359,7 +367,7 @@ describe("serveStatic", () => { execFileSync("mkfifo", [fifo]); try { // The identity file still serves; only the variant is unusable. - const res = await fetchEncoded("/piped.js", "br"); + const res = await fetchVariant("/piped.js", "br"); expect(res.status).toBe(200); expect(res.headers.get("content-encoding")).toBe(null); await expect(res.text()).resolves.toBe("PIPED_JS"); @@ -430,10 +438,11 @@ describe("serveStatic", () => { }); }); - describe("precompressed lookup", () => { + describe("precompressed lookup (opt-in via encodings)", () => { // (file, Accept-Encoding) -> which bytes are served. `/app.js` has both a // `.br` and a `.gz` beside it; `/only-gz.js` only a `.gz`; `/index.html` - // neither. + // neither. All fixtures are under the 1 KiB floor, so on-the-fly compression + // never fires here and the served bytes reflect the variant lookup alone. const VARIANT_CASES: { why: string; accept: string; @@ -489,25 +498,34 @@ describe("serveStatic", () => { ]; test.each(VARIANT_CASES)("$why", async ({ accept, enc, body, path = "/app.js" }) => { - const res = await fetchEncoded(path, accept); + const res = await fetchVariant(path, accept); expect(res.headers.get("content-encoding")).toBe(enc); await expect(res.text()).resolves.toBe(body); }); - test("sets Vary: Accept-Encoding whenever variants are configured", async () => { - expect((await fetchEncoded("/app.js", "br")).headers.get("vary")).toBe("Accept-Encoding"); + test("is off by default, so a variant on disk is not served", async () => { + // `/app.js` (8 bytes) has a `.br` beside it, but without `encodings` the + // lookup never runs and the file is under the on-the-fly floor, so the + // plain bytes are what come back. + const res = await fetchEncoded("/app.js", "br"); + expect(res.headers.get("content-encoding")).toBe(null); + await expect(res.text()).resolves.toBe("PLAIN_JS"); + }); + + test("sets Vary: Accept-Encoding on a variant response and its identity", async () => { + expect((await fetchVariant("/app.js", "br")).headers.get("vary")).toBe("Accept-Encoding"); // Also on the uncompressed response: caches must key on the header. - expect((await fetchEncoded("/index.html", "")).headers.get("vary")).toBe("Accept-Encoding"); + expect((await fetchVariant("/index.html", "")).headers.get("vary")).toBe("Accept-Encoding"); }); test("sets Content-Length to the served variant's size", async () => { - const res = await fetchEncoded("/app.js", "br"); + const res = await fetchVariant("/app.js", "br"); expect(res.headers.get("content-length")).toBe(String("BROTLI_JS".length)); }); test("skips the lookup with encodings: {}", async () => { - // `/app.js` is under the size floor for on-the-fly compression, so with no - // variant lookup there is nothing left to serve but the plain bytes. + // `{}` is as explicit an off as the default. `/app.js` is under the floor, + // so with no lookup there is nothing left to serve but the plain bytes. const res = await fetchEncoded("/app.js", "br", { encodings: {} }); expect(res.headers.get("content-encoding")).toBe(null); await expect(res.text()).resolves.toBe("PLAIN_JS"); @@ -517,7 +535,7 @@ describe("serveStatic", () => { // The identity file gates the lookup, so an orphan `.br` is not a route. // Nothing is lost: a client accepting no encoding could not be served it // anyway, so shipping one without its source is already a broken deploy. - await expectNext(await fetchEncoded("/orphan.js", "br")); + await expectNext(await fetchVariant("/orphan.js", "br")); }); test.each([ @@ -529,7 +547,7 @@ describe("serveStatic", () => { ])( "falls back to the plain file when %s's variant is not servable", async (path, body, secret) => { - const res = await fetchEncoded(path!, "br"); + const res = await fetchVariant(path!, "br"); expect(res.status).toBe(200); expect(res.headers.get("content-encoding")).toBe(null); const text = await res.text(); @@ -579,8 +597,8 @@ describe("serveStatic", () => { // accepted and ranks first, but a variant costs no CPU, so the `.gz` wins // — and its marker contents are what prove it was not encoded here. Both // the variant and the file under it are over the floor, so nothing but - // precedence decides this. - const res = await fetchEncoded("/big-gz.js", "br, gzip"); + // precedence decides this. The lookup is opt-in, hence `encodings: true`. + const res = await fetchVariant("/big-gz.js", "br, gzip"); expect(res.headers.get("content-encoding")).toBe("gzip"); await expect(res.text()).resolves.toBe(BIG_GZ_MARKER); }); @@ -721,7 +739,7 @@ describe("serveStatic", () => { test("declares a charset alongside Content-Encoding", async () => { // The charset describes the decoded bytes, so a variant keeps it. - const res = await fetchEncoded("/app.js", "br"); + const res = await fetchVariant("/app.js", "br"); expect(res.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); expect(res.headers.get("content-encoding")).toBe("br"); }); @@ -739,7 +757,7 @@ describe("serveStatic", () => { test("reports the variant's headers without a body", async () => { const res = await fetchStatic( "/app.js", - {}, + { encodings: true }, { method: "HEAD", headers: { "accept-encoding": "br" } }, ); expect(res.headers.get("content-encoding")).toBe("br");