From 13ce6a2c1c09ab30541fce858551a5cceb4a44a8 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 22:50:08 +0000 Subject: [PATCH 1/9] feat(static): add opt-in directory listing via `dirListing` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve a minimal HTML directory listing when a request resolves to a directory with no index file. Off by default (it exposes the directory structure); the CLI enables it in dev mode only. The listing re-asserts the same boundaries a file request gets — lexical and symlink-resolved containment, plus the dot-path deny check on both the requested path and each entry — so it never names anything a direct request would refuse to serve. Entry hrefs are absolute, so links resolve identically whether the directory was requested with a trailing slash or without. Co-Authored-By: Claude Opus 4.8 --- src/cli/serve.ts | 3 ++ src/static.ts | 123 +++++++++++++++++++++++++++++++++++++++++++- test/static.test.ts | 69 +++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 1 deletion(-) diff --git a/src/cli/serve.ts b/src/cli/serve.ts index d28fb8e8..080030f6 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -92,6 +92,9 @@ export async function cliServe(cliOpts: CLIOptions): Promise { cliOpts.static ? serveStatic({ dir: cliOpts.static, + // Dev convenience: browse directories without an index. Off in prod + // so the directory structure is never exposed by default. + dirListing: !cliOpts.prod, }) : undefined, ...(serverOptions.middleware || []), diff --git a/src/static.ts b/src/static.ts index c3c4bc24..d535e403 100644 --- a/src/static.ts +++ b/src/static.ts @@ -5,7 +5,7 @@ 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 { open, readdir, realpath, stat } from "node:fs/promises"; import { pipeline } from "node:stream"; import { constants as zlibConstants, createBrotliCompress, createGzip } from "node:zlib"; import { FastResponse } from "srvx"; @@ -112,6 +112,22 @@ export interface ServeStaticOptions { */ ranges?: boolean; + /** + * Serve a minimal HTML directory listing when a request resolves to a + * directory that has no index file (`index.html`). A directory that does have + * one always serves the index instead. + * + * Entries are the directory's immediate children, with denied dot segments + * (see `dotfiles`) hidden just as they are for file requests. Only names are + * revealed, never file contents. + * + * Off by default — it exposes the directory structure, so it is opt-in. The + * `srvx` CLI turns it on in dev mode only. + * + * @default false + */ + dirListing?: boolean; + /** * A function to modify the HTML content before serving it. */ @@ -236,6 +252,7 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { const lastModified = options.lastModified ?? true; const etag = options.etag ?? true; const ranges = options.ranges ?? true; + const dirListing = options.dirListing ?? false; // Depends only on the options, so it is built once. Empty when `maxAge` is // unset — the header is fully opt-in. @@ -314,6 +331,44 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { return null; }; + // The immediate children of a directory safe to list, or null when the path is + // not a listable directory. Re-asserts the same boundaries a file request gets: + // lexical containment, then symlink-resolved containment (a link inside `dir` + // could point the directory out of the root), and the dot-path deny check on + // both the requested path and each entry — so a listing never names what a + // direct request would refuse to serve. + const readListing = async (relPath: string): Promise<{ name: string; dir: boolean }[] | null> => { + const dirPath = relPath === "" ? dir : join(dir, relPath); + if ( + relPath !== "" && + (!dirPath.startsWith(dir) || isDeniedDotPath(dirPath.slice(dir.length))) + ) { + return null; + } + const realPath = await realpath(dirPath).catch(() => null); + if (realPath === null) { + return null; + } + const root = await getRealDir(); + const realWithSep = asPrefix(realPath); + if (!realWithSep.startsWith(root) || isDeniedDotPath(realWithSep.slice(root.length))) { + return null; + } + // `withFileTypes` reads the type from the same `readdir` syscall, so the + // trailing-slash decoration below costs no extra `stat` per entry. A + // non-directory (or an unreadable one) throws and falls through to `null`. + const dirents = await readdir(realPath, { withFileTypes: true }).catch(() => null); + if (dirents === null) { + return null; + } + const entries = dirents + .filter((d) => !isDeniedDotPath(d.name)) + .map((d) => ({ name: d.name, dir: d.isDirectory() })); + // Directories first, then by name — a conventional, stable listing order. + entries.sort((a, b) => (a.dir === b.dir ? (a.name < b.name ? -1 : 1) : a.dir ? -1 : 1)); + return entries; + }; + return async (req, next) => { if (!methods.has(req.method)) { return next(); @@ -628,12 +683,78 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { pipeline(stream, encoded, () => {}); return new FastResponse(encoded as any, { headers }); } + // No file matched. With `dirListing` on, a request naming a directory (root, a + // trailing-slash path, or an extension-less one) that has no index is + // answered with a listing rather than falling through. An index always wins: + // its `index.html` candidate was probed in the loop above. + if (dirListing && (path === "" || trailingSlash || extname(path) === "")) { + const entries = await readListing(path); + if (entries) { + const base = url.pathname.endsWith("/") ? url.pathname : url.pathname + "/"; + const headers = { "Content-Type": "text/html; charset=utf-8" }; + // HEAD mirrors GET's headers without the body. + const body = req.method === "HEAD" ? null : renderDirListing(base, path, entries); + return new FastResponse(body, { headers }); + } + } return next(); }; }; // --- internal --- +const HTML_ESCAPES: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +// Escape text before interpolating it into the listing HTML — a filename is +// attacker-controllable and lands in both element text and an `href` attribute. +function escapeHtml(str: string): string { + return str.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]!); +} + +// A minimal directory listing. `base` is the request pathname with a guaranteed +// trailing slash, so every entry href is an absolute path that resolves the same +// whether the directory was requested with a trailing slash or without. +// `displayPath` is the decoded relative path, shown in the heading. +function renderDirListing( + base: string, + displayPath: string, + entries: { name: string; dir: boolean }[], +): string { + const heading = escapeHtml("/" + (displayPath ? displayPath + "/" : "")); + const items: string[] = []; + // A parent link everywhere but the root. `base` ends in `/`, so relative `../` + // climbs one segment. + if (displayPath !== "") { + items.push(`
  • ../
  • `); + } + for (const entry of entries) { + const suffix = entry.dir ? "/" : ""; + // `encodeURIComponent` before `escapeHtml`: the first makes the name a safe + // URL path segment (a literal `/` in a name is encoded, never a separator), + // the second makes that URL safe inside the attribute. The trailing `/` for + // a directory is appended after encoding so it stays a real separator. + const href = escapeHtml(base + encodeURIComponent(entry.name) + suffix); + const text = escapeHtml(entry.name + suffix); + items.push(`
  • ${text}
  • `); + } + return ( + `` + + `` + + `Index of ${heading}` + + `` + + `

    Index of ${heading}

      ${items.join("")}
    ` + ); +} + // The `Cache-Control` value, or "" when `maxAge` is unset so the header is // omitted entirely. `max-age` takes non-negative integer seconds, so a // fractional or negative `maxAge` is floored and clamped, non-finite values fall diff --git a/test/static.test.ts b/test/static.test.ts index 4f93dd7b..c4ab2423 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -144,6 +144,13 @@ beforeAll(async () => { await writeFile(join(dir, "alias-variant.js"), "PLAIN_ALIAS_VARIANT"); await symlink(join(dir, ".env"), join(dir, "alias-variant.js.br")); + // A directory with no index, for directory-listing tests. Holds a nested + // directory, plain files, and a dotfile that a listing must hide. + await mkdir(join(dir, "files", "nested"), { recursive: true }); + await writeFile(join(dir, "files", "a.txt"), "A"); + await writeFile(join(dir, "files", "b.txt"), "B"); + await writeFile(join(dir, "files", ".hidden"), "HIDDEN"); + // A root that is itself a symlink must keep working. linkedDir = join(tmp, "public-link"); await symlink(dir, linkedDir); @@ -287,6 +294,68 @@ describe("serveStatic", () => { ); }); + describe("directory listing (dirListing)", () => { + test("off by default: a directory without an index falls through", async () => { + await expectNext(await fetchStatic("/files/")); + }); + + test("lists a directory without an index when enabled", async () => { + const res = await fetchStatic("/files/", { dirListing: true }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8"); + const html = await res.text(); + expect(html).toContain("Index of /files/"); + expect(html).toContain('href="/files/a.txt"'); + expect(html).toContain('href="/files/b.txt"'); + // A nested directory is linked with a trailing slash. + expect(html).toContain('href="/files/nested/"'); + // A parent link climbs one segment. + expect(html).toContain('href="../"'); + }); + + test("hides denied dot segments from the listing", async () => { + const html = await (await fetchStatic("/files/", { dirListing: true })).text(); + expect(html).not.toContain(".hidden"); + }); + + test("an index always wins over a listing", async () => { + // `/sub/` has an index.html, so it serves that rather than a listing. + const res = await fetchStatic("/sub/", { dirListing: true }); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toContain("sub index"); + }); + + test("root serves its index rather than a listing", async () => { + // Root has an index.html, so it is served even with listing enabled. + const html = await (await fetchStatic("/", { dirListing: true })).text(); + expect(html).toContain("

    index

    "); + }); + + test("serves a listing for an extension-less directory route", async () => { + // No trailing slash: hrefs are still absolute, so relative links resolve + // the same as under `/files/`. + const res = await fetchStatic("/files", { dirListing: true }); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('href="/files/a.txt"'); + }); + + test("HEAD returns the listing headers without a body", async () => { + const res = await fetchStatic("/files/", { dirListing: true }, { method: "HEAD" }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8"); + await expect(res.text()).resolves.toBe(""); + }); + + test("does not list through a symlinked directory escaping the root", async () => { + await expectNext(await fetchStatic("/escape-dir/", { dirListing: true })); + }); + + test("a missing directory still falls through", async () => { + await expectNext(await fetchStatic("/nope/", { dirListing: true })); + }); + }); + describe("symlinks", () => { test("does not serve a symlink escaping the root", async () => { await expectNext(await fetchStatic("/escape.txt")); From 4eaf4308358cd43fbae6f6589840203a12c827b4 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 22:54:37 +0000 Subject: [PATCH 2/9] feat(static): follow OS dark theme in directory listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain `prefers-color-scheme: dark` palette swap on the listing page — no toggle, no stored preference. Co-Authored-By: Claude Opus 4.8 --- src/static.ts | 5 ++++- test/static.test.ts | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/static.ts b/src/static.ts index d535e403..b68284c7 100644 --- a/src/static.ts +++ b/src/static.ts @@ -750,7 +750,10 @@ function renderDirListing( `` + + `a:hover{text-decoration:underline}` + + // Follow the OS theme — a plain palette swap, no toggle. + `@media(prefers-color-scheme:dark){body{background:#0d1117;color:#c9d1d9}` + + `a{color:#58a6ff}}` + `

    Index of ${heading}

      ${items.join("")}
    ` ); } diff --git a/test/static.test.ts b/test/static.test.ts index c4ab2423..26a17940 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -313,6 +313,11 @@ describe("serveStatic", () => { expect(html).toContain('href="../"'); }); + test("follows the OS dark theme", async () => { + const html = await (await fetchStatic("/files/", { dirListing: true })).text(); + expect(html).toContain("prefers-color-scheme:dark"); + }); + test("hides denied dot segments from the listing", async () => { const html = await (await fetchStatic("/files/", { dirListing: true })).text(); expect(html).not.toContain(".hidden"); From 6095d5e4ad19d1b66a61090eaa81a548756b2d81 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 22:56:11 +0000 Subject: [PATCH 3/9] feat(static): modernize directory listing and mark it noindex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refresh the listing style — card-style rows with hover, a monospace heading, and folder/file icons — driven by CSS variables so the dark-theme swap is a single palette override. Add a `noindex, nofollow` robots meta tag and matching `X-Robots-Tag` header, since a generated listing is not content to index. Co-Authored-By: Claude Opus 4.8 --- src/static.ts | 51 +++++++++++++++++++++++++++++++++------------ test/static.test.ts | 6 ++++++ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/static.ts b/src/static.ts index b68284c7..8a2ae471 100644 --- a/src/static.ts +++ b/src/static.ts @@ -691,7 +691,12 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { const entries = await readListing(path); if (entries) { const base = url.pathname.endsWith("/") ? url.pathname : url.pathname + "/"; - const headers = { "Content-Type": "text/html; charset=utf-8" }; + const headers = { + "Content-Type": "text/html; charset=utf-8", + // A generated listing should never be indexed; mirrors the `robots` + // meta tag for crawlers that only read headers. + "X-Robots-Tag": "noindex, nofollow", + }; // HEAD mirrors GET's headers without the body. const body = req.method === "HEAD" ? null : renderDirListing(base, path, entries); return new FastResponse(body, { headers }); @@ -729,9 +734,9 @@ function renderDirListing( const heading = escapeHtml("/" + (displayPath ? displayPath + "/" : "")); const items: string[] = []; // A parent link everywhere but the root. `base` ends in `/`, so relative `../` - // climbs one segment. + // climbs one segment. Shown as a folder, since it navigates up to one. if (displayPath !== "") { - items.push(`
  • ../
  • `); + items.push(row("../", "../", true)); } for (const entry of entries) { const suffix = entry.dir ? "/" : ""; @@ -739,25 +744,45 @@ function renderDirListing( // URL path segment (a literal `/` in a name is encoded, never a separator), // the second makes that URL safe inside the attribute. The trailing `/` for // a directory is appended after encoding so it stays a real separator. - const href = escapeHtml(base + encodeURIComponent(entry.name) + suffix); - const text = escapeHtml(entry.name + suffix); - items.push(`
  • ${text}
  • `); + const href = base + encodeURIComponent(entry.name) + suffix; + items.push(row(href, entry.name + suffix, entry.dir)); } return ( `` + `` + + // A generated listing is not content to index; the `X-Robots-Tag` header + // set alongside covers crawlers that skip the markup. + `` + `Index of ${heading}` + - `` + - `

    Index of ${heading}

      ${items.join("")}
    ` + `@media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#8b949e;--line:#30363d;--link:#4493f8;--hover:#161b22}}` + + `*{box-sizing:border-box}` + + `body{font-family:system-ui,-apple-system,sans-serif;line-height:1.5;margin:0;` + + `padding:2rem 1.25rem;color:var(--fg);background:var(--bg)}` + + `main{max-width:48rem;margin:0 auto}` + + `h1{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.95rem;` + + `font-weight:600;color:var(--muted);margin:0 0 1rem;word-break:break-all}` + + `ul{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:.625rem;overflow:hidden}` + + `li+li{border-top:1px solid var(--line)}` + + `a{display:flex;gap:.625rem;align-items:center;padding:.55rem .875rem;` + + `text-decoration:none;color:var(--link)}` + + `a:hover{background:var(--hover)}` + + `.i{flex:none;width:1.25rem;text-align:center}` + + `` + + `

    Index of ${heading}

      ${items.join("")}
    ` ); } +// One listing row. `href` is a raw (unescaped) URL and `label` raw text; both +// are escaped here before landing in the attribute and element text. The icon +// is a fixed emoji, so it needs none. +function row(href: string, label: string, dir: boolean): string { + const icon = dir ? "📁" : "📄"; + return `
  • ${icon}${escapeHtml(label)}
  • `; +} + // The `Cache-Control` value, or "" when `maxAge` is unset so the header is // omitted entirely. `max-age` takes non-negative integer seconds, so a // fractional or negative `maxAge` is floored and clamped, non-finite values fall diff --git a/test/static.test.ts b/test/static.test.ts index 26a17940..b756a4cc 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -318,6 +318,12 @@ describe("serveStatic", () => { expect(html).toContain("prefers-color-scheme:dark"); }); + test("marks the listing noindex (meta tag and header)", async () => { + const res = await fetchStatic("/files/", { dirListing: true }); + expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow"); + await expect(res.text()).resolves.toContain('name="robots" content="noindex, nofollow"'); + }); + test("hides denied dot segments from the listing", async () => { const html = await (await fetchStatic("/files/", { dirListing: true })).text(); expect(html).not.toContain(".hidden"); From 9739a0cb3c5b1ac0a644eb598d2282eccb8ec1f4 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 22:58:55 +0000 Subject: [PATCH 4/9] feat(static): harden directory listing with security headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The listing interpolates filenames into HTML, so add defense-in-depth headers. The page is fully self-contained (inline CSS, no scripts, images, or fonts), so a strict CSP pins it to exactly that — even a hypothetical escaping slip could neither run a script nor reach an external origin. Add `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer` alongside. Co-Authored-By: Claude Opus 4.8 --- src/static.ts | 12 ++++++++++++ test/static.test.ts | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/static.ts b/src/static.ts index 8a2ae471..f71dbc3f 100644 --- a/src/static.ts +++ b/src/static.ts @@ -696,6 +696,18 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // A generated listing should never be indexed; mirrors the `robots` // meta tag for crawlers that only read headers. "X-Robots-Tag": "noindex, nofollow", + // Defense-in-depth for a page that interpolates filenames. The listing + // is fully self-contained — inline CSS, no scripts, images, or fonts — + // so it is pinned to exactly that: even a hypothetical escaping slip + // could neither run a script nor reach an external origin. `base-uri` + // and `frame-ancestors` close off `` injection and clickjacking. + "Content-Security-Policy": + "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'", + // Never let the HTML be MIME-sniffed into another type. + "X-Content-Type-Options": "nosniff", + // The URL reveals directory structure; keep it out of the `Referer` + // on any outbound navigation. + "Referrer-Policy": "no-referrer", }; // HEAD mirrors GET's headers without the body. const body = req.method === "HEAD" ? null : renderDirListing(base, path, entries); diff --git a/test/static.test.ts b/test/static.test.ts index b756a4cc..5932d79f 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -324,6 +324,16 @@ describe("serveStatic", () => { await expect(res.text()).resolves.toContain('name="robots" content="noindex, nofollow"'); }); + test("locks the listing down with security headers", async () => { + const res = await fetchStatic("/files/", { dirListing: true }); + // A self-contained page: no scripts, no external origins. + expect(res.headers.get("content-security-policy")).toBe( + "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'", + ); + expect(res.headers.get("x-content-type-options")).toBe("nosniff"); + expect(res.headers.get("referrer-policy")).toBe("no-referrer"); + }); + test("hides denied dot segments from the listing", async () => { const html = await (await fetchStatic("/files/", { dirListing: true })).text(); expect(html).not.toContain(".hidden"); From a510777d9584a4364e74b7028f913e4e3efe2f2e Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 23:00:12 +0000 Subject: [PATCH 5/9] docs: document dirListing option and CLI dev-mode listing Add the `dirListing` option to the serveStatic reference with a paragraph covering behaviour, dotfile hiding, absolute links, dark mode, and the noindex/CSP headers. Note the dev-only listing in the CLI static-files section and cross-link the two. Co-Authored-By: Claude Opus 4.8 --- docs/1.guide/10.cli.md | 2 ++ docs/1.guide/4.middleware.md | 3 +++ 2 files changed, 5 insertions(+) diff --git a/docs/1.guide/10.cli.md b/docs/1.guide/10.cli.md index 47737e70..23a0eff5 100644 --- a/docs/1.guide/10.cli.md +++ b/docs/1.guide/10.cli.md @@ -128,6 +128,8 @@ When both a server entry and a static directory are present, static files take p 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. +In dev mode, a request for a directory with no index file returns a generated HTML listing of its contents, so you can browse a folder without an `index.html`. This is a dev convenience only: it is disabled under `--prod`, so the directory structure is never exposed in production. It maps to the [`dirListing`](/guide/middleware#static-files) option of `serveStatic()`. + ## Programmatic API Both CLI modes are built on `srvx/loader`. The same loader is available to you, so you can build a dev server, a test harness, or a framework CLI that accepts any server entry srvx accepts — without reimplementing entry discovery or handler detection. diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index 07f2529f..55d20eae 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -116,10 +116,13 @@ When no file matches the request, it calls `next()` — so your handler acts as - `etag`: Emit a weak `ETag` validator, and answer a matching `If-None-Match` request with `304 Not Modified` (default `true`). - `maxAge`: Freshness lifetime in **seconds**, emitted as `Cache-Control: max-age=` (default `undefined`, no header). Lets a client reuse a response without a request until it goes stale. - `immutable`: Add the `immutable` directive to `Cache-Control`, so a client does not revalidate a still-fresh response even on reload (default `false`). Only takes effect alongside `maxAge`, and only makes sense for fingerprinted (content-hashed) assets. +- `dirListing`: Serve a minimal HTML directory listing when a request resolves to a directory with no index file (default `false`). Off by default because it exposes the directory structure — it is opt-in. The [CLI](/guide/cli#serving-static-files) turns it on in dev mode only. - `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`. +With `dirListing: true`, a request naming a directory with no index file — the root, a trailing-slash path, or an extension-less one — is answered with a generated HTML listing instead of falling through to `next()`. An index always wins, so a directory with an `index.html` still serves it. Entries are the directory's immediate children, sorted directories-first; a denied dot segment (`.env`, `.git`) is hidden from the listing exactly as it is from a direct request, so a listing never names anything the middleware would refuse to serve. Links are absolute paths, so they resolve the same whether the directory was requested with a trailing slash or without, and the page follows the OS light/dark theme. The listing carries `X-Robots-Tag: noindex, nofollow` (mirrored by a `robots` meta tag) and a strict `Content-Security-Policy` — it is a self-contained page with no scripts or external resources — plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. It is off by default because it reveals the directory structure; the [CLI](/guide/cli#serving-static-files) enables it in dev mode only. + 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. From fde202384c1473ec9b365c495bcf63290e88ffac Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 23:05:57 +0000 Subject: [PATCH 6/9] feat(cli): add --dir-listing / --no-dir-listing flags Make the directory listing explicitly controllable: `--dir-listing` forces it on (e.g. under --prod) and `--no-dir-listing` forces it off (e.g. in dev), overriding the dev-on/prod-off default. `parseArgs` has no native negation, so the opt-out is its own flag; the two collapse into a tri-state `dirListing` that falls back to the default when unset. Document both flags in the usage help and CLI guide, and cover the prod opt-in with an end-to-end test. Co-Authored-By: Claude Opus 4.8 --- docs/1.guide/10.cli.md | 11 ++++++++++- src/cli/main.ts | 10 +++++++++- src/cli/serve.ts | 5 +++-- src/cli/types.ts | 6 ++++++ src/cli/usage.ts | 4 ++++ test/cli.test.ts | 24 +++++++++++++++++++++++- 6 files changed, 55 insertions(+), 5 deletions(-) diff --git a/docs/1.guide/10.cli.md b/docs/1.guide/10.cli.md index 23a0eff5..9ddfffe1 100644 --- a/docs/1.guide/10.cli.md +++ b/docs/1.guide/10.cli.md @@ -43,6 +43,8 @@ $ srvx serve --prod # Start production server $ srvx serve --port=8080 # Listen on port 8080 $ srvx serve --host=localhost # Bind to localhost only $ srvx serve --static=./dist # Serve static files (no entry needed) +$ srvx serve --static=./dist --no-dir-listing # ...without the dev directory listing +$ srvx serve --prod --dir-listing # Enable the directory listing in production $ srvx serve --import=jiti/register # Enable [jiti](https://github.com/unjs/jiti) loader $ srvx serve --tls --cert=cert.pem --key=key.pem # Enable TLS (HTTPS/HTTP2) @@ -70,6 +72,8 @@ SERVE OPTIONS -p, --port Port to listen on (default: 3000) --host, --hostname Host to bind to (default: all interfaces) -s, --static Serve static files from the specified directory (default: public) + --dir-listing Serve a directory listing for index-less directories (default: on in dev, off with --prod) + --no-dir-listing Disable the directory listing (e.g. in dev) --prod Run in production mode (no watch, no debug) --import ES module to preload --tls Enable TLS (HTTPS/HTTP2) @@ -128,7 +132,12 @@ When both a server entry and a static directory are present, static files take p 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. -In dev mode, a request for a directory with no index file returns a generated HTML listing of its contents, so you can browse a folder without an `index.html`. This is a dev convenience only: it is disabled under `--prod`, so the directory structure is never exposed in production. It maps to the [`dirListing`](/guide/middleware#static-files) option of `serveStatic()`. +In dev mode, a request for a directory with no index file returns a generated HTML listing of its contents, so you can browse a folder without an `index.html`. This is a dev convenience only: it is disabled under `--prod`, so the directory structure is never exposed in production. Override the default either way with `--dir-listing` (force it on, e.g. in production) or `--no-dir-listing` (force it off in dev). It maps to the [`dirListing`](/guide/middleware#static-files) option of `serveStatic()`. + +```bash +npx srvx --static ./dist --prod --dir-listing # opt in under --prod +npx srvx --static ./dist --no-dir-listing # opt out in dev +``` ## Programmatic API diff --git a/src/cli/main.ts b/src/cli/main.ts index 62ad0440..b25d9533 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -113,6 +113,10 @@ function parseArgs(args: string[]): CLIOptions { prod: { type: "boolean" }, port: { type: "string", short: "p" }, static: { type: "string", short: "s" }, + // `parseArgs` has no native `--no-*` negation, so the opt-out is its own + // flag; the two collapse into a tri-state `dirListing` below. + "dir-listing": { type: "boolean" }, + "no-dir-listing": { type: "boolean" }, import: { type: "string" }, cert: { type: "string" }, key: { type: "string" }, @@ -142,6 +146,10 @@ function parseArgs(args: string[]): CLIOptions { return { mode, ...values, url, method }; } + // Collapse the two listing flags into a tri-state: explicit on/off, or + // `undefined` to leave the dev/prod default to `cliServe`. + const dirListing = values["dir-listing"] ? true : values["no-dir-listing"] ? false : undefined; + // Serve mode: allow entry or dir as a positional argument const maybeEntryOrDir = positionals[0]; if (maybeEntryOrDir) { @@ -159,7 +167,7 @@ function parseArgs(args: string[]): CLIOptions { } } - return { mode, ...values }; + return { mode, ...values, dirListing }; } async function startServer(cliOpts: CLIOptions) { diff --git a/src/cli/serve.ts b/src/cli/serve.ts index 080030f6..3dd2a91b 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -93,8 +93,9 @@ export async function cliServe(cliOpts: CLIOptions): Promise { ? serveStatic({ dir: cliOpts.static, // Dev convenience: browse directories without an index. Off in prod - // so the directory structure is never exposed by default. - dirListing: !cliOpts.prod, + // so the structure is never exposed by default, unless the explicit + // `--dir-listing` / `--no-dir-listing` flag overrides either way. + dirListing: cliOpts.dirListing ?? !cliOpts.prod, }) : undefined, ...(serverOptions.middleware || []), diff --git a/src/cli/types.ts b/src/cli/types.ts index 0bcb8661..30b336ee 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -37,6 +37,12 @@ export type CLIOptions = { prod?: boolean; /** Serve static files from the specified directory (default: "public") */ static?: string; + /** + * Serve an HTML directory listing for directories without an index file. + * Defaults to on in dev mode and off with `--prod`; set explicitly to override + * either way (`--dir-listing` / `--no-dir-listing`). + */ + dirListing?: boolean; /** ES module to preload */ import?: string; /** Host to bind to (default: all interfaces) */ diff --git a/src/cli/usage.ts b/src/cli/usage.ts index 1c52ebc4..a3a18a5b 100644 --- a/src/cli/usage.ts +++ b/src/cli/usage.ts @@ -20,6 +20,8 @@ ${c.gray("$")} ${c.cyan(command)} serve --prod ${c.gray("# Start ${c.gray("$")} ${c.cyan(command)} serve --port=8080 ${c.gray("# Listen on port 8080")} ${c.gray("$")} ${c.cyan(command)} serve --host=localhost ${c.gray("# Bind to localhost only")} ${c.gray("$")} ${c.cyan(command)} serve --static=./dist ${c.gray("# Serve static files (no entry needed)")} +${c.gray("$")} ${c.cyan(command)} serve --static=./dist --no-dir-listing ${c.gray("# ...without the dev directory listing")} +${c.gray("$")} ${c.cyan(command)} serve --prod --dir-listing ${c.gray("# Enable the directory listing in production")} ${c.gray("$")} ${c.cyan(command)} serve --import=jiti/register ${c.gray(`# Enable ${c.url("jiti", "https://github.com/unjs/jiti")} loader`)} ${c.gray("$")} ${c.cyan(command)} serve --tls --cert=cert.pem --key=key.pem ${c.gray("# Enable TLS (HTTPS/HTTP2)")} @@ -47,6 +49,8 @@ ${c.bold("SERVE OPTIONS")} ${c.green("-p, --port")} ${c.yellow("")} Port to listen on (default: ${c.yellow("3000")}) ${c.green("--host, --hostname")} ${c.yellow("")} Host to bind to (default: all interfaces) ${c.green("-s, --static")} ${c.yellow("")} Serve static files from the specified directory (default: ${c.yellow("public")}) + ${c.green("--dir-listing")} Serve a directory listing for index-less directories (default: on in dev, off with ${c.green("--prod")}) + ${c.green("--no-dir-listing")} Disable the directory listing (e.g. in dev) ${c.green("--prod")} Run in production mode (no watch, no debug) ${c.green("--import")} ${c.yellow("")} ES module to preload ${c.green("--tls")} Enable TLS (HTTPS/HTTP2) diff --git a/test/cli.test.ts b/test/cli.test.ts index 6c06f1c1..703d7938 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { fileURLToPath } from "node:url"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import { createServer } from "node:http"; import { mkdtemp, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -38,6 +38,9 @@ describe("cli", () => { expect(exitCode, `${flag} should exit 0`).toBe(0); expect(stdout).toContain("SERVE MODE"); expect(stdout).toContain("FETCH MODE"); + // The directory-listing opt-in/opt-out flags are documented. + expect(stdout).toContain("--dir-listing"); + expect(stdout).toContain("--no-dir-listing"); } }); @@ -146,6 +149,25 @@ describe("cli", () => { } }); + it("--dir-listing opts the directory listing into prod", async () => { + // Listing is dev-only by default; the explicit flag turns it on in prod. + const dir = await mkdtemp(join(tmpdir(), "srvx-cli-list-")); + await writeFile(join(dir, "hello.txt"), "hi"); + const port = await getRandomPort("localhost"); + const child = runCli(["--prod", "--static", dir, "--dir-listing", "--port", String(port)]); + try { + await waitForPort(port, { host: "localhost", delay: 50, retries: 100 }); + const res = await fetch(`http://localhost:${port}/`); + expect(res.status).toBe(200); + expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow"); + expect(await res.text()).toContain("hello.txt"); + } finally { + child.kill("SIGTERM"); + await child.catch(() => {}); + await rm(dir, { recursive: true, force: true }); + } + }); + it("F42: `--tls` without cert/key errors instead of downgrading to http", async () => { const port = await getRandomPort("localhost"); const { stderr, exitCode } = await runCli([ From 5e20cf34b3acabe5fcb50c379e541b6437bcbcae Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 23:11:09 +0000 Subject: [PATCH 7/9] fix(static): resolve listing entries and use absolute parent links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two review findings on the directory listing: - Resolve each symlink entry's canonical target and re-check it against the root and dot-path rules before listing it, classifying by the target. A symlink escaping the root or aliasing a denied dot path is now hidden — matching what a direct request would refuse — and a contained symlink to a directory lists as one. - Build the parent link as an absolute path derived from the base, so an extension-less nested request served without a trailing slash (`/docs/api`) points `../` to `/docs/` rather than `/`. Co-Authored-By: Claude Opus 4.8 --- src/static.ts | 50 ++++++++++++++++++++++++++++++++++++--------- test/static.test.ts | 32 +++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/src/static.ts b/src/static.ts index f71dbc3f..c57df4e2 100644 --- a/src/static.ts +++ b/src/static.ts @@ -354,16 +354,39 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { if (!realWithSep.startsWith(root) || isDeniedDotPath(realWithSep.slice(root.length))) { return null; } - // `withFileTypes` reads the type from the same `readdir` syscall, so the - // trailing-slash decoration below costs no extra `stat` per entry. A - // non-directory (or an unreadable one) throws and falls through to `null`. const dirents = await readdir(realPath, { withFileTypes: true }).catch(() => null); if (dirents === null) { return null; } - const entries = dirents - .filter((d) => !isDeniedDotPath(d.name)) - .map((d) => ({ name: d.name, dir: d.isDirectory() })); + const entries: { name: string; dir: boolean }[] = []; + for (const d of dirents) { + if (isDeniedDotPath(d.name)) { + continue; + } + // A non-symlink child is contained by construction, and `withFileTypes` + // already typed it from the `readdir` syscall — no extra work. + if (!d.isSymbolicLink()) { + entries.push({ name: d.name, dir: d.isDirectory() }); + continue; + } + // A symlink is re-checked exactly as a direct request would be: its + // canonical target must stay under the root and off a denied dot path, or + // it is dropped rather than named. It is then classified by that target — + // so a link to a directory lists as one — never by the link itself, whose + // `isDirectory()` is always false. + const target = await realpath(join(realPath, d.name)).catch(() => null); + if ( + target === null || + !target.startsWith(root) || + isDeniedDotPath(target.slice(root.length)) + ) { + continue; + } + const targetStat = await stat(target).catch(() => null); + if (targetStat) { + entries.push({ name: d.name, dir: targetStat.isDirectory() }); + } + } // Directories first, then by name — a conventional, stable listing order. entries.sort((a, b) => (a.dir === b.dir ? (a.name < b.name ? -1 : 1) : a.dir ? -1 : 1)); return entries; @@ -690,7 +713,10 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { if (dirListing && (path === "" || trailingSlash || extname(path) === "")) { const entries = await readListing(path); if (entries) { - const base = url.pathname.endsWith("/") ? url.pathname : url.pathname + "/"; + // Exactly one trailing slash: entry and parent links are absolute paths + // built off this base, so a request served without a trailing slash + // (`/docs/api`) still yields correct links. + const base = url.pathname.replace(/\/+$/, "") + "/"; const headers = { "Content-Type": "text/html; charset=utf-8", // A generated listing should never be indexed; mirrors the `robots` @@ -745,10 +771,14 @@ function renderDirListing( ): string { const heading = escapeHtml("/" + (displayPath ? displayPath + "/" : "")); const items: string[] = []; - // A parent link everywhere but the root. `base` ends in `/`, so relative `../` - // climbs one segment. Shown as a folder, since it navigates up to one. + // A parent link everywhere but the root, shown as a folder since it navigates + // up to one. Absolute like the entry links: a relative `../` would resolve + // against the browser's document URL, which drops a segment for an + // extension-less request served without a trailing slash. Deriving the parent + // from `base` (`/docs/api/` → `/docs/`) is correct either way. if (displayPath !== "") { - items.push(row("../", "../", true)); + const parent = base.slice(0, base.slice(0, -1).lastIndexOf("/") + 1); + items.push(row(parent, "../", true)); } for (const entry of entries) { const suffix = entry.dir ? "/" : ""; diff --git a/test/static.test.ts b/test/static.test.ts index 5932d79f..a42d1fc3 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -151,6 +151,14 @@ beforeAll(async () => { await writeFile(join(dir, "files", "b.txt"), "B"); await writeFile(join(dir, "files", ".hidden"), "HIDDEN"); + // Symlink entries a listing must resolve like a direct request: a contained + // link to a file, a contained link to a directory (classified by its target), + // one escaping the root, and one aliasing a denied dot path. + await symlink(join(dir, "files", "a.txt"), join(dir, "files", "good-link.txt")); + await symlink(join(dir, "files", "nested"), join(dir, "files", "dir-link")); + await symlink(join(tmp, "outside", "secret.txt"), join(dir, "files", "escape-link.txt")); + await symlink(join(dir, ".env"), join(dir, "files", "dot-alias.txt")); + // A root that is itself a symlink must keep working. linkedDir = join(tmp, "public-link"); await symlink(dir, linkedDir); @@ -309,8 +317,8 @@ describe("serveStatic", () => { expect(html).toContain('href="/files/b.txt"'); // A nested directory is linked with a trailing slash. expect(html).toContain('href="/files/nested/"'); - // A parent link climbs one segment. - expect(html).toContain('href="../"'); + // The parent link is absolute — one segment up from `/files/`. + expect(html).toContain('href="/"'); }); test("follows the OS dark theme", async () => { @@ -339,6 +347,26 @@ describe("serveStatic", () => { expect(html).not.toContain(".hidden"); }); + test("resolves symlink entries by their target and hides unservable ones", async () => { + const html = await (await fetchStatic("/files/", { dirListing: true })).text(); + // A contained symlink to a file is listed as a file. + expect(html).toContain('href="/files/good-link.txt"'); + // A contained symlink to a directory is classified by its target — listed + // as a directory (trailing slash), not as the (non-directory) link. + expect(html).toContain('href="/files/dir-link/"'); + // A symlink escaping the root, or aliasing a denied dot path, is hidden — + // just as a direct request for it is refused. + expect(html).not.toContain("escape-link"); + expect(html).not.toContain("dot-alias"); + }); + + test("parent link is absolute for an extension-less nested directory", async () => { + // `nested/` has no index; requested without a trailing slash, `../` must + // point to `/files/`, not `/`. + const html = await (await fetchStatic("/files/nested", { dirListing: true })).text(); + expect(html).toContain('href="/files/"'); + }); + test("an index always wins over a listing", async () => { // `/sub/` has an index.html, so it serves that rather than a listing. const res = await fetchStatic("/sub/", { dirListing: true }); From 0813146295a8c84d12e55435720783f9ee28d971 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sat, 18 Jul 2026 07:03:36 +0000 Subject: [PATCH 8/9] refactor(static): serve the directory listing as a 404 fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The listing no longer pre-empts the rest of the app: `next()` runs first, and only a 404 response for a path naming a listable directory is replaced by the listing. A real route beats a listing even when a same-name directory exists in the static dir, and a custom 404 page keeps working for any path that is not a listable directory. Static-only CLI mode now answers a miss with an ordinary 404 instead of a 501 "Server Entry Not Found" page — the correct status for that mode, and the one the fallback keys on. Co-Authored-By: Claude Fable 5 --- docs/1.guide/10.cli.md | 2 +- docs/1.guide/4.middleware.md | 4 ++-- src/cli/serve.ts | 18 ++++++++++-------- src/static.ts | 31 ++++++++++++++++++++++--------- test/cli.test.ts | 5 +++++ test/static.test.ts | 23 +++++++++++++++++++++++ 6 files changed, 63 insertions(+), 20 deletions(-) diff --git a/docs/1.guide/10.cli.md b/docs/1.guide/10.cli.md index 9ddfffe1..c0ba7033 100644 --- a/docs/1.guide/10.cli.md +++ b/docs/1.guide/10.cli.md @@ -132,7 +132,7 @@ When both a server entry and a static directory are present, static files take p 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. -In dev mode, a request for a directory with no index file returns a generated HTML listing of its contents, so you can browse a folder without an `index.html`. This is a dev convenience only: it is disabled under `--prod`, so the directory structure is never exposed in production. Override the default either way with `--dir-listing` (force it on, e.g. in production) or `--no-dir-listing` (force it off in dev). It maps to the [`dirListing`](/guide/middleware#static-files) option of `serveStatic()`. +In dev mode, a request for a directory with no index file returns a generated HTML listing of its contents, so you can browse a folder without an `index.html`. The listing is a last-resort fallback: static files are tried first, then your server handler runs, and only a `404` response falls back to the listing — a real route (or a custom 404 page for a non-directory path) always wins. This is a dev convenience only: it is disabled under `--prod`, so the directory structure is never exposed in production. Override the default either way with `--dir-listing` (force it on, e.g. in production) or `--no-dir-listing` (force it off in dev). It maps to the [`dirListing`](/guide/middleware#static-files) option of `serveStatic()`. ```bash npx srvx --static ./dist --prod --dir-listing # opt in under --prod diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index 55d20eae..64842a47 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -116,12 +116,12 @@ When no file matches the request, it calls `next()` — so your handler acts as - `etag`: Emit a weak `ETag` validator, and answer a matching `If-None-Match` request with `304 Not Modified` (default `true`). - `maxAge`: Freshness lifetime in **seconds**, emitted as `Cache-Control: max-age=` (default `undefined`, no header). Lets a client reuse a response without a request until it goes stale. - `immutable`: Add the `immutable` directive to `Cache-Control`, so a client does not revalidate a still-fresh response even on reload (default `false`). Only takes effect alongside `maxAge`, and only makes sense for fingerprinted (content-hashed) assets. -- `dirListing`: Serve a minimal HTML directory listing when a request resolves to a directory with no index file (default `false`). Off by default because it exposes the directory structure — it is opt-in. The [CLI](/guide/cli#serving-static-files) turns it on in dev mode only. +- `dirListing`: Serve a minimal HTML directory listing as a 404 fallback for a directory with no index file (default `false`). The rest of the app answers first; only a 404 is replaced by the listing. Off by default because it exposes the directory structure — it is opt-in. The [CLI](/guide/cli#serving-static-files) turns it on in dev mode only. - `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`. -With `dirListing: true`, a request naming a directory with no index file — the root, a trailing-slash path, or an extension-less one — is answered with a generated HTML listing instead of falling through to `next()`. An index always wins, so a directory with an `index.html` still serves it. Entries are the directory's immediate children, sorted directories-first; a denied dot segment (`.env`, `.git`) is hidden from the listing exactly as it is from a direct request, so a listing never names anything the middleware would refuse to serve. Links are absolute paths, so they resolve the same whether the directory was requested with a trailing slash or without, and the page follows the OS light/dark theme. The listing carries `X-Robots-Tag: noindex, nofollow` (mirrored by a `robots` meta tag) and a strict `Content-Security-Policy` — it is a self-contained page with no scripts or external resources — plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. It is off by default because it reveals the directory structure; the [CLI](/guide/cli#serving-static-files) enables it in dev mode only. +With `dirListing: true`, a request naming a directory with no index file — the root, a trailing-slash path, or an extension-less one — still falls through to `next()` first, and a generated HTML listing replaces the response only when it comes back `404`. A real route always wins over a listing, and a custom 404 page keeps working: it is replaced only when the path actually names a listable directory, and passes through untouched everywhere else. An index always wins, so a directory with an `index.html` still serves it. Entries are the directory's immediate children, sorted directories-first; a denied dot segment (`.env`, `.git`) is hidden from the listing exactly as it is from a direct request, so a listing never names anything the middleware would refuse to serve. Links are absolute paths, so they resolve the same whether the directory was requested with a trailing slash or without, and the page follows the OS light/dark theme. The listing carries `X-Robots-Tag: noindex, nofollow` (mirrored by a `robots` meta tag) and a strict `Content-Security-Policy` — it is a self-contained page with no scripts or external resources — plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. It is off by default because it reveals the directory structure; the [CLI](/guide/cli#serving-static-files) enables it in dev mode only. 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. diff --git a/src/cli/serve.ts b/src/cli/serve.ts index 3dd2a91b..0379a374 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -81,19 +81,21 @@ export async function cliServe(cliOpts: CLIOptions): Promise { }, fetch: loaded.fetch || - (() => - renderError( - cliOpts, - loaded.notFound ? "Server Entry Not Found" : "No Fetch Handler Exported", - 501, - )), + (loaded.notFound + ? // Static-only mode (no entry): an unmatched request is an ordinary + // 404, not a server misconfiguration — and 404 is what the static + // middleware's `dirListing` fallback keys on. + () => new Response("Not Found", { status: 404 }) + : () => renderError(cliOpts, "No Fetch Handler Exported", 501)), middleware: [ log(), cliOpts.static ? serveStatic({ dir: cliOpts.static, - // Dev convenience: browse directories without an index. Off in prod - // so the structure is never exposed by default, unless the explicit + // Dev convenience: browse directories without an index. A 404 + // fallback — static files win first, then the user handler runs, + // and only a 404 falls back to the listing. Off in prod so the + // structure is never exposed by default, unless the explicit // `--dir-listing` / `--no-dir-listing` flag overrides either way. dirListing: cliOpts.dirListing ?? !cliOpts.prod, }) diff --git a/src/static.ts b/src/static.ts index c57df4e2..1c128438 100644 --- a/src/static.ts +++ b/src/static.ts @@ -113,9 +113,11 @@ export interface ServeStaticOptions { ranges?: boolean; /** - * Serve a minimal HTML directory listing when a request resolves to a - * directory that has no index file (`index.html`). A directory that does have - * one always serves the index instead. + * Serve a minimal HTML directory listing as a 404 fallback: when a request + * names a directory with no index file (`index.html`), the rest of the app + * answers first via `next()`, and only a 404 response is replaced by the + * listing. A real route always wins over a listing, and a directory with an + * index always serves the index. * * Entries are the directory's immediate children, with denied dot segments * (see `dotfiles`) hidden just as they are for file requests. Only names are @@ -706,11 +708,19 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { pipeline(stream, encoded, () => {}); return new FastResponse(encoded as any, { headers }); } - // No file matched. With `dirListing` on, a request naming a directory (root, a - // trailing-slash path, or an extension-less one) that has no index is - // answered with a listing rather than falling through. An index always wins: - // its `index.html` candidate was probed in the loop above. - if (dirListing && (path === "" || trailingSlash || extname(path) === "")) { + // No file matched: the rest of the app answers first. With `dirListing` on, + // a request naming a directory (root, a trailing-slash path, or an + // extension-less one) falls back to a generated listing only when downstream + // returned 404 — so a real route always beats a listing, and an index beats + // both (its `index.html` candidate was probed in the loop above). Any other + // downstream response — including a custom 404 page for a path that is not + // a listable directory — passes through untouched. + const response = await next(); + if ( + dirListing && + response.status === 404 && + (path === "" || trailingSlash || extname(path) === "") + ) { const entries = await readListing(path); if (entries) { // Exactly one trailing slash: entry and parent links are absolute paths @@ -735,12 +745,15 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // on any outbound navigation. "Referrer-Policy": "no-referrer", }; + // The listing replaces the downstream 404; drop that response's unread + // body so a streamed one is not left dangling. + response.body?.cancel().catch(() => {}); // HEAD mirrors GET's headers without the body. const body = req.method === "HEAD" ? null : renderDirListing(base, path, entries); return new FastResponse(body, { headers }); } } - return next(); + return response; }; }; diff --git a/test/cli.test.ts b/test/cli.test.ts index 703d7938..e66b2ee3 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -161,6 +161,11 @@ describe("cli", () => { expect(res.status).toBe(200); expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow"); expect(await res.text()).toContain("hello.txt"); + // Static-only mode answers a miss with an ordinary 404 (the status the + // listing fallback keys on), not a 501 error page. + const miss = await fetch(`http://localhost:${port}/missing.txt`); + expect(miss.status).toBe(404); + await miss.arrayBuffer(); } finally { child.kill("SIGTERM"); await child.catch(() => {}); diff --git a/test/static.test.ts b/test/static.test.ts index a42d1fc3..2f07353e 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -307,6 +307,29 @@ describe("serveStatic", () => { await expectNext(await fetchStatic("/files/")); }); + test("a handled route wins: the listing only replaces a downstream 404", async () => { + // The listing is a 404 fallback — `next()` runs first, and a real + // response for the same path is returned untouched. + const middleware = serveStatic({ dir, dirListing: true }); + const res = await track( + middleware(req("/files/"), () => new Response("app route")) as Promise, + ); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("app route"); + }); + + test("a custom 404 page passes through when the path is not a listable directory", async () => { + const middleware = serveStatic({ dir, dirListing: true }); + const res = await track( + middleware( + req("/nope/"), + () => new Response("custom 404", { status: 404 }), + ) as Promise, + ); + expect(res.status).toBe(404); + await expect(res.text()).resolves.toBe("custom 404"); + }); + test("lists a directory without an index when enabled", async () => { const res = await fetchStatic("/files/", { dirListing: true }); expect(res.status).toBe(200); From c7254f873f54bf2bd2cbbe173bc4758ef8869037 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sat, 18 Jul 2026 12:09:18 +0000 Subject: [PATCH 9/9] fix(static): contain listing symlinks to the root itself; never cache listings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-entry symlink containment check compared the canonical target against the root without the trailing separator treatment the directory check gets, so a link whose target is exactly the served root (`self -> .`) vanished from listings while still serving. Apply `asPrefix` before the prefix test, matching the directory-level check. Also send `Cache-Control: no-store` on listings — they mirror live directory state, and heuristic caching could otherwise show a stale listing after files change. Pin the request-path dot-deny with a test (`/.secret-dir/` stays unlistable) and correct "dev mode only" wording to "by default" now that `--dir-listing` can force it on in prod. Co-Authored-By: Claude Fable 5 --- docs/1.guide/4.middleware.md | 4 ++-- src/static.ts | 18 ++++++++++++------ test/static.test.ts | 21 ++++++++++++++++++++- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index 64842a47..b4b67f92 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -116,12 +116,12 @@ When no file matches the request, it calls `next()` — so your handler acts as - `etag`: Emit a weak `ETag` validator, and answer a matching `If-None-Match` request with `304 Not Modified` (default `true`). - `maxAge`: Freshness lifetime in **seconds**, emitted as `Cache-Control: max-age=` (default `undefined`, no header). Lets a client reuse a response without a request until it goes stale. - `immutable`: Add the `immutable` directive to `Cache-Control`, so a client does not revalidate a still-fresh response even on reload (default `false`). Only takes effect alongside `maxAge`, and only makes sense for fingerprinted (content-hashed) assets. -- `dirListing`: Serve a minimal HTML directory listing as a 404 fallback for a directory with no index file (default `false`). The rest of the app answers first; only a 404 is replaced by the listing. Off by default because it exposes the directory structure — it is opt-in. The [CLI](/guide/cli#serving-static-files) turns it on in dev mode only. +- `dirListing`: Serve a minimal HTML directory listing as a 404 fallback for a directory with no index file (default `false`). The rest of the app answers first; only a 404 is replaced by the listing. Off by default because it exposes the directory structure — it is opt-in. The [CLI](/guide/cli#serving-static-files) turns it on in dev mode by default. - `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`. -With `dirListing: true`, a request naming a directory with no index file — the root, a trailing-slash path, or an extension-less one — still falls through to `next()` first, and a generated HTML listing replaces the response only when it comes back `404`. A real route always wins over a listing, and a custom 404 page keeps working: it is replaced only when the path actually names a listable directory, and passes through untouched everywhere else. An index always wins, so a directory with an `index.html` still serves it. Entries are the directory's immediate children, sorted directories-first; a denied dot segment (`.env`, `.git`) is hidden from the listing exactly as it is from a direct request, so a listing never names anything the middleware would refuse to serve. Links are absolute paths, so they resolve the same whether the directory was requested with a trailing slash or without, and the page follows the OS light/dark theme. The listing carries `X-Robots-Tag: noindex, nofollow` (mirrored by a `robots` meta tag) and a strict `Content-Security-Policy` — it is a self-contained page with no scripts or external resources — plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. It is off by default because it reveals the directory structure; the [CLI](/guide/cli#serving-static-files) enables it in dev mode only. +With `dirListing: true`, a request naming a directory with no index file — the root, a trailing-slash path, or an extension-less one — still falls through to `next()` first, and a generated HTML listing replaces the response only when it comes back `404`. A real route always wins over a listing, and a custom 404 page keeps working: it is replaced only when the path actually names a listable directory, and passes through untouched everywhere else. An index always wins, so a directory with an `index.html` still serves it. Entries are the directory's immediate children, sorted directories-first; a denied dot segment (`.env`, `.git`) is hidden from the listing exactly as it is from a direct request, so a listing never names anything the middleware would refuse to serve. Links are absolute paths, so they resolve the same whether the directory was requested with a trailing slash or without, and the page follows the OS light/dark theme. The listing carries `X-Robots-Tag: noindex, nofollow` (mirrored by a `robots` meta tag) and a strict `Content-Security-Policy` — it is a self-contained page with no scripts or external resources — plus `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and `Cache-Control: no-store` so a browser never shows a stale listing after files change. It is off by default because it reveals the directory structure; the [CLI](/guide/cli#serving-static-files) enables it in dev mode by default. 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. diff --git a/src/static.ts b/src/static.ts index 1c128438..cecdc678 100644 --- a/src/static.ts +++ b/src/static.ts @@ -124,7 +124,7 @@ export interface ServeStaticOptions { * revealed, never file contents. * * Off by default — it exposes the directory structure, so it is opt-in. The - * `srvx` CLI turns it on in dev mode only. + * `srvx` CLI turns it on in dev mode by default. * * @default false */ @@ -377,11 +377,14 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // so a link to a directory lists as one — never by the link itself, whose // `isDirectory()` is always false. const target = await realpath(join(realPath, d.name)).catch(() => null); - if ( - target === null || - !target.startsWith(root) || - isDeniedDotPath(target.slice(root.length)) - ) { + if (target === null) { + continue; + } + // `asPrefix` like the directory check above, or a link whose target is + // exactly the root (`self -> .`) fails the prefix test on the missing + // trailing separator and vanishes from a listing while still serving. + const targetWithSep = asPrefix(target); + if (!targetWithSep.startsWith(root) || isDeniedDotPath(targetWithSep.slice(root.length))) { continue; } const targetStat = await stat(target).catch(() => null); @@ -744,6 +747,9 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // The URL reveals directory structure; keep it out of the `Referer` // on any outbound navigation. "Referrer-Policy": "no-referrer", + // The listing mirrors live directory state — without this, heuristic + // caching could keep showing a stale listing after files change. + "Cache-Control": "no-store", }; // The listing replaces the downstream 404; drop that response's unread // body so a streamed one is not left dangling. diff --git a/test/static.test.ts b/test/static.test.ts index 2f07353e..af3b2a30 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -153,11 +153,19 @@ beforeAll(async () => { // Symlink entries a listing must resolve like a direct request: a contained // link to a file, a contained link to a directory (classified by its target), - // one escaping the root, and one aliasing a denied dot path. + // one escaping the root, one aliasing a denied dot path, and one whose target + // is exactly the served root (the containment edge case: root minus its + // trailing separator must still count as contained). await symlink(join(dir, "files", "a.txt"), join(dir, "files", "good-link.txt")); await symlink(join(dir, "files", "nested"), join(dir, "files", "dir-link")); await symlink(join(tmp, "outside", "secret.txt"), join(dir, "files", "escape-link.txt")); await symlink(join(dir, ".env"), join(dir, "files", "dot-alias.txt")); + await symlink(dir, join(dir, "files", "root-link")); + + // A denied dot directory: a listing request for it must fall through, like + // any direct request under it. + await mkdir(join(dir, ".secret-dir")); + await writeFile(join(dir, ".secret-dir", "inner.txt"), "SECRET"); // A root that is itself a symlink must keep working. linkedDir = join(tmp, "public-link"); @@ -363,6 +371,8 @@ describe("serveStatic", () => { ); expect(res.headers.get("x-content-type-options")).toBe("nosniff"); expect(res.headers.get("referrer-policy")).toBe("no-referrer"); + // Live directory state: never cached, so a listing is never stale. + expect(res.headers.get("cache-control")).toBe("no-store"); }); test("hides denied dot segments from the listing", async () => { @@ -381,6 +391,15 @@ describe("serveStatic", () => { // just as a direct request for it is refused. expect(html).not.toContain("escape-link"); expect(html).not.toContain("dot-alias"); + // A link to the served root itself is contained (a request through it + // serves), so it is listed — as a directory. + expect(html).toContain('href="/files/root-link/"'); + }); + + test("a denied dot directory is not listable", async () => { + // The request-path deny: `/.secret-dir/` must fall through with the + // downstream 404 intact, exactly like a direct request under it. + await expectNext(await fetchStatic("/.secret-dir/", { dirListing: true })); }); test("parent link is absolute for an extension-less nested directory", async () => {