Skip to content

Commit 0600095

Browse files
committed
Fix absolute path traversal bypass with backslash conversion
I also found out we didn't check \t \r \n for absolute paths (if it's an URL object)
1 parent 78c24e6 commit 0600095

File tree

6 files changed

+158
-18
lines changed

6 files changed

+158
-18
lines changed

library/sinks/FileSystem.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,11 +277,46 @@ t.test("it works", async (t) => {
277277
}
278278
);
279279

280+
// same as above but starts with dangerous path
281+
runWithContext(
282+
{
283+
remoteAddress: "::1",
284+
method: "POST",
285+
url: "http://localhost:4000",
286+
query: {
287+
q: "/etc\t/passwd",
288+
},
289+
headers: {},
290+
body: {},
291+
cookies: {},
292+
routeParams: {},
293+
source: "express",
294+
route: "/posts/:id",
295+
},
296+
() => {
297+
throws(
298+
() => rename(new URL("file:///etc\t/passwd")),
299+
"Zen has blocked a path traversal attack: fs.rename(...) originating from query.q"
300+
);
301+
}
302+
);
303+
280304
// Ignores malformed URLs
281305
runWithContext(
282306
{ ...unsafeContext, body: { file: { matches: "../%" } } },
283307
() => {
284308
rename(new URL("file:///../../test.txt"), "../test2.txt", () => {});
285309
}
286310
);
311+
312+
// new URL("file://\\etc/passwd") becomes "file:///etc/passwd"
313+
runWithContext(
314+
{ ...unsafeContext, body: { file: { matches: "\\etc/passwd" } } },
315+
() => {
316+
throws(
317+
() => rename(new URL("file://\\etc/passwd")),
318+
"Zen has blocked a path traversal attack: fs.rename(...) originating from body.file.matches"
319+
);
320+
}
321+
);
287322
});

library/sources/HTTPServer.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,3 +830,89 @@ t.test("it blocks double encoded path traversal", async (t) => {
830830
});
831831
});
832832
});
833+
834+
t.test("it blocks absolute path traversal with tab inside", async (t) => {
835+
const server = http.createServer((req, res) => {
836+
try {
837+
const query = new URL(
838+
req.url!.startsWith("http") ? req.url! : `http://acme.com${req.url!}`
839+
).searchParams;
840+
const file = readFileSync(new URL(`file://${query.get("path")}`));
841+
842+
res.statusCode = 200;
843+
res.end(file);
844+
} catch (error) {
845+
res.statusCode = 500;
846+
if (error instanceof Error) {
847+
res.end(error.message);
848+
return;
849+
}
850+
res.end("Internal server error");
851+
}
852+
});
853+
854+
await new Promise<void>((resolve) => {
855+
server.listen(3328, async () => {
856+
fetch({
857+
url: new URL(
858+
`http://localhost:3328/?path=/etc${encodeURIComponent("\t")}/passwd`
859+
),
860+
method: "GET",
861+
headers: {
862+
"x-forwarded-for": "1.2.3.4",
863+
},
864+
timeoutInMS: 500,
865+
}).then(({ statusCode, body }) => {
866+
t.equal(statusCode, 500);
867+
t.equal(
868+
body,
869+
"Zen has blocked a path traversal attack: fs.readFileSync(...) originating from query.path"
870+
);
871+
server.close();
872+
resolve();
873+
});
874+
});
875+
});
876+
});
877+
878+
t.test("it blocks backslash path traversal", async (t) => {
879+
const server = http.createServer((req, res) => {
880+
try {
881+
const query = new URL(
882+
req.url!.startsWith("http") ? req.url! : `http://acme.com${req.url!}`
883+
).searchParams;
884+
const file = readFileSync(new URL(`file://${query.get("path")}`));
885+
886+
res.statusCode = 200;
887+
res.end(file);
888+
} catch (error) {
889+
res.statusCode = 500;
890+
if (error instanceof Error) {
891+
res.end(error.message);
892+
return;
893+
}
894+
res.end("Internal server error");
895+
}
896+
});
897+
898+
await new Promise<void>((resolve) => {
899+
server.listen(3328, async () => {
900+
fetch({
901+
url: new URL("http://localhost:3328/?path=\\etc/passwd"),
902+
method: "GET",
903+
headers: {
904+
"x-forwarded-for": "1.2.3.4",
905+
},
906+
timeoutInMS: 500,
907+
}).then(({ statusCode, body }) => {
908+
t.equal(statusCode, 500);
909+
t.equal(
910+
body,
911+
"Zen has blocked a path traversal attack: fs.readFileSync(...) originating from query.path"
912+
);
913+
server.close();
914+
resolve();
915+
});
916+
});
917+
});
918+
});
Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { normalizeLikeURLConstructor } from "./normalizeLikeURLConstructor";
2+
13
const dangerousPathParts = ["../", "..\\"];
24

