Skip to content

Commit bc26506

Browse files
authored
TEZ-4699: Add Validation/Canonical checks to avoid path exploitation in CSVResult.java (#471) (Ramit Gupta reviewed by Laszlo Bodor)
1 parent fd03af6 commit bc26506

2 files changed

Lines changed: 189 additions & 28 deletions

File tree

tez-tools/analyzers/job-analyzer/src/main/java/org/apache/tez/analyzer/CSVResult.java

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020

2121
import java.io.BufferedWriter;
2222
import java.io.File;
23-
import java.io.FileOutputStream;
2423
import java.io.IOException;
2524
import java.io.OutputStreamWriter;
26-
import java.nio.charset.Charset;
25+
import java.nio.charset.StandardCharsets;
26+
import java.nio.file.Files;
27+
import java.nio.file.Path;
28+
import java.nio.file.Paths;
29+
import java.nio.file.StandardOpenOption;
2730
import java.util.Arrays;
2831
import java.util.Collections;
2932
import java.util.Comparator;
@@ -69,7 +72,7 @@ public Iterator<String[]> getRecordsIterator() {
6972
return Iterators.unmodifiableIterator(recordsList.iterator());
7073
}
7174

72-
@SuppressWarnings({ "rawtypes", "unchecked" })
75+
@SuppressWarnings({"rawtypes", "unchecked"})
7376
public void sort(Comparator comparator) {
7477
Collections.sort(recordsList, comparator);
7578
}
@@ -78,46 +81,68 @@ public void setComments(String comments) {
7881
this.comments = comments;
7982
}
8083

81-
@Override public String toJson() throws TezException {
84+
@Override
85+
public String toJson() throws TezException {
8286
return "";
8387
}
8488

85-
@Override public String getComments() {
89+
@Override
90+
public String getComments() {
8691
return comments;
8792
}
8893

89-
@Override public String toString() {
94+
@Override
95+
public String toString() {
9096
return "CSVResult{" +
9197
"headers=" + Arrays.toString(headers) +
9298
", recordsList=" + recordsList +
9399
'}';
94100
}
95101

96-
//For testing
97102
public void dumpToFile(String fileName) throws IOException {
98-
OutputStreamWriter writer = new OutputStreamWriter(
99-
new FileOutputStream(new File(fileName)),
100-
Charset.forName("UTF-8").newEncoder());
101-
BufferedWriter bw = new BufferedWriter(writer);
102-
bw.write(Joiner.on(",").join(headers));
103-
bw.newLine();
104-
for (String[] record : recordsList) {
105-
106-
if (record.length != headers.length) {
107-
continue; //LOG error msg?
108-
}
109-
110-
StringBuilder sb = new StringBuilder();
111-
for(int i=0;i<record.length;i++) {
112-
sb.append(!Strings.isNullOrEmpty(record[i]) ? record[i] : " ");
113-
if (i < record.length - 1) {
114-
sb.append(",");
103+
File target = validateOutputFile(fileName);
104+
Path targetPath = target.toPath();
105+
106+
try (BufferedWriter bw = new BufferedWriter(
107+
new OutputStreamWriter(
108+
Files.newOutputStream(targetPath, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE),
109+
StandardCharsets.UTF_8))) {
110+
bw.write(Joiner.on(",").join(headers));
111+
bw.newLine();
112+
for (String[] record : recordsList) {
113+
if (record.length != headers.length) {
114+
continue;
115+
}
116+
StringBuilder sb = new StringBuilder();
117+
for (int i = 0; i < record.length; i++) {
118+
sb.append(!Strings.isNullOrEmpty(record[i]) ? record[i] : " ");
119+
if (i < record.length - 1) {
120+
sb.append(",");
121+
}
115122
}
123+
bw.write(sb.toString());
124+
bw.newLine();
116125
}
117-
bw.write(sb.toString());
118-
bw.newLine();
119126
}
120-
bw.flush();
121-
bw.close();
127+
}
128+
129+
private static File validateOutputFile(String fileName) throws IOException {
130+
if (fileName == null || fileName.trim().isEmpty()) {
131+
throw new IllegalArgumentException("File name must not be null or empty");
132+
}
133+
134+
Path baseDir = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
135+
Path targetPath = Paths.get(fileName).toAbsolutePath().normalize();
136+
137+
if (!targetPath.startsWith(baseDir)) {
138+
throw new IOException("Path escapes the allowed base directory. Path: " + targetPath + ", Base: " + baseDir);
139+
}
140+
141+
Path parent = targetPath.getParent();
142+
if (parent == null || !Files.isDirectory(parent)) {
143+
throw new IOException("Parent directory does not exist: " + parent);
144+
}
145+
146+
return targetPath.toFile();
122147
}
123148
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
* <p/>
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
* <p/>
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package org.apache.tez.analyzer;
20+
21+
import java.io.IOException;
22+
import java.nio.charset.StandardCharsets;
23+
import java.nio.file.FileAlreadyExistsException;
24+
import java.nio.file.Files;
25+
import java.nio.file.Path;
26+
import java.nio.file.Paths;
27+
28+
import org.junit.After;
29+
import org.junit.Assert;
30+
import org.junit.Before;
31+
import org.junit.Test;
32+
33+
public class TestCSVResult {
34+
35+
private Path testDir;
36+
37+
@Before
38+
public void setUp() throws IOException {
39+
testDir = Paths.get(System.getProperty("user.dir") + "/test");
40+
Files.createDirectories(testDir);
41+
}
42+
43+
@After
44+
public void tearDown() throws IOException {
45+
if (Files.exists(testDir)) {
46+
Files.walk(testDir)
47+
.sorted(java.util.Comparator.reverseOrder())
48+
.forEach(path -> path.toFile().delete());
49+
}
50+
}
51+
52+
@Test
53+
public void testDumpToFileWritesContent() throws Exception {
54+
Path dir = Files.createDirectories(testDir.resolve("test1"));
55+
Path out = dir.resolve("out.csv");
56+
57+
CSVResult result = new CSVResult(new String[]{"h1", "h2"});
58+
result.addRecord(new String[]{"a", "b"});
59+
result.dumpToFile(out.toString());
60+
61+
String content = Files.readString(out, StandardCharsets.UTF_8);
62+
Assert.assertEquals("h1,h2\na,b\n", content);
63+
}
64+
65+
@Test
66+
public void testDumpToFileRejectsExistingFile() throws Exception {
67+
Path out = testDir.resolve("existing.csv");
68+
Files.createFile(out);
69+
70+
CSVResult result = new CSVResult(new String[]{"x"});
71+
72+
try {
73+
result.dumpToFile(out.toString());
74+
Assert.fail("Expected FileAlreadyExistsException when output file already exists");
75+
} catch (FileAlreadyExistsException e) {
76+
Assert.assertTrue(e.getMessage().contains(out.toString()));
77+
}
78+
}
79+
80+
@Test
81+
public void testDumpToFileRejectsMissingParentDir() throws Exception {
82+
Path missingParent = testDir.resolve("no-such-dir");
83+
Path out = missingParent.resolve("out.csv");
84+
85+
CSVResult result = new CSVResult(new String[]{"x"});
86+
87+
try {
88+
result.dumpToFile(out.toString());
89+
Assert.fail("Expected IOException when parent directory does not exist");
90+
} catch (IOException e) {
91+
Assert.assertTrue(e.getMessage().contains("Parent directory does not exist"));
92+
}
93+
}
94+
95+
@Test(expected = IllegalArgumentException.class)
96+
public void testDumpToFileRejectsNullFileName() throws Exception {
97+
new CSVResult(new String[]{"x"}).dumpToFile(null);
98+
}
99+
100+
@Test(expected = IllegalArgumentException.class)
101+
public void testDumpToFileRejectsEmptyFileName() throws Exception {
102+
new CSVResult(new String[]{"x"}).dumpToFile("");
103+
}
104+
105+
@Test(expected = IllegalArgumentException.class)
106+
public void testDumpToFileRejectsBlankFileName() throws Exception {
107+
new CSVResult(new String[]{"x"}).dumpToFile(" ");
108+
}
109+
110+
@Test
111+
public void testDumpToFileNestedDirectory() throws Exception {
112+
Path nested = testDir.resolve("a").resolve("b");
113+
Files.createDirectories(nested);
114+
115+
Path out = nested.resolve("nested.csv");
116+
117+
CSVResult result = new CSVResult(new String[]{"h"});
118+
result.addRecord(new String[]{"r"});
119+
result.dumpToFile(out.toString());
120+
121+
Assert.assertEquals("h\nr\n", Files.readString(out, StandardCharsets.UTF_8));
122+
}
123+
124+
@Test
125+
public void testDumpToFileNoWriteUpwardDirPath() throws Exception {
126+
String relativePath = testDir + "/../../out.csv";
127+
CSVResult result = new CSVResult(new String[]{"h"});
128+
129+
try {
130+
result.dumpToFile(relativePath);
131+
Assert.fail("Expected IOException due to moving upwards from pwd");
132+
} catch (IOException e) {
133+
Assert.assertTrue(e.getMessage().contains(Paths.get(relativePath).toAbsolutePath().normalize().toString()));
134+
}
135+
}
136+
}

0 commit comments

Comments
 (0)