From 1e4f452b0b17d74468c8ff8868e3159f805427d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Wed, 16 Sep 2026 22:39:12 +0200 Subject: [PATCH 1/3] fix(http): reject percent-encoded backslashes in serveDir() paths --- http/file_server.ts | 22 +++++++++++++++++++++- http/file_server_test.ts | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/http/file_server.ts b/http/file_server.ts index 687dfa1340e0..b2b7599b80f3 100644 --- a/http/file_server.ts +++ b/http/file_server.ts @@ -37,7 +37,7 @@ import { extname } from "@std/path/extname"; import { join } from "@std/path/join"; import { relative } from "@std/path/relative"; import { resolve } from "@std/path/resolve"; -import { SEPARATOR_PATTERN } from "@std/path/constants"; +import { SEPARATOR, SEPARATOR_PATTERN } from "@std/path/constants"; import { exists } from "@std/fs/exists"; import { contentType } from "@std/media-types/content-type"; import { eTag, ifNoneMatch } from "./etag.ts"; @@ -729,6 +729,14 @@ async function createServeDirResponse( normalizedPath = normalizedPath.slice(0, -1); } + // A percent-encoded backslash (`%5C`) survives URL parsing and is treated + // as a path separator by the Windows filesystem, which would bypass the + // POSIX-based normalization and dotfile checks (e.g. `/%5C.env` or + // `/sub%5C..%5C..%5Csecret`). + if (normalizedPath.includes("\\")) { + return createStandardResponse(STATUS_CODE.NotFound); + } + // Exclude dotfiles if showDotfiles is false if (!showDotfiles && /\/\./.test(normalizedPath)) { return createStandardResponse(STATUS_CODE.NotFound); @@ -741,6 +749,18 @@ async function createServeDirResponse( if (cleanUrls && !fsPath.endsWith(".html") && !(await exists(fsPath))) { fsPath += ".html"; } + + // Defense in depth: the checks above should guarantee containment, but + // never serve a path that resolves outside the root directory. + const resolvedTarget = resolve(target); + const resolvedFsPath = resolve(fsPath); + if ( + resolvedFsPath !== resolvedTarget && + !resolvedFsPath.startsWith(resolvedTarget + SEPARATOR) + ) { + return createStandardResponse(STATUS_CODE.NotFound); + } + const fileInfo = await Deno.stat(fsPath); // For files, remove the trailing slash from the path. diff --git a/http/file_server_test.ts b/http/file_server_test.ts index eba6a99cdfcd..2065e4df0b67 100644 --- a/http/file_server_test.ts +++ b/http/file_server_test.ts @@ -499,6 +499,29 @@ Deno.test("serveDir() doesn't show dotfiles when showDotfiles=false", async () = assertEquals(body, "Not Found"); }); +Deno.test("serveDir() rejects percent-encoded backslashes", async () => { + // A percent-encoded backslash (`%5C`) survives URL parsing and acts as a + // path separator on Windows, allowing dotfile disclosure and path + // traversal outside fsRoot if not rejected. + const paths = [ + "/%5C.dotfile", + "/%5c.dotfile", + "/subdir%5C..%5C.dotfile", + "/subdir%5C..%5C..%5Cfile_server.ts", + "/%5C..%5C..%5Cfile_server.ts", + ]; + for (const path of paths) { + const req = new Request(`http://localhost${path}`); + const res = await serveDir(req, { + ...serveDirOptions, + showDotfiles: false, + }); + await res.body?.cancel(); + + assertEquals(res.status, 404); + } +}); + Deno.test("serveDir() shows .. if it makes sense", async () => { const req1 = new Request("http://localhost/"); const res1 = await serveDir(req1, serveDirOptions); From 26ebe10750c9e62e4b824c7ceff7125c715fbb5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Wed, 16 Sep 2026 22:46:54 +0200 Subject: [PATCH 2/3] feat(http/unstable): add dotfiles option to serveDir() --- http/file_server.ts | 17 +++++++--- http/file_server_test.ts | 65 ++++++++++++++++++++++++++++++++++++ http/unstable_file_server.ts | 17 ++++++++++ 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/http/file_server.ts b/http/file_server.ts index b2b7599b80f3..e4ab5ee3519d 100644 --- a/http/file_server.ts +++ b/http/file_server.ts @@ -701,7 +701,8 @@ async function createServeDirResponse( const urlRoot = opts.urlRoot; const showIndex = opts.showIndex ?? true; const cleanUrls = (opts as { cleanUrls?: boolean }).cleanUrls ?? false; - const showDotfiles = opts.showDotfiles || false; + const dotfiles = (opts as { dotfiles?: "allow" | "deny" | "ignore" }) + .dotfiles ?? (opts.showDotfiles ? "allow" : "ignore"); const { etagAlgorithm = "SHA-256", showDirListing = false, quiet = false } = opts; @@ -737,9 +738,11 @@ async function createServeDirResponse( return createStandardResponse(STATUS_CODE.NotFound); } - // Exclude dotfiles if showDotfiles is false - if (!showDotfiles && /\/\./.test(normalizedPath)) { - return createStandardResponse(STATUS_CODE.NotFound); + // Exclude dotfiles unless they are allowed + if (dotfiles !== "allow" && /\/\./.test(normalizedPath)) { + return createStandardResponse( + dotfiles === "deny" ? STATUS_CODE.Forbidden : STATUS_CODE.NotFound, + ); } // Resolve path @@ -810,7 +813,11 @@ async function createServeDirResponse( } if (showDirListing) { // serve directory list - return serveDirIndex(req, fsPath, { showDotfiles, target, quiet }); + return serveDirIndex(req, fsPath, { + showDotfiles: dotfiles === "allow", + target, + quiet, + }); } return createStandardResponse(STATUS_CODE.NotFound); diff --git a/http/file_server_test.ts b/http/file_server_test.ts index 2065e4df0b67..6b0eb9128a2e 100644 --- a/http/file_server_test.ts +++ b/http/file_server_test.ts @@ -1250,6 +1250,71 @@ Deno.test("(unstable) serveDir() does not shadow existing files and directory if assertEquals(res.headers.has("location"), true); }); +Deno.test("(unstable) serveDir() ignores dotfiles by default", async () => { + const req = new Request("http://localhost/.dotfile"); + const res = await unstableServeDir(req, { + quiet: true, + fsRoot: testdataDir, + }); + await res.body?.cancel(); + + assertEquals(res.status, 404); +}); + +Deno.test("(unstable) serveDir() serves dotfiles when dotfiles=allow", async () => { + const req1 = new Request("http://localhost/.dotfile"); + const res1 = await unstableServeDir(req1, { + ...serveDirOptions, + showDotfiles: false, + dotfiles: "allow", + }); + + assertEquals(res1.status, 200); + assertEquals(await res1.text(), "dotfile"); + + const req2 = new Request("http://localhost/"); + const res2 = await unstableServeDir(req2, { + ...serveDirOptions, + showDotfiles: false, + dotfiles: "allow", + }); + const listing = await res2.text(); + + assert(listing.includes(".dotfile")); +}); + +Deno.test("(unstable) serveDir() denies dotfiles when dotfiles=deny", async () => { + const req1 = new Request("http://localhost/.dotfile"); + const res1 = await unstableServeDir(req1, { + ...serveDirOptions, + dotfiles: "deny", + }); + await res1.body?.cancel(); + + assertEquals(res1.status, 403); + + const req2 = new Request("http://localhost/"); + const res2 = await unstableServeDir(req2, { + ...serveDirOptions, + dotfiles: "deny", + }); + const listing = await res2.text(); + + assert(!listing.includes(".dotfile")); +}); + +Deno.test("(unstable) serveDir() dotfiles option takes precedence over showDotfiles", async () => { + const req = new Request("http://localhost/.dotfile"); + const res = await unstableServeDir(req, { + ...serveDirOptions, + showDotfiles: true, + dotfiles: "ignore", + }); + await res.body?.cancel(); + + assertEquals(res.status, 404); +}); + Deno.test("(unstable) serveFile() sends custom headers", async () => { const req = new Request("http://localhost/testdata/test_file.txt"); const res = await unstableServeFile(req, TEST_FILE_PATH, { diff --git a/http/unstable_file_server.ts b/http/unstable_file_server.ts index c0a4ecbdd8de..d69c9ad88316 100644 --- a/http/unstable_file_server.ts +++ b/http/unstable_file_server.ts @@ -61,6 +61,23 @@ export interface ServeDirOptions extends StableServeDirOptions { * @default {false} */ cleanUrls?: boolean; + /** + * How to treat dotfiles (files and directories whose name starts with a + * dot): + * + * - `"ignore"`: respond with `404 Not Found`, as if the file does not + * exist, and hide dotfiles from directory listings. + * - `"deny"`: respond with `403 Forbidden` and hide dotfiles from + * directory listings. + * - `"allow"`: no special treatment; serve dotfiles and show them in + * directory listings. + * + * Takes precedence over {@linkcode ServeDirOptions.showDotfiles} when both + * are specified. + * + * @default {"ignore"} + */ + dotfiles?: "allow" | "deny" | "ignore"; } /** Interface for serveFile options. */ From bbf130987727ec10518d0f77745803d47b23113a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Wed, 16 Sep 2026 22:50:14 +0200 Subject: [PATCH 3/3] ci: skip worker-based isGlob test on bun Bun 1.4.2 crashes when constructing a node:worker_threads Worker (TypeError: undefined is not an object (evaluating 'this._events')), which fails this test on every PR. --- path/is_glob_test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/path/is_glob_test.ts b/path/is_glob_test.ts index 406bf9c5965c..58a0b06a5b2b 100644 --- a/path/is_glob_test.ts +++ b/path/is_glob_test.ts @@ -114,10 +114,16 @@ Deno.test({ }, }); +const isBun = navigator.userAgent.includes("Bun/"); + // ref https://github.com/denoland/std/pull/6764 -Deno.test( - "isGlob works with the input that includes large number of open brackets", - async () => { +Deno.test({ + name: + "isGlob works with the input that includes large number of open brackets", + // Bun 1.4.2 crashes when constructing a node:worker_threads Worker + // (TypeError: undefined is not an object (evaluating 'this._events')) + ignore: isBun, + async fn() { const { promise, resolve, reject } = Promise.withResolvers(); const timer = setTimeout(() => { reject(new Error("isGlob() did not finish in time")); @@ -149,4 +155,4 @@ Deno.test( await promise; }, -); +});