35
export function containsUnsafePathParts(filePath: string) {
@@ -10,16 +12,6 @@ export function containsUnsafePathParts(filePath: string) {
1012
return false;
1113
}
1214

13-
/**
14-
* This function is used for urls, because they can contain a TAB, carriage return or line feed that is silently removed by the URL constructor.
15-
*
16-
* The WHATWG URL spec defines the following:
17-
* - Remove all ASCII tab or newline from input.
18-
* - An ASCII tab or newline is U+0009 TAB, U+000A LF, or U+000D CR.
19-
*
20-
* See https://url.spec.whatwg.org/#url-parsing
21-
*/
2215
export function containsUnsafePathPartsUrl(filePath: string) {
23-
const normalized = filePath.replace(/[\t\n\r]/g, "");
24-
return containsUnsafePathParts(normalized);
16+
return containsUnsafePathParts(normalizeLikeURLConstructor(filePath));
2517
}

library/vulnerabilities/path-traversal/detectPathTraversal.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import {
22
containsUnsafePathParts,
33
containsUnsafePathPartsUrl,
44
} from "./containsUnsafePathParts";
5-
import { startsWithUnsafePath } from "./unsafePathStart";
5+
import {
6+
startsWithUnsafePath,
7+
startsWithUnsafePathUrl,
8+
} from "./unsafePathStart";
69
import { fileURLToPath } from "url";
710

811
export function detectPathTraversal(
@@ -19,13 +22,18 @@ export function detectPathTraversal(
1922
// Check for URL path traversal
2023
// Reason: new URL("file:///../../test.txt") => /test.txt
2124
// The normal check for relative path traversal will fail in this case, because transformed path does not contain ../.
22-
// For absolute path traversal, we dont need to check the transformed path, because it will always start with /.
2325
// Also /./ is checked by normal absolute path traversal check (if #219 is merged)
2426
// Use containsUnsafePathPartsUrl, because urls can contain a TAB, carriage return or line feed that is silently removed by the URL constructor.
25-
if (isUrl && containsUnsafePathPartsUrl(userInput)) {
26-
const filePathFromUrl = parseAsFileUrl(userInput);
27-
if (filePathFromUrl && filePath.includes(filePathFromUrl)) {
28-
return true;
27+
// Use startsWithUnsafePathUrl, because URLs can contain backward slashes that are converted to forward slashes by the URL constructor.
28+
if (isUrl) {
29+
const containsUnsafePath = containsUnsafePathPartsUrl(userInput);
30+
const startWithUnsafePath = startsWithUnsafePathUrl(filePath, userInput);
31+
32+
if (containsUnsafePath || startWithUnsafePath) {
33+
const filePathFromUrl = parseAsFileUrl(userInput);
34+
if (filePathFromUrl && filePath.includes(filePathFromUrl)) {
35+
return true;
36+
}
2937
}
3038
}
3139

@@ -63,7 +71,7 @@ export function detectPathTraversal(
6371
function parseAsFileUrl(path: string) {
6472
let url = path;
6573
if (!url.startsWith("file:")) {
66-
if (!url.startsWith("/")) {
74+
if (!url.startsWith("/") && !url.startsWith("\\")) {
6775
url = `/${url}`;
6876
}
6977
url = `file://${url}`;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* This function is used for urls, because they can contain a TAB, carriage return or line feed that is silently removed by the URL constructor.
3+
*
4+
* The WHATWG URL spec defines the following:
5+
* - Remove all ASCII tab or newline from input.
6+
* - An ASCII tab or newline is U+0009 TAB, U+000A LF, or U+000D CR.
7+
*
8+
* Also, backward slashes are converted to forward slashes by the URL constructor.
9+
*
10+
* See https://url.spec.whatwg.org/#url-parsing
11+
*/
12+
export function normalizeLikeURLConstructor(url: string): string {
13+
return url.replace(/[\t\n\r]/g, "").replace(/\\/g, "/");
14+
}

library/vulnerabilities/path-traversal/unsafePathStart.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { isAbsolute, resolve } from "path";
22
import { isWrapped } from "../../helpers/wrap";
3+
import { normalizeLikeURLConstructor } from "./normalizeLikeURLConstructor";
34

45
const linuxRootFolders = [
56
"/bin/",
@@ -57,3 +58,7 @@ export function startsWithUnsafePath(filePath: string, userInput: string) {
5758
}
5859
return false;
5960
}
61+
62+
export function startsWithUnsafePathUrl(filePath: string, userInput: string) {
63+
return startsWithUnsafePath(filePath, normalizeLikeURLConstructor(userInput));
64+
}

0 commit comments

Comments
 (0)