diff options
author | sarahdenofiletrav <74922000+sarahdenofiletrav@users.noreply.github.com> | 2020-11-26 21:31:19 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-11-26 22:31:19 +0100 |
commit | 28869a632d190dc29d78738bc5e90eadf99bc824 (patch) | |
tree | 85b749f6ffbaeada706a6b6d15b0f4ce2399d454 | |
parent | 4f46dc999b9fd3f26b6586d06d099d7039ca35c8 (diff) |
fix(std/http): prevent path traversal (#8474)
Fix path traversal problem when the request URI
does not have a leading slash.
The file server now returns HTTP 400 when requests
lack the leading slash, and are not absolute URIs.
(https://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html).
-rw-r--r-- | std/http/file_server.ts | 32 | ||||
-rw-r--r-- | std/http/file_server_test.ts | 101 |
2 files changed, 127 insertions, 6 deletions
diff --git a/std/http/file_server.ts b/std/http/file_server.ts index 331dbe5c5..c0d58351b 100644 --- a/std/http/file_server.ts +++ b/std/http/file_server.ts @@ -187,10 +187,15 @@ async function serveDir( } function serveFallback(req: ServerRequest, e: Error): Promise<Response> { - if (e instanceof Deno.errors.NotFound) { + if (e instanceof URIError) { + return Promise.resolve({ + status: 400, + body: encoder.encode("Bad Request"), + }); + } else if (e instanceof Deno.errors.NotFound) { return Promise.resolve({ status: 404, - body: encoder.encode("Not found"), + body: encoder.encode("Not Found"), }); } else { return Promise.resolve({ @@ -335,6 +340,21 @@ function normalizeURL(url: string): string { throw e; } } + + try { + //allowed per https://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html + const absoluteURI = new URL(normalizedUrl); + normalizedUrl = absoluteURI.pathname; + } catch (e) { //wasn't an absoluteURI + if (!(e instanceof TypeError)) { + throw e; + } + } + + if (normalizedUrl[0] !== "/") { + throw new URIError("The request URI is malformed."); + } + normalizedUrl = posix.normalize(normalizedUrl); const startOfParams = normalizedUrl.indexOf("?"); return startOfParams > -1 @@ -383,11 +403,13 @@ function main(): void { } const handler = async (req: ServerRequest): Promise<void> => { - const normalizedUrl = normalizeURL(req.url); - const fsPath = posix.join(target, normalizedUrl); - let response: Response | undefined; try { + const normalizedUrl = normalizeURL(req.url); + let fsPath = posix.join(target, normalizedUrl); + if (fsPath.indexOf(target) !== 0) { + fsPath = target; + } const fileInfo = await Deno.stat(fsPath); if (fileInfo.isDirectory) { if (dirListingEnabled) { diff --git a/std/http/file_server_test.ts b/std/http/file_server_test.ts index 5f7137998..050109fe0 100644 --- a/std/http/file_server_test.ts +++ b/std/http/file_server_test.ts @@ -2,11 +2,12 @@ import { assert, assertEquals, + assertNotEquals, assertStringIncludes, } from "../testing/asserts.ts"; import { BufReader } from "../io/bufio.ts"; import { TextProtoReader } from "../textproto/mod.ts"; -import { ServerRequest } from "./server.ts"; +import { Response, ServerRequest } from "./server.ts"; import { FileServerArgs, serveFile } from "./file_server.ts"; import { dirname, fromFileUrl, join, resolve } from "../path/mod.ts"; let fileServer: Deno.Process<Deno.RunOptions & { stdout: "piped" }>; @@ -78,6 +79,78 @@ async function killFileServer(): Promise<void> { fileServer.stdout!.close(); } +interface StringResponse extends Response { + body: string; +} + +/* HTTP GET request allowing arbitrary paths */ +async function fetchExactPath( + hostname: string, + port: number, + path: string, +): Promise<StringResponse> { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const request = encoder.encode("GET " + path + " HTTP/1.1\r\n\r\n"); + let conn: void | Deno.Conn; + try { + conn = await Deno.connect( + { hostname: hostname, port: port, transport: "tcp" }, + ); + await Deno.writeAll(conn, request); + let currentResult = ""; + let contentLength = -1; + let startOfBody = -1; + for await (const chunk of Deno.iter(conn)) { + currentResult += decoder.decode(chunk); + if (contentLength === -1) { + const match = /^content-length: (.*)$/m.exec(currentResult); + if (match && match[1]) { + contentLength = Number(match[1]); + } + } + if (startOfBody === -1) { + const ind = currentResult.indexOf("\r\n\r\n"); + if (ind !== -1) { + startOfBody = ind + 4; + } + } + if (startOfBody !== -1 && contentLength !== -1) { + const byteLen = encoder.encode(currentResult).length; + if (byteLen >= contentLength + startOfBody) { + break; + } + } + } + const status = /^HTTP\/1.1 (...)/.exec(currentResult); + let statusCode = 0; + if (status && status[1]) { + statusCode = Number(status[1]); + } + + const body = currentResult.slice(startOfBody); + const headersStr = currentResult.slice(0, startOfBody); + const headersReg = /^(.*): (.*)$/mg; + const headersObj: { [i: string]: string } = {}; + let match = headersReg.exec(headersStr); + while (match !== null) { + if (match[1] && match[2]) { + headersObj[match[1]] = match[2]; + } + match = headersReg.exec(headersStr); + } + return { + status: statusCode, + headers: new Headers(headersObj), + body: body, + }; + } finally { + if (conn) { + Deno.close(conn.rid); + } + } +} + Deno.test( "file_server serveFile", async (): Promise<void> => { @@ -169,6 +242,32 @@ Deno.test("checkPathTraversal", async function (): Promise<void> { } }); +Deno.test("checkPathTraversalNoLeadingSlash", async function (): Promise<void> { + await startFileServer(); + try { + const res = await fetchExactPath("127.0.0.1", 4507, "../../../.."); + assertEquals(res.status, 400); + } finally { + await killFileServer(); + } +}); + +Deno.test("checkPathTraversalAbsoluteURI", async function (): Promise<void> { + await startFileServer(); + try { + //allowed per https://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html + const res = await fetchExactPath( + "127.0.0.1", + 4507, + "http://localhost/../../../..", + ); + assertEquals(res.status, 200); + assertStringIncludes(res.body, "README.md"); + } finally { + await killFileServer(); + } +}); + Deno.test("checkURIEncodedPathTraversal", async function (): Promise<void> { await startFileServer(); try { |