Skip to content

Commit

Permalink
fix(std/http): prevent path traversal (denoland#8474)
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
sarahdenofiletrav authored Nov 26, 2020
1 parent 4f46dc9 commit 28869a6
Show file tree
Hide file tree
Showing 2 changed files with 127 additions and 6 deletions.
32 changes: 27 additions & 5 deletions std/http/file_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
101 changes: 100 additions & 1 deletion std/http/file_server_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }>;
Expand Down Expand Up @@ -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> => {
Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit 28869a6

Please sign in to comment.