From 335fde0c7f3d5b2791d4fac88a40621b0337d445 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 14 Jul 2026 11:38:13 +0000 Subject: [PATCH 1/2] fix(static): harden srvx/static (decoding, dotfiles, symlinks, caching) - Percent-decode the request pathname so filenames with spaces/unicode are reachable; malformed sequences fall through instead of crashing. Traversal checks run against the decoded path (raw and encoded `%2e%2e` blocked). - Deny any path segment starting with a dot (dotfiles like `.env`, `.env.local`, `.npmrc.bak`, `.git/...` and dot-segment traversal). - Resolve symlinks with `fs.realpath` and reject files that escape `dir`. - Compression correctness: only compress compressible MIME types (never re-encode png/jpg/woff/etc.); parse `Accept-Encoding` with exact tokens + q-values (`br;q=0` disabled, `abbr` no longer matches `br`); set `Vary: Accept-Encoding` on both compressed and identity variants. - Add `ETag` (size+mtime) and `Last-Modified`; handle `If-None-Match` / `If-Modified-Since` -> `304`; add a configurable conservative `Cache-Control`. - HEAD sends the same headers as GET with no body/compression work. - Add test/static.test.ts covering all of the above. Range/206 support is intentionally deferred. Co-Authored-By: Claude Fable 5 --- src/static.ts | 242 +++++++++++++++++++++++++++++++---- test/static.test.ts | 305 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 519 insertions(+), 28 deletions(-) create mode 100644 test/static.test.ts diff --git a/src/static.ts b/src/static.ts index 40288000..e808297f 100644 --- a/src/static.ts +++ b/src/static.ts @@ -2,7 +2,7 @@ import type { ServerMiddleware } from "./types.ts"; import type { Transform } from "node:stream"; import { extname, join, resolve, sep } from "node:path"; -import { readFile, stat } from "node:fs/promises"; +import { readFile, stat, realpath } from "node:fs/promises"; import { createReadStream, ReadStream } from "node:fs"; import { FastResponse } from "srvx"; import { createGzip, createBrotliCompress } from "node:zlib"; @@ -19,6 +19,15 @@ export interface ServeStaticOptions { */ methods?: string[]; + /** + * Value for the `Cache-Control` response header. + * + * Defaults to a conservative `"public, max-age=0, must-revalidate"` which + * lets clients cache but forces revalidation (via `ETag`/`Last-Modified`) + * on every request. Set to `false` to omit the header entirely. + */ + cacheControl?: string | false; + /** * A function to modify the HTML content before serving it. */ @@ -39,6 +48,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,24 +56,137 @@ 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", + ".br": "application/x-brotli", ".pdf": "application/pdf", }; +/** + * Whether a MIME type benefits from compression. Already-compressed binary + * formats (images, video, audio, archives, fonts) are excluded so we never + * waste CPU re-encoding them. + */ +function isCompressible(mimeType: string): boolean { + const type = mimeType.split(";", 1)[0].trim(); + return ( + type.startsWith("text/") || + type === "application/json" || + type === "application/xml" || + type === "application/javascript" || + type === "application/wasm" || + type === "image/svg+xml" || + type.endsWith("+json") || + type.endsWith("+xml") + ); +} + +/** + * Parse an `Accept-Encoding` header into a `token -> q-value` map, honoring + * q-values (so `br;q=0` disables brotli) and only matching exact tokens (so a + * value like `abbr` never matches `br`). + */ +function parseAcceptEncoding(header: string): Map { + const map = new Map(); + for (const part of header.split(",")) { + const [token, ...params] = part.trim().split(";"); + const name = token.trim().toLowerCase(); + if (!name) { + continue; + } + let q = 1; + for (const param of params) { + const match = /^q=(\d+(?:\.\d+)?)$/.exec(param.trim()); + if (match) { + q = Number.parseFloat(match[1]); + } + } + map.set(name, q); + } + return map; +} + +/** + * Negotiate a content encoding from an `Accept-Encoding` header, preferring + * brotli, then gzip, and falling back to identity (`undefined`) when neither is + * acceptable (q=0 / absent). + */ +function negotiateEncoding(header: string): "br" | "gzip" | undefined { + if (!header) { + return undefined; + } + const map = parseAcceptEncoding(header); + const star = map.get("*"); + const qOf = (name: string): number => { + const direct = map.get(name); + if (direct !== undefined) { + return direct; + } + return star ?? 0; + }; + const brQ = qOf("br"); + const gzipQ = qOf("gzip"); + if (brQ > 0 && brQ >= gzipQ) { + return "br"; + } + if (gzipQ > 0) { + return "gzip"; + } + return undefined; +} + +/** Weak `ETag` comparison (RFC 9110): ignore any leading `W/`. */ +function etagMatches(ifNoneMatch: string, etag: string): boolean { + if (ifNoneMatch.trim() === "*") { + return true; + } + const normalize = (tag: string) => tag.trim().replace(/^W\//, ""); + const target = normalize(etag); + return ifNoneMatch.split(",").some((tag) => normalize(tag) === target); +} + export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { const dir = resolve(options.dir) + sep; const methods = new Set((options.methods || ["GET", "HEAD"]).map((m) => m.toUpperCase())); + const cacheControl = + options.cacheControl === undefined + ? "public, max-age=0, must-revalidate" + : options.cacheControl; + + // Real (symlink-resolved) base directory, resolved lazily and cached. Used to + // reject files that escape `dir` through a symlink. + let realDir: string | undefined; return async (req, next) => { if (!methods.has(req.method)) { return next(); } + const isHead = req.method === "HEAD"; const url = (req._url ??= new FastURL(req.url)); - const path = url.pathname.slice(1).replace(/\/$/, ""); + + // Percent-decode the pathname so on-disk names with spaces/unicode are + // reachable. Malformed sequences must not crash; fall through to `next()`. + let path: string; + try { + path = decodeURIComponent(url.pathname.slice(1).replace(/\/$/, "")); + } catch { + return next(); + } + + // Deny any path segment starting with a dot. This is a deliberate denylist + // that blocks dotfiles (`.env`, `.env.local`, `.npmrc.bak`, `.git/...`) and + // dot-segment traversal (`.` / `..`, including once-encoded `%2e` forms + // which are now decoded), so secrets and parent dirs are never served. + if (path.split("/").some((segment) => segment.startsWith("."))) { + return next(); + } + let paths: string[]; if (path === "") { paths = ["index.html"]; @@ -72,40 +195,103 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { } else { paths = [path]; } - for (const path of paths) { - const filePath = join(dir, path); + + for (const candidate of paths) { + const filePath = join(dir, candidate); + // Defense-in-depth: `join` normalization must not escape `dir`. if (!filePath.startsWith(dir)) { continue; } const fileStat = await stat(filePath).catch(() => null); - if (fileStat?.isFile()) { - const fileExt = extname(filePath); - const headers: HeadersInit = { - "Content-Length": fileStat.size.toString(), - "Content-Type": COMMON_MIME_TYPES[fileExt] || "application/octet-stream", - }; - if (options.renderHTML && fileExt === ".html") { - return options.renderHTML({ - html: await readFile(filePath, "utf8"), - filename: filePath, - request: req, - }); + if (!fileStat?.isFile()) { + continue; + } + + // Symlink escape: resolve the real path and ensure it stays inside the + // real base directory before serving. + try { + if (realDir === undefined) { + realDir = (await realpath(resolve(options.dir))) + sep; } - let stream: ReadStream | Transform = createReadStream(filePath); - const acceptEncoding = req.headers.get("accept-encoding") || ""; - if (acceptEncoding.includes("br")) { - headers["Content-Encoding"] = "br"; - delete headers["Content-Length"]; - headers["Vary"] = "Accept-Encoding"; - stream = stream.pipe(createBrotliCompress()); - } else if (acceptEncoding.includes("gzip")) { - headers["Content-Encoding"] = "gzip"; + const realFile = await realpath(filePath); + if (realFile !== realDir.slice(0, -1) && !realFile.startsWith(realDir)) { + continue; + } + } catch { + continue; + } + + const fileExt = extname(filePath); + const contentType = COMMON_MIME_TYPES[fileExt] || "application/octet-stream"; + + if (options.renderHTML && fileExt === ".html") { + return options.renderHTML({ + html: await readFile(filePath, "utf8"), + filename: filePath, + request: req, + }); + } + + // Validators for conditional requests and caching. + const mtime = fileStat.mtime; + const etag = `W/"${fileStat.size.toString(16)}-${mtime.getTime().toString(16)}"`; + const lastModified = mtime.toUTCString(); + + const headers: Record = { + "Content-Type": contentType, + "Content-Length": fileStat.size.toString(), + ETag: etag, + "Last-Modified": lastModified, + }; + if (cacheControl) { + headers["Cache-Control"] = cacheControl; + } + + // Compression negotiation (only for compressible types). `Vary` is set on + // both the compressed and the identity variant so caches key correctly. + let encoding: "br" | "gzip" | undefined; + if (isCompressible(contentType)) { + headers["Vary"] = "Accept-Encoding"; + encoding = negotiateEncoding(req.headers.get("accept-encoding") || ""); + if (encoding) { + headers["Content-Encoding"] = encoding; + // Compressed length is unknown ahead of time. delete headers["Content-Length"]; - headers["Vary"] = "Accept-Encoding"; - stream = stream.pipe(createGzip()); } - return new FastResponse(stream as any, { headers }); } + + // Conditional requests: `If-None-Match` takes precedence over + // `If-Modified-Since` (RFC 9110). Respond `304` with no body. + const ifNoneMatch = req.headers.get("if-none-match"); + const ifModifiedSince = req.headers.get("if-modified-since"); + let notModified = false; + if (ifNoneMatch) { + notModified = etagMatches(ifNoneMatch, etag); + } else if (ifModifiedSince) { + const since = Date.parse(ifModifiedSince); + // Compare at second resolution (HTTP dates have no sub-second part). + if (!Number.isNaN(since) && Math.floor(mtime.getTime() / 1000) * 1000 <= since) { + notModified = true; + } + } + if (notModified) { + delete headers["Content-Length"]; + delete headers["Content-Encoding"]; + return new FastResponse(null, { status: 304, headers }); + } + + // HEAD: send the same headers a GET would, with no body work. + if (isHead) { + return new FastResponse(null, { headers }); + } + + let stream: ReadStream | Transform = createReadStream(filePath); + if (encoding === "br") { + stream = stream.pipe(createBrotliCompress()); + } else if (encoding === "gzip") { + stream = stream.pipe(createGzip()); + } + return new FastResponse(stream as any, { headers }); } return next(); }; diff --git a/test/static.test.ts b/test/static.test.ts new file mode 100644 index 00000000..c4551444 --- /dev/null +++ b/test/static.test.ts @@ -0,0 +1,305 @@ +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gunzipSync, brotliDecompressSync } from "node:zlib"; +import { serveStatic } from "../src/static.ts"; +import type { ServeStaticOptions } from "../src/static.ts"; +import type { ServerRequest } from "../src/types.ts"; + +let root: string; +let dir: string; + +// A large-enough, highly compressible text payload. +const TEXT_BODY = "hello compressible world\n".repeat(200); + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "srvx-static-")); + dir = join(root, "public"); + mkdirSync(dir, { recursive: true }); + + writeFileSync(join(dir, "index.html"), "

home

"); + writeFileSync(join(dir, "about.html"), "

about

"); + writeFileSync(join(dir, "hello world.txt"), "spaces"); + writeFileSync(join(dir, "café.txt"), "unicode"); + writeFileSync(join(dir, "data.json"), '{"a":1}'); + writeFileSync(join(dir, "page.txt"), TEXT_BODY); + // A PNG-ish binary file (content irrelevant, extension drives MIME type). + writeFileSync(join(dir, "pic.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 1, 2, 3])); + writeFileSync(join(dir, "icon.svg"), ""); + writeFileSync(join(dir, ".env"), "SECRET=1"); + + mkdirSync(join(dir, "sub"), { recursive: true }); + writeFileSync(join(dir, "sub", "index.html"), "

sub

"); + + mkdirSync(join(dir, ".secret"), { recursive: true }); + writeFileSync(join(dir, ".secret", "config"), "topsecret"); + + // A file outside `dir` that a symlink inside `dir` points at. + const outside = join(root, "outside"); + mkdirSync(outside, { recursive: true }); + writeFileSync(join(outside, "secret.txt"), "escaped"); + symlinkSync(join(outside, "secret.txt"), join(dir, "link.txt")); +}); + +afterAll(() => { + rmSync(root, { recursive: true, force: true }); +}); + +const NEXT = "__NEXT__"; + +async function req( + path: string, + init: { method?: string; headers?: Record } = {}, + options: Partial = {}, +): Promise { + const mw = serveStatic({ dir, ...options }); + const request = new Request(`http://localhost${path}`, { + method: init.method || "GET", + headers: init.headers, + }) as unknown as ServerRequest; + return mw(request, () => new Response(NEXT, { status: 404 })); +} + +/** Fully drain the body so no file stream is left dangling past cleanup. */ +async function head(res: Response): Promise { + await res.arrayBuffer().catch(() => {}); + return res; +} + +describe("serveStatic: routing & resolution", () => { + test("serves index.html at root", async () => { + const res = await req("/"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("

home

"); + expect(res.headers.get("content-type")).toBe("text/html"); + }); + + test("resolves extensionless path to .html", async () => { + const res = await req("/about"); + expect(await res.text()).toBe("

about

"); + }); + + test("resolves directory to index.html", async () => { + const res = await req("/sub"); + expect(await res.text()).toBe("

sub

"); + }); + + test("404 fallthrough for missing file", async () => { + const res = await req("/missing.txt"); + expect(res.status).toBe(404); + expect(await res.text()).toBe(NEXT); + }); + + test("ignores non GET/HEAD methods", async () => { + const res = await req("/", { method: "POST" }); + expect(await res.text()).toBe(NEXT); + }); +}); + +describe("serveStatic: percent-decoding", () => { + test("serves a filename with a space", async () => { + const res = await req("/hello%20world.txt"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("spaces"); + }); + + test("serves a unicode filename", async () => { + const res = await req("/caf%C3%A9.txt"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("unicode"); + }); + + test("malformed percent-encoding falls through (no crash)", async () => { + const res = await req("/%E0%A4%A.txt"); + expect(res.status).toBe(404); + expect(await res.text()).toBe(NEXT); + }); +}); + +describe("serveStatic: traversal & dotfiles", () => { + test("raw ../ cannot escape dir", async () => { + const res = await req("/../../outside/secret.txt"); + expect(await res.text()).toBe(NEXT); + }); + + test("encoded %2e%2e traversal is blocked", async () => { + const res = await req("/%2e%2e%2foutside%2fsecret.txt"); + expect(res.status).toBe(404); + expect(await res.text()).toBe(NEXT); + }); + + test("encoded ..%2f traversal is blocked", async () => { + const res = await req("/..%2f..%2foutside%2fsecret.txt"); + expect(await res.text()).toBe(NEXT); + }); + + test("dotfile is denied", async () => { + const res = await req("/.env"); + expect(res.status).toBe(404); + expect(await res.text()).toBe(NEXT); + }); + + test("file inside a dot directory is denied", async () => { + const res = await req("/.secret/config"); + expect(await res.text()).toBe(NEXT); + }); + + test("symlink escaping dir is denied", async () => { + const res = await req("/link.txt"); + expect(await res.text()).toBe(NEXT); + }); +}); + +describe("serveStatic: MIME types", () => { + test.each([ + ["/data.json", "application/json"], + ["/pic.png", "image/png"], + ["/icon.svg", "image/svg+xml"], + ["/page.txt", "text/plain"], + ["/about", "text/html"], + ])("%s -> %s", async (path, type) => { + const res = await head(await req(path)); + expect(res.headers.get("content-type")).toBe(type); + }); +}); + +describe("serveStatic: encoding negotiation", () => { + test("gzip", async () => { + const res = await req("/page.txt", { headers: { "accept-encoding": "gzip" } }); + expect(res.headers.get("content-encoding")).toBe("gzip"); + expect(res.headers.get("content-length")).toBeNull(); + const body = gunzipSync(Buffer.from(await res.arrayBuffer())).toString(); + expect(body).toBe(TEXT_BODY); + }); + + test("brotli", async () => { + const res = await req("/page.txt", { headers: { "accept-encoding": "br" } }); + expect(res.headers.get("content-encoding")).toBe("br"); + const body = brotliDecompressSync(Buffer.from(await res.arrayBuffer())).toString(); + expect(body).toBe(TEXT_BODY); + }); + + test("prefers brotli when both are acceptable", async () => { + const res = await head(await req("/page.txt", { headers: { "accept-encoding": "gzip, br" } })); + expect(res.headers.get("content-encoding")).toBe("br"); + }); + + test("br;q=0 disables brotli, falls back to gzip", async () => { + const res = await head( + await req("/page.txt", { headers: { "accept-encoding": "br;q=0, gzip" } }), + ); + expect(res.headers.get("content-encoding")).toBe("gzip"); + }); + + test("br;q=0 alone falls back to identity", async () => { + const res = await req("/page.txt", { headers: { "accept-encoding": "br;q=0" } }); + expect(res.headers.get("content-encoding")).toBeNull(); + expect(await res.text()).toBe(TEXT_BODY); + }); + + test("'abbr' does not match 'br'", async () => { + const res = await req("/page.txt", { headers: { "accept-encoding": "abbr" } }); + expect(res.headers.get("content-encoding")).toBeNull(); + expect(await res.text()).toBe(TEXT_BODY); + }); + + test("no accept-encoding -> identity", async () => { + const res = await req("/page.txt"); + expect(res.headers.get("content-encoding")).toBeNull(); + expect(await res.text()).toBe(TEXT_BODY); + }); + + test("already-compressed types are not re-encoded", async () => { + const res = await head(await req("/pic.png", { headers: { "accept-encoding": "gzip, br" } })); + expect(res.headers.get("content-encoding")).toBeNull(); + expect(res.headers.get("content-length")).not.toBeNull(); + }); +}); + +describe("serveStatic: Vary", () => { + test("Vary on the compressed variant", async () => { + const res = await head(await req("/page.txt", { headers: { "accept-encoding": "gzip" } })); + expect(res.headers.get("vary")).toBe("Accept-Encoding"); + expect(res.headers.get("content-encoding")).toBe("gzip"); + }); + + test("Vary on the identity variant of a compressible type", async () => { + const res = await head(await req("/page.txt")); + expect(res.headers.get("vary")).toBe("Accept-Encoding"); + expect(res.headers.get("content-encoding")).toBeNull(); + }); + + test("no Vary on already-compressed types", async () => { + const res = await head(await req("/pic.png")); + expect(res.headers.get("vary")).toBeNull(); + }); +}); + +describe("serveStatic: HEAD", () => { + test("HEAD sends headers but no body", async () => { + const res = await req("/page.txt", { method: "HEAD" }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/plain"); + expect(res.headers.get("content-length")).toBe(String(Buffer.byteLength(TEXT_BODY))); + expect(res.headers.get("etag")).toBeTruthy(); + expect(await res.text()).toBe(""); + }); + + test("HEAD with accept-encoding negotiates without a body", async () => { + const res = await req("/page.txt", { + method: "HEAD", + headers: { "accept-encoding": "gzip" }, + }); + expect(res.headers.get("content-encoding")).toBe("gzip"); + expect(res.headers.get("content-length")).toBeNull(); + expect(await res.text()).toBe(""); + }); +}); + +describe("serveStatic: caching & conditional requests", () => { + test("sets ETag, Last-Modified and Cache-Control", async () => { + const res = await head(await req("/page.txt")); + expect(res.headers.get("etag")).toBeTruthy(); + expect(res.headers.get("last-modified")).toBeTruthy(); + expect(res.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate"); + }); + + test("If-None-Match hit -> 304 with no body", async () => { + const first = await head(await req("/page.txt")); + const etag = first.headers.get("etag")!; + const res = await req("/page.txt", { headers: { "if-none-match": etag } }); + expect(res.status).toBe(304); + expect(res.headers.get("content-length")).toBeNull(); + expect(await res.text()).toBe(""); + }); + + test("If-None-Match miss -> 200", async () => { + const res = await head( + await req("/page.txt", { headers: { "if-none-match": 'W/"deadbeef-1"' } }), + ); + expect(res.status).toBe(200); + }); + + test("If-Modified-Since not modified -> 304", async () => { + const first = await head(await req("/page.txt")); + const lastModified = first.headers.get("last-modified")!; + const res = await req("/page.txt", { + headers: { "if-modified-since": lastModified }, + }); + expect(res.status).toBe(304); + expect(await res.text()).toBe(""); + }); + + test("If-Modified-Since in the past -> 200", async () => { + const res = await head( + await req("/page.txt", { headers: { "if-modified-since": new Date(0).toUTCString() } }), + ); + expect(res.status).toBe(200); + }); + + test("cacheControl option can be disabled", async () => { + const res = await head(await req("/page.txt", {}, { cacheControl: false })); + expect(res.headers.get("cache-control")).toBeNull(); + }); +}); From ab21d093b8c65b65d39a30ed318c40c6ea0079e8 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 16 Jul 2026 11:47:25 +0000 Subject: [PATCH 2/2] fix(static): allow /.well-known/ (RFC 8615) The dotfile denylist blocked every segment starting with a dot, which also blocked `/.well-known/` -- silently breaking ACME/Let's Encrypt renewal, `security.txt` and `assetlinks.json`, with no way to opt out (the CLI passes only `dir`). Exempt a *leading* `.well-known` segment from the denylist. Everything below it is still checked, so `/.well-known/.env` and traversal out of `/.well-known/` stay denied, and `.well-known` nested anywhere else is not well-known and stays denied too. Also skip the `.html`/`index.html` fallback under `/.well-known/`. Well-known URIs are exact identifiers, and ACME challenge tokens are extensionless -- `/.well-known/acme-challenge/` would otherwise resolve to `.html` and 404, so the exemption alone did not fix cert renewal. Tests: `/.env` alone could not catch a denylist regression, because `extname(".env")` is "" and the fallback looks for `.env.html` -- it 404s either way. Add `.env.local` / `.npmrc.bak` cases, which resolve to real files and so are only stopped by the denylist. Verified by mutation: removing the denylist, the exemption, or the fallback skip each fail tests that previously passed. Co-Authored-By: Claude Opus 4.8 --- docs/1.guide/10.cli.md | 4 ++- src/static.ts | 26 ++++++++++++++---- test/static.test.ts | 62 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/docs/1.guide/10.cli.md b/docs/1.guide/10.cli.md index 07004419..34bc249e 100644 --- a/docs/1.guide/10.cli.md +++ b/docs/1.guide/10.cli.md @@ -102,7 +102,9 @@ npx srvx --static ./dist If `--static` is omitted, srvx serves files from a `public/` directory when one exists. When both a server entry and a static directory are present, static files take priority and unmatched requests fall through to your handler. -Static serving includes automatic `index.html` resolution, `.html` extension fallback (e.g. `/about` → `about.html`), common MIME types, gzip/Brotli compression, and path-traversal protection. +Static serving includes automatic `index.html` resolution, `.html` extension fallback (e.g. `/about` → `about.html`), common MIME types, gzip/Brotli compression, and `ETag` / `Last-Modified` validators with `304` conditional-request handling. + +Requests are also hardened against path traversal and symlinks that escape the served directory. Dotfiles are never served (`.env`, `.env.local`, `.git/...`), with one exception: [`/.well-known/`](https://datatracker.ietf.org/doc/html/rfc8615) stays reachable so ACME/Let's Encrypt challenges, `security.txt`, and `assetlinks.json` work. Paths under `/.well-known/` are served verbatim, without the `.html` fallback. For programmatic usage, import the [`serveStatic`](https://github.com/h3js/srvx/blob/main/src/static.ts) middleware: diff --git a/src/static.ts b/src/static.ts index e808297f..b177ee32 100644 --- a/src/static.ts +++ b/src/static.ts @@ -183,17 +183,33 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // that blocks dotfiles (`.env`, `.env.local`, `.npmrc.bak`, `.git/...`) and // dot-segment traversal (`.` / `..`, including once-encoded `%2e` forms // which are now decoded), so secrets and parent dirs are never served. - if (path.split("/").some((segment) => segment.startsWith("."))) { - return next(); + // + // A leading `.well-known` (RFC 8615) is the single exemption: it is a + // registered, public-by-design namespace (ACME challenges, `security.txt`, + // `assetlinks.json`) that must stay reachable. Only the first segment is + // exempt, so everything below it is still denied (`/.well-known/.env`), and + // `.well-known` nested anywhere else (`/sub/.well-known/...`) is not + // well-known at all and stays denied too. + const segments = path.split("/"); + const isWellKnown = segments[0] === ".well-known"; + for (let i = isWellKnown ? 1 : 0; i < segments.length; i++) { + if (segments[i].startsWith(".")) { + return next(); + } } let paths: string[]; if (path === "") { paths = ["index.html"]; - } else if (extname(path) === "") { - paths = [`${path}.html`, `${path}/index.html`]; - } else { + } else if (isWellKnown || extname(path) !== "") { + // Well-known URIs are exact identifiers, so the `.html`/`index.html` + // fallback must not apply below `/.well-known/`: ACME challenge tokens + // (`/.well-known/acme-challenge/`) are extensionless and would + // otherwise resolve to `.html` and 404, silently breaking cert + // renewal. paths = [path]; + } else { + paths = [`${path}.html`, `${path}/index.html`]; } for (const candidate of paths) { diff --git a/test/static.test.ts b/test/static.test.ts index c4551444..6a1655db 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -28,6 +28,10 @@ beforeAll(() => { writeFileSync(join(dir, "pic.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 1, 2, 3])); writeFileSync(join(dir, "icon.svg"), ""); writeFileSync(join(dir, ".env"), "SECRET=1"); + // Dotfiles that carry an extension, so the extensionless `.html` fallback + // cannot 404 them by accident. These fail if the dot denylist regresses. + writeFileSync(join(dir, ".env.local"), "SECRET=2"); + writeFileSync(join(dir, ".npmrc.bak"), "//registry/:_authToken=tok"); mkdirSync(join(dir, "sub"), { recursive: true }); writeFileSync(join(dir, "sub", "index.html"), "

sub

"); @@ -35,6 +39,17 @@ beforeAll(() => { mkdirSync(join(dir, ".secret"), { recursive: true }); writeFileSync(join(dir, ".secret", "config"), "topsecret"); + // `/.well-known/` (RFC 8615) is exempt from the dot denylist. + mkdirSync(join(dir, ".well-known", "acme-challenge"), { recursive: true }); + // ACME tokens are extensionless and must be served verbatim, not via `.html`. + writeFileSync(join(dir, ".well-known", "acme-challenge", "token123"), "acme-proof"); + writeFileSync(join(dir, ".well-known", "security.txt"), "Contact: mailto:x@y.z"); + // A dotfile *below* `.well-known` stays denied. + writeFileSync(join(dir, ".well-known", ".env"), "SECRET=3"); + // `.well-known` is only well-known at the root; nested it stays denied. + mkdirSync(join(dir, "sub", ".well-known"), { recursive: true }); + writeFileSync(join(dir, "sub", ".well-known", "nope.txt"), "nested"); + // A file outside `dir` that a symlink inside `dir` points at. const outside = join(root, "outside"); mkdirSync(outside, { recursive: true }); @@ -140,6 +155,15 @@ describe("serveStatic: traversal & dotfiles", () => { expect(await res.text()).toBe(NEXT); }); + // `/.env` alone would 404 even without the denylist, because `extname(".env")` + // is "" and the fallback looks for `.env.html`. These carry an extension, so + // they resolve to a real file and only the denylist can stop them. + test.each(["/.env.local", "/.npmrc.bak"])("dotfile %s is denied", async (path) => { + const res = await req(path); + expect(res.status).toBe(404); + expect(await res.text()).toBe(NEXT); + }); + test("file inside a dot directory is denied", async () => { const res = await req("/.secret/config"); expect(await res.text()).toBe(NEXT); @@ -151,6 +175,44 @@ describe("serveStatic: traversal & dotfiles", () => { }); }); +describe("serveStatic: .well-known (RFC 8615)", () => { + test("serves an extensionless ACME challenge token verbatim", async () => { + const res = await req("/.well-known/acme-challenge/token123"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("acme-proof"); + }); + + test("serves security.txt", async () => { + const res = await req("/.well-known/security.txt"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("Contact: mailto:x@y.z"); + }); + + test("does not apply the .html fallback under .well-known", async () => { + const res = await req("/.well-known/acme-challenge/missing"); + expect(res.status).toBe(404); + expect(await res.text()).toBe(NEXT); + }); + + test("a dotfile below .well-known is still denied", async () => { + const res = await req("/.well-known/.env"); + expect(res.status).toBe(404); + expect(await res.text()).toBe(NEXT); + }); + + test("traversal out of .well-known is still denied", async () => { + const res = await req("/.well-known/..%2f.env.local"); + expect(res.status).toBe(404); + expect(await res.text()).toBe(NEXT); + }); + + test("nested .well-known is not exempt", async () => { + const res = await req("/sub/.well-known/nope.txt"); + expect(res.status).toBe(404); + expect(await res.text()).toBe(NEXT); + }); +}); + describe("serveStatic: MIME types", () => { test.each([ ["/data.json", "application/json"],