Skip to content

Commit 03f6397

Browse files
tballisonCopilot
andcommitted
improve isatab parsing (#2875)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> # Conflicts: # tika-core/src/main/java/org/apache/tika/io/FilenameUtils.java # tika-core/src/test/java/org/apache/tika/io/FilenameUtilsTest.java
1 parent fce8805 commit 03f6397

4 files changed

Lines changed: 111 additions & 3 deletions

File tree

tika-core/src/main/java/org/apache/tika/io/FilenameUtils.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
*/
1717
package org.apache.tika.io;
1818

19+
import java.io.IOException;
20+
import java.nio.file.Path;
1921
import java.util.HashSet;
2022
import java.util.Locale;
2123
import java.util.regex.Pattern;
@@ -130,4 +132,34 @@ public static String getSuffixFromPath(String path) {
130132
}
131133
return StringUtils.EMPTY;
132134
}
135+
136+
/**
137+
* Resolves {@code name} under {@code dir}, rejecting anything that escapes it (zip-slip
138+
* guard). Normalizes first so {@code ..} is collapsed before the element-wise containment
139+
* check.
140+
*
141+
* @throws IOException if {@code name} resolves outside {@code dir}
142+
*/
143+
public static Path resolveWithin(Path dir, String name) throws IOException {
144+
Path normalizedDir = dir.normalize();
145+
Path resolved = normalizedDir.resolve(name).normalize();
146+
if (!resolved.startsWith(normalizedDir)) {
147+
throw new IOException(
148+
"'" + name + "' resolves to '" + resolved + "', which is outside of '" +
149+
normalizedDir + "'");
150+
}
151+
152+
// Defense in depth against symlink traversal (only possible if the paths exist).
153+
if (java.nio.file.Files.exists(resolved) && java.nio.file.Files.exists(normalizedDir)) {
154+
Path realDir = normalizedDir.toRealPath();
155+
Path realResolved = resolved.toRealPath();
156+
if (!realResolved.startsWith(realDir)) {
157+
throw new IOException(
158+
"'" + name + "' resolves to '" + realResolved + "', which is outside of '" +
159+
realDir + "'");
160+
}
161+
}
162+
163+
return resolved;
164+
}
133165
}

tika-core/src/test/java/org/apache/tika/io/FilenameUtilsTest.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@
1717
package org.apache.tika.io;
1818

1919
import static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertThrows;
2021
import static org.junit.jupiter.api.Assertions.assertTrue;
2122
import static org.junit.jupiter.api.Assertions.fail;
2223

24+
import java.io.IOException;
25+
import java.nio.file.Path;
26+
import java.nio.file.Paths;
27+
2328
import org.junit.jupiter.api.Test;
2429

2530
import org.apache.tika.utils.StringUtils;
@@ -102,6 +107,32 @@ public void testGetName() throws Exception {
102107
testFilenameEquality("HW.txt", "_1457338542/HW.txt");
103108
}
104109

110+
@Test
111+
public void testResolveWithin() throws Exception {
112+
Path dir = Paths.get("base", "isa");
113+
114+
// plain and nested children resolve to a path under dir
115+
assertEquals(Paths.get("base", "isa", "a_assay.txt"),
116+
FilenameUtils.resolveWithin(dir, "a_assay.txt"));
117+
assertEquals(Paths.get("base", "isa", "sub", "a_assay.txt"),
118+
FilenameUtils.resolveWithin(dir, "sub/a_assay.txt"));
119+
120+
// resolving the directory itself stays within it
121+
assertEquals(dir.normalize(), FilenameUtils.resolveWithin(dir, "."));
122+
123+
// names that resolve out of dir are rejected. These also pin the normalize() call:
124+
// without it the resolved path would still textually begin with dir and be accepted.
125+
assertThrows(IOException.class, () -> FilenameUtils.resolveWithin(dir, "../outside.txt"));
126+
assertThrows(IOException.class, () -> FilenameUtils.resolveWithin(dir, "../../outside.txt"));
127+
assertThrows(IOException.class,
128+
() -> FilenameUtils.resolveWithin(dir, "sub/../../outside.txt"));
129+
130+
// element-wise (not string-prefix) containment: a/bc is not within a/b even though
131+
// the string "a/bc" starts with the string "a/b".
132+
assertThrows(IOException.class,
133+
() -> FilenameUtils.resolveWithin(Paths.get("a", "b"), "../bc"));
134+
}
135+
105136
@Test
106137
public void testExtension() throws Exception {
107138
assertEquals(".pdf", FilenameUtils.getSuffixFromPath("blah/blah/or/something.pdf"));

tika-parsers/tika-parsers-extended/tika-parser-scientific-module/src/main/java/org/apache/tika/parser/isatab/ISArchiveParser.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@
1919
import java.io.File;
2020
import java.io.IOException;
2121
import java.io.InputStream;
22+
import java.nio.file.Path;
2223
import java.util.Collections;
2324
import java.util.Set;
2425

2526
import org.xml.sax.ContentHandler;
2627
import org.xml.sax.SAXException;
2728

2829
import org.apache.tika.exception.TikaException;
30+
import org.apache.tika.io.FilenameUtils;
2931
import org.apache.tika.io.TemporaryResources;
3032
import org.apache.tika.io.TikaInputStream;
3133
import org.apache.tika.metadata.Metadata;
@@ -131,12 +133,15 @@ private void parseStudy(InputStream stream, XHTMLContentHandler xhtml, Metadata
131133

132134
private void parseAssay(XHTMLContentHandler xhtml, Metadata metadata, ParseContext context)
133135
throws IOException, SAXException, TikaException {
136+
// location starts with "/C:" on windows, so build the directory Path from a File
137+
// rather than Paths.get(). The assay file names come from the investigation file and
138+
// are resolved within this directory.
139+
Path locationDir = new File(this.location).toPath();
134140
for (String assayFileName : metadata.getValues(studyAssayFileNameField)) {
135141
xhtml.startElement("div");
136142
xhtml.element("h3", "ASSAY " + assayFileName);
137-
// location starts with "/C:" on windows, can't use Paths.get()
138-
try (InputStream stream = TikaInputStream.get(new File(this.location + assayFileName).toPath()))
139-
{
143+
Path assayFile = FilenameUtils.resolveWithin(locationDir, assayFileName);
144+
try (InputStream stream = TikaInputStream.get(assayFile)) {
140145
ISATabUtils.parseAssay(stream, xhtml, metadata, context);
141146
}
142147
xhtml.endElement("div");

tika-parsers/tika-parsers-extended/tika-parser-scientific-module/src/test/java/org/apache/tika/parser/isatab/ISArchiveParserTest.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,22 @@
1717
package org.apache.tika.parser.isatab;
1818

1919
import static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertFalse;
21+
import static org.junit.jupiter.api.Assertions.assertThrows;
2022

23+
import java.io.IOException;
2124
import java.io.InputStream;
25+
import java.nio.charset.StandardCharsets;
26+
import java.nio.file.Files;
27+
import java.nio.file.Path;
2228

2329
import org.junit.jupiter.api.Test;
30+
import org.junit.jupiter.api.io.TempDir;
2431
import org.xml.sax.ContentHandler;
2532

33+
import org.apache.tika.io.TikaInputStream;
2634
import org.apache.tika.metadata.Metadata;
35+
import org.apache.tika.metadata.TikaCoreProperties;
2736
import org.apache.tika.parser.ParseContext;
2837
import org.apache.tika.parser.Parser;
2938
import org.apache.tika.sax.BodyContentHandler;
@@ -66,4 +75,35 @@ public void testParseArchive() throws Exception {
6675
assertEquals("Stephen", metadata.get("Investigation Person First Name"),
6776
"Invalid Investigation Person First Name");
6877
}
78+
79+
@Test
80+
public void testAssayFileNameResolvedWithinDirectory(@TempDir Path root) throws Exception {
81+
// A file sitting next to (but outside of) the ISA-Tab directory.
82+
Path outside = root.resolve("outside.txt");
83+
Files.write(outside, "OUTSIDE_DIRECTORY_CONTENT".getBytes(StandardCharsets.UTF_8));
84+
85+
// An ISA-Tab directory whose investigation file points an assay at the sibling file
86+
// using a relative name.
87+
Path isaDir = Files.createDirectory(root.resolve("isa"));
88+
Files.write(isaDir.resolve("i_test.txt"),
89+
("STUDY\n"
90+
+ "Study File Name\t\"s_test.txt\"\n"
91+
+ "Study Assay File Name\t\"../outside.txt\"\n")
92+
.getBytes(StandardCharsets.UTF_8));
93+
Path study = isaDir.resolve("s_test.txt");
94+
Files.write(study, "\"Source Name\"\n\"culture1\"\n".getBytes(StandardCharsets.UTF_8));
95+
96+
Parser parser = new ISArchiveParser(isaDir.toString());
97+
ContentHandler handler = new BodyContentHandler();
98+
Metadata metadata = new Metadata();
99+
metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, "s_test.txt");
100+
ParseContext context = new ParseContext();
101+
102+
try (TikaInputStream tis = TikaInputStream.get(study)) {
103+
assertThrows(IOException.class,
104+
() -> parser.parse(tis, handler, metadata, context));
105+
}
106+
assertFalse(handler.toString().contains("OUTSIDE_DIRECTORY_CONTENT"),
107+
"assay reader read a file outside the ISA-Tab directory");
108+
}
69109
}

0 commit comments

Comments
 (0)