Skip to content

Commit a2bdf90

Browse files
aschemanclaude
andcommitted
#405 Improve path security and error handling
Address GitHub Copilot code review suggestions: 1. Enhanced path traversal security - Replace string-based startsWith() check with NIO Path API - Use Path.normalize() for more robust path validation - Prevent sophisticated path traversal attacks 2. Improved error diagnostics - Add detailed context to directory creation failures - Include directory existence status and parent permissions - Make debugging filesystem issues easier 3. Comprehensive test coverage - Add testPathTraversalAttackIsBlocked - Add testPathTraversalWithSymlinkStyleAttackIsBlocked - Add testEnhancedErrorMessageWhenDirectoryCreationFails - Add testEnhancedErrorMessageFormatIsCorrect - All 24 tests pass successfully 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent fba7f0f commit a2bdf90

2 files changed

Lines changed: 155 additions & 3 deletions

File tree

htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import java.io.File;
1313
import java.io.FileWriter;
1414
import java.io.IOException;
15+
import java.nio.file.Path;
1516
import java.util.UUID;
1617

1718
/************************************************************************
@@ -147,8 +148,12 @@ private File getHierarchicalOutputFile(String name) {
147148
File tempPath = new File(outputPath, parentDir.getPath());
148149
testOutputDir = tempPath.getCanonicalFile();
149150

150-
// Verify the canonical path is still under outputPath
151-
if (!testOutputDir.getAbsolutePath().startsWith(outputPath.getCanonicalPath())) {
151+
// Verify the canonical path is still under outputPath using NIO Path API
152+
// This provides better security against path traversal attacks
153+
Path normalizedOutputPath = outputPath.getCanonicalFile().toPath().normalize();
154+
Path normalizedTestOutputDir = testOutputDir.toPath().normalize();
155+
156+
if (!normalizedTestOutputDir.startsWith(normalizedOutputPath)) {
152157
// Path tries to escape outputPath, so just use outputPath directly
153158
testOutputDir = outputPath;
154159
}
@@ -162,7 +167,13 @@ private File getHierarchicalOutputFile(String name) {
162167

163168
// Ensure the directory exists
164169
if (!testOutputDir.exists() && !testOutputDir.mkdirs()) {
165-
throw new RuntimeException("Cannot create directory " + testOutputDir); //NOSONAR(S112)
170+
StringBuilder errorMsg = new StringBuilder("Cannot create directory: ")
171+
.append(testOutputDir.getAbsolutePath());
172+
errorMsg.append(" (exists: ").append(testOutputDir.exists())
173+
.append(", parent canWrite: ")
174+
.append(testOutputDir.getParentFile() != null ? testOutputDir.getParentFile().canWrite() : "unknown")
175+
.append(")");
176+
throw new RuntimeException(errorMsg.toString()); //NOSONAR(S112)
166177
}
167178

168179
// Create the test file with a simple, sanitized filename

htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import org.junit.Before
1010
import org.junit.Test
1111

1212
import static org.junit.Assert.assertEquals
13+
import static org.junit.Assert.assertNotNull
1314
import static org.junit.Assert.assertTrue
1415

1516
// see end-of-file for license information
@@ -521,4 +522,144 @@ class JUnitXmlReporterTest {
521522
File[] allFiles = outputPath.listFiles()
522523
assertTrue("Should have created at least one file or directory", allFiles != null && allFiles.length > 0)
523524
}
525+
526+
@Test
527+
void testPathTraversalAttackIsBlocked() {
528+
// Given: a malicious path trying to escape the output directory
529+
// This simulates a path traversal attack like "../../../etc/passwd"
530+
SinglePageResults maliciousPage = new SinglePageResults(
531+
"index.html",
532+
"../../../malicious/path/index.html",
533+
"Malicious Page",
534+
1000,
535+
new ArrayList<>())
536+
PerRunResults runResults = new PerRunResults()
537+
runResults.addPageResults(maliciousPage)
538+
539+
// When: we generate the report in HIERARCHICAL mode
540+
new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL)
541+
.reportPageSummary(maliciousPage)
542+
543+
// Then: the file should be created safely within outputPath, not outside it
544+
File[] allFiles = outputPath.listFiles()
545+
assertTrue("Should have created file or directory", allFiles != null && allFiles.length > 0)
546+
547+
// Verify no files were created outside outputPath
548+
def outputPathCanonical = outputPath.canonicalPath
549+
def createdFile = findFirstXmlFile(outputPath)
550+
assertNotNull(createdFile)
551+
552+
// The created file should be within outputPath
553+
assertTrue("File should be within output directory",
554+
createdFile.canonicalPath.startsWith(outputPathCanonical))
555+
}
556+
557+
@Test
558+
void testPathTraversalWithSymlinkStyleAttackIsBlocked() {
559+
// Given: a more sophisticated path traversal attack that tries to bypass simple checks
560+
// Example: "validdir/../../escape/test.html" which could bypass startsWith() on strings
561+
SinglePageResults sophisticatedAttack = new SinglePageResults(
562+
"test.html",
563+
"valid/../../../escape/test.html",
564+
"Sophisticated Attack",
565+
1000,
566+
new ArrayList<>())
567+
PerRunResults runResults = new PerRunResults()
568+
runResults.addPageResults(sophisticatedAttack)
569+
570+
// When: we generate the report in HIERARCHICAL mode
571+
new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL)
572+
.reportPageSummary(sophisticatedAttack)
573+
574+
// Then: verify the file is safely contained
575+
def outputPathCanonical = outputPath.canonicalPath
576+
def createdFile = findFirstXmlFile(outputPath)
577+
assertNotNull(createdFile)
578+
579+
// Use NIO Path API to verify containment (same method as production code)
580+
def normalizedOutputPath = outputPath.canonicalFile.toPath().normalize()
581+
def normalizedCreatedPath = createdFile.canonicalFile.toPath().normalize()
582+
583+
assertTrue("File should be within output directory using NIO Path API",
584+
normalizedCreatedPath.startsWith(normalizedOutputPath))
585+
}
586+
587+
@Test
588+
void testEnhancedErrorMessageWhenDirectoryCreationFails() {
589+
// Given: a non-existent parent directory that cannot be created
590+
// We'll use a path that's invalid on the filesystem
591+
File invalidOutputPath = new File("/nonexistent/deeply/nested/path/that/cannot/be/created")
592+
593+
SinglePageResults page = new SinglePageResults(
594+
"test.html",
595+
"some/deep/path/test.html",
596+
"Test Page",
597+
1000,
598+
new ArrayList<>())
599+
PerRunResults runResults = new PerRunResults()
600+
runResults.addPageResults(page)
601+
602+
// When/Then: directory creation should fail with enhanced error message
603+
try {
604+
new JUnitXmlReporter(runResults, invalidOutputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL)
605+
.reportPageSummary(page)
606+
fail("Should have thrown RuntimeException for directory creation failure")
607+
} catch (RuntimeException e) {
608+
// Verify the error message contains diagnostic information
609+
String errorMsg = e.message
610+
assertTrue("Error message should mention 'Cannot create directory'",
611+
errorMsg.contains("Cannot create directory"))
612+
assertTrue("Error message should contain full path",
613+
errorMsg.contains(invalidOutputPath.absolutePath))
614+
assertTrue("Error message should include 'exists:' diagnostic",
615+
errorMsg.contains("exists:"))
616+
assertTrue("Error message should include 'parent canWrite:' diagnostic",
617+
errorMsg.contains("parent canWrite:"))
618+
}
619+
}
620+
621+
@Test
622+
void testEnhancedErrorMessageFormatIsCorrect() {
623+
// Given: setup that will trigger directory creation failure
624+
File readOnlyParent = new File(outputPath, "readonly-parent")
625+
readOnlyParent.mkdirs()
626+
627+
// Try to make it read-only (this may not work on all platforms, especially Windows)
628+
boolean madeReadOnly = readOnlyParent.setReadOnly()
629+
630+
if (!madeReadOnly || readOnlyParent.canWrite()) {
631+
// Skip test if we cannot make directory read-only on this platform
632+
System.err.println("Skipping testEnhancedErrorMessageFormatIsCorrect - cannot make directory read-only on this platform")
633+
return
634+
}
635+
636+
try {
637+
SinglePageResults page = new SinglePageResults(
638+
"test.html",
639+
"readonly-parent/subdir/test.html",
640+
"Test Page",
641+
1000,
642+
new ArrayList<>())
643+
PerRunResults runResults = new PerRunResults()
644+
runResults.addPageResults(page)
645+
646+
// When: attempting to create subdirectory in read-only parent
647+
new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL)
648+
.reportPageSummary(page)
649+
fail("Should have thrown RuntimeException")
650+
} catch (RuntimeException e) {
651+
// Then: error message should have proper format with parentheses
652+
String errorMsg = e.message
653+
assertTrue("Error message should contain opening parenthesis",
654+
errorMsg.contains("("))
655+
assertTrue("Error message should contain closing parenthesis",
656+
errorMsg.contains(")"))
657+
// Should have format like: "... (exists: true, parent canWrite: false)"
658+
assertTrue("Error message should match expected format pattern",
659+
errorMsg.matches(".*\\(exists: .*, parent canWrite: .*\\).*"))
660+
} finally {
661+
// Cleanup: restore write permission
662+
readOnlyParent.setWritable(true)
663+
}
664+
}
524665
}

0 commit comments

Comments
 (0)