From 6e8ee3ceffda52938d13c15d9fecf506ce598331 Mon Sep 17 00:00:00 2001 From: ayame113 <40050810+ayame113@users.noreply.github.com> Date: Sat, 6 May 2023 16:28:27 +0900 Subject: [PATCH] fix(http/file_server): redirect non-canonical URL to canonical URL (#3362) --- http/file_server.ts | 195 +++++++++++++++++++++++---------------- http/file_server_test.ts | 27 ++++++ 2 files changed, 142 insertions(+), 80 deletions(-) diff --git a/http/file_server.ts b/http/file_server.ts index 90b5bf6e3b0e..c67d17ea2ac0 100644 --- a/http/file_server.ts +++ b/http/file_server.ts @@ -12,7 +12,6 @@ import { calculate, ifNoneMatch } from "./etag.ts"; import { Status } from "./http_status.ts"; import { ByteSliceStream } from "../streams/byte_slice_stream.ts"; import { parse } from "../flags/mod.ts"; -import { assert } from "../_util/asserts.ts"; import { red } from "../fmt/colors.ts"; import { createCommonResponse } from "./util.ts"; import { VERSION } from "../version.ts"; @@ -259,11 +258,11 @@ export async function serveFile( async function serveDirIndex( dirPath: string, options: { - dotfiles: boolean; + showDotfiles: boolean; target: string; }, ): Promise { - const showDotfiles = options.dotfiles; + const { showDotfiles } = options; const dirUrl = `/${posix.relative(options.target, dirPath)}`; const listEntry: EntryInfo[] = []; @@ -306,21 +305,23 @@ async function serveDirIndex( return createCommonResponse(Status.OK, page, { headers }); } -function serveFallback(_req: Request, e: Error): Promise { - if (e instanceof URIError) { - return Promise.resolve(createCommonResponse(Status.BadRequest)); - } else if (e instanceof Deno.errors.NotFound) { - return Promise.resolve(createCommonResponse(Status.NotFound)); +function serveFallback(maybeError: unknown): Response { + if (maybeError instanceof URIError) { + return createCommonResponse(Status.BadRequest); } - return Promise.resolve(createCommonResponse(Status.InternalServerError)); + if (maybeError instanceof Deno.errors.NotFound) { + return createCommonResponse(Status.NotFound); + } + + return createCommonResponse(Status.InternalServerError); } function serverLog(req: Request, status: number) { const d = new Date().toISOString(); const dateFmt = `[${d.slice(0, 10)} ${d.slice(11, 19)}]`; - const normalizedUrl = normalizeURL(req.url); - const s = `${dateFmt} [${req.method}] ${normalizedUrl} ${status}`; + const url = new URL(req.url); + const s = `${dateFmt} [${req.method}] ${url.pathname}${url.search} ${status}`; // using console.debug instead of console.log so chrome inspect users can hide request logs console.debug(s); } @@ -541,74 +542,22 @@ export interface ServeDirOptions { * @param req The request to handle */ export async function serveDir(req: Request, opts: ServeDirOptions = {}) { - let response: Response | undefined = undefined; - const target = opts.fsRoot || "."; - const urlRoot = opts.urlRoot; - const showIndex = opts.showIndex ?? true; - + let response: Response; try { - let normalizedPath = normalizeURL(req.url); - if (urlRoot) { - if (normalizedPath.startsWith("/" + urlRoot)) { - normalizedPath = normalizedPath.replace(urlRoot, ""); - } else { - throw new Deno.errors.NotFound(); - } - } - - const fsPath = posix.join(target, normalizedPath); - const fileInfo = await Deno.stat(fsPath); - - if (fileInfo.isDirectory) { - if (showIndex) { - try { - const path = posix.join(fsPath, "index.html"); - const indexFileInfo = await Deno.lstat(path); - if (indexFileInfo.isFile) { - // If the current URL's pathname doesn't end with a slash, any - // relative URLs in the index file will resolve against the parent - // directory, rather than the current directory. To prevent that, we - // return a 301 redirect to the URL with a slash. - if (!fsPath.endsWith("/")) { - const url = new URL(req.url); - url.pathname += "/"; - return Response.redirect(url, 301); - } - response = await serveFile(req, path, { - etagAlgorithm: opts.etagAlgorithm, - fileInfo: indexFileInfo, - }); - } - } catch (e) { - if (!(e instanceof Deno.errors.NotFound)) { - throw e; - } - // pass - } - } - if (!response && opts.showDirListing) { - response = await serveDirIndex(fsPath, { - dotfiles: opts.showDotfiles || false, - target, - }); - } - if (!response) { - throw new Deno.errors.NotFound(); - } - } else { - response = await serveFile(req, fsPath, { - etagAlgorithm: opts.etagAlgorithm, - fileInfo, - }); + response = await createServeDirResponse(req, opts); + } catch (error) { + if (!opts.quiet) { + console.error( + red(error instanceof Error ? error.message : "[non-error thrown]"), + ); } - } catch (e) { - const err = e instanceof Error ? e : new Error("[non-error thrown]"); - if (!opts.quiet) console.error(red(err.message)); - response = await serveFallback(req, err); + response = serveFallback(error); } - if (opts.enableCors) { - assert(response); + // Do not update the header if the response is a 301 redirect. + const isRedirectResponse = 300 <= response.status && response.status < 400; + + if (opts.enableCors && !isRedirectResponse) { response.headers.append("access-control-allow-origin", "*"); response.headers.append( "access-control-allow-headers", @@ -616,9 +565,9 @@ export async function serveDir(req: Request, opts: ServeDirOptions = {}) { ); } - if (!opts.quiet) serverLog(req, response!.status); + if (!opts.quiet) serverLog(req, response.status); - if (opts.headers) { + if (opts.headers && !isRedirectResponse) { for (const header of opts.headers) { const headerSplit = header.split(":"); const name = headerSplit[0]; @@ -627,11 +576,97 @@ export async function serveDir(req: Request, opts: ServeDirOptions = {}) { } } - return response!; + return response; } -function normalizeURL(url: string): string { - return posix.normalize(decodeURIComponent(new URL(url).pathname)); +async function createServeDirResponse( + req: Request, + opts: ServeDirOptions, +) { + const target = opts.fsRoot || "."; + const urlRoot = opts.urlRoot; + const showIndex = opts.showIndex ?? true; + const showDotfiles = opts.showDotfiles || false; + const { etagAlgorithm, showDirListing } = opts; + + const url = new URL(req.url); + const decodedUrl = decodeURIComponent(url.pathname); + let normalizedPath = posix.normalize(decodedUrl); + + if (urlRoot && !normalizedPath.startsWith("/" + urlRoot)) { + return createCommonResponse(Status.NotFound); + } + + // Redirect paths like `/foo////bar` and `/foo/bar/////` to normalized paths. + if (normalizedPath !== decodedUrl) { + url.pathname = normalizedPath; + return Response.redirect(url, 301); + } + + if (urlRoot) { + normalizedPath = normalizedPath.replace(urlRoot, ""); + } + + // Remove trailing slashes to avoid ENOENT errors + // when accessing a path to a file with a trailing slash. + if (normalizedPath.endsWith("/")) { + normalizedPath = normalizedPath.slice(0, -1); + } + + const fsPath = posix.join(target, normalizedPath); + const fileInfo = await Deno.stat(fsPath); + + // For files, remove the trailing slash from the path. + if (fileInfo.isFile && url.pathname.endsWith("/")) { + url.pathname = url.pathname.slice(0, -1); + return Response.redirect(url, 301); + } + // For directories, the path must have a trailing slash. + if (fileInfo.isDirectory && !url.pathname.endsWith("/")) { + // On directory listing pages, + // if the current URL's pathname doesn't end with a slash, any + // relative URLs in the index file will resolve against the parent + // directory, rather than the current directory. To prevent that, we + // return a 301 redirect to the URL with a slash. + url.pathname += "/"; + return Response.redirect(url, 301); + } + + // if target is file, serve file. + if (!fileInfo.isDirectory) { + return await serveFile(req, fsPath, { + etagAlgorithm, + fileInfo, + }); + } + + // if target is directory, serve index or dir listing. + if (showIndex) { // serve index.html + const indexPath = posix.join(fsPath, "index.html"); + + let indexFileInfo: Deno.FileInfo | undefined; + try { + indexFileInfo = await Deno.lstat(indexPath); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) { + throw error; + } + // skip Not Found error + } + + if (indexFileInfo?.isFile) { + return await serveFile(req, indexPath, { + etagAlgorithm, + fileInfo: indexFileInfo, + }); + } + } + + if (showDirListing) { // serve directory list + return await serveDirIndex(fsPath, { showDotfiles, target }); + } + + return createCommonResponse(Status.NotFound); } function main() { diff --git a/http/file_server_test.ts b/http/file_server_test.ts index 628dbf9c6c1a..b64619011096 100644 --- a/http/file_server_test.ts +++ b/http/file_server_test.ts @@ -1428,6 +1428,33 @@ Deno.test( }, ); +Deno.test( + "serveDir redirects a file URL ending with a slash correctly even with a query string", + async () => { + const url = "http://localhost:4507/http/testdata/test%20file.txt/?test"; + const res = await serveDir(new Request(url), { showIndex: true }); + assertEquals(res.status, 301); + assertEquals( + res.headers.get("Location"), + "http://localhost:4507/http/testdata/test%20file.txt?test", + ); + }, +); + +Deno.test( + "serveDir redirects non-canonical URLs", + async () => { + const url = + "http://localhost:4507/http/testdata//////test%20file.txt/////?test"; + const res = await serveDir(new Request(url), { showIndex: true }); + assertEquals(res.status, 301); + assertEquals( + res.headers.get("Location"), + "http://localhost:4507/http/testdata/test%20file.txt/?test", + ); + }, +); + Deno.test( "file_server returns 304 for requests with if-none-match set with the etag but with W/ prefixed etag in request headers.", async () => {