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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions http/file_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -729,18 +730,40 @@ async function createServeDirResponse(
normalizedPath = normalizedPath.slice(0, -1);
}

// Exclude dotfiles if showDotfiles is false
if (!showDotfiles && /\/\./.test(normalizedPath)) {
// 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 unless they are allowed
if (dotfiles !== "allow" && /\/\./.test(normalizedPath)) {
return createStandardResponse(
dotfiles === "deny" ? STATUS_CODE.Forbidden : STATUS_CODE.NotFound,
);
}

// Resolve path
// If cleanUrls is enabled, automatically append ".html" if not present
// and it does not shadow another existing file or directory
let fsPath = join(target, normalizedPath);
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.
Expand Down Expand Up @@ -790,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);
Expand Down
88 changes: 88 additions & 0 deletions http/file_server_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1227,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, {
Expand Down
17 changes: 17 additions & 0 deletions http/unstable_file_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
14 changes: 10 additions & 4 deletions path/is_glob_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
const timer = setTimeout(() => {
reject(new Error("isGlob() did not finish in time"));
Expand Down Expand Up @@ -149,4 +155,4 @@ Deno.test(

await promise;
},
);
});
Loading