Skip to content

fix: prevent path traversal in markdown exporter#7217

Closed
sandiyochristan wants to merge 2 commits intoprojectdiscovery:devfrom
sandiyochristan:fix/path-traversal-in-markdown-exporter
Closed

fix: prevent path traversal in markdown exporter#7217
sandiyochristan wants to merge 2 commits intoprojectdiscovery:devfrom
sandiyochristan:fix/path-traversal-in-markdown-exporter

Conversation

@sandiyochristan
Copy link
Contributor

@sandiyochristan sandiyochristan commented Mar 14, 2026

Proposed Changes

The markdown report exporter is vulnerable to path traversal (CWE-22) when the sort mode is set to host or template. An attacker-controlled Host header or crafted TemplateID containing .. sequences can cause report files to be written outside the intended export directory.

Root Cause

The sanitizeFilename() function in pkg/reporting/exporters/markdown/markdown.go replaces dangerous characters (/, ?, *, etc.) with _, but does not handle .. (dot-dot) directory traversal sequences.

When sort mode is host, the event.Host value is used as a subdirectory:

case "host":
    subdirectory = event.Host  // attacker-controlled via HTTP Host header

After sanitization, .. passes through unchanged. Then filepath.Join(exporter.directory, "..") resolves to the parent of the export directory, enabling arbitrary file writes.

Exploitation Scenario

  1. Attacker controls a web server that nuclei scans
  2. The server returns a crafted Host header value: ../../../tmp/malicious
  3. The / characters are replaced with _ by sanitizeFilename → _.._.._.._tmp_malicious
  4. But if the host is simply .. — it passes through completely unchanged
  5. filepath.Join("/reports", "..", "evil.md")/evil.md — file written outside reports directory

The Fix (Defense in Depth — Two Layers)

Layer 1: SanitizationsanitizeFilename() now replaces .. with _ before other character replacements:

func sanitizeFilename(filename string) string {
    if len(filename) > 256 {
        filename = filename[0:255]
    }
    // Replace directory traversal sequences before other sanitization
    filename = strings.ReplaceAll(filename, "..", "_")
    return stringsutil.ReplaceAll(filename, "_", "?", "/", ">", "|", ":", ";", "*", "<", "\"", "'", " ")
}

Layer 2: Path Validation — Added filepath.Clean() + strings.HasPrefix() checks before both directory creation and file writing, ensuring the resolved path always stays within exporter.directory. This is the same validated pattern used in pkg/installer/template.go (the zip extraction path traversal guard).

Files Changed

  • pkg/reporting/exporters/markdown/markdown.go — added .. replacement in sanitizeFilename, added path prefix validation before directory creation and file write

Security Impact

  • CWE-22: Improper Limitation of a Pathname to a Restricted Directory (Path Traversal)
  • Severity: High
  • Attack Vector: Malicious target server returns crafted Host header → nuclei writes markdown report files to arbitrary filesystem locations
  • Impact: Arbitrary file write on the machine running nuclei

Proof

The fix follows the exact same defensive pattern already established in the codebase:

// pkg/installer/template.go lines 278-282 (existing protection)
newPath := filepath.Clean(filepath.Join(templateDir, relPath))
if !strings.HasPrefix(newPath, templateDir) {
    return ""  // reject path traversal
}

Checklist

  • PR created against dev branch
  • All checks passed (lint, unit/integration/regression tests)
  • Minimal, focused change — only the markdown exporter is modified
  • Defense in depth — both sanitization and validation layers added
  • Follows existing codebase patterns for path traversal prevention

Summary by CodeRabbit

  • Bug Fixes
    • Strengthened path validation throughout the export process to block directory traversal and ensure exported files stay inside the intended output folder.
    • Added pre-sanitization of filenames to neutralize traversal patterns and improved error reporting when invalid paths are detected.

…sal sequences

The sanitizeFilename function did not handle ".." sequences, allowing
path traversal when event.Host or event.TemplateID contains dot-dot
patterns. Added ".." replacement in sanitization and path prefix
validation to ensure exported files stay within the intended directory.
@auto-assign auto-assign bot requested a review from Mzack9999 March 14, 2026 21:01
@neo-by-projectdiscovery-dev
Copy link

neo-by-projectdiscovery-dev bot commented Mar 14, 2026

Neo - PR Security Review

No security issues found

Highlights

  • Implements two-layer defense: sanitization (removes .. sequences) + path validation (prefix check with separator)
  • Uses separator suffix pattern (cleanBase + filepath.Separator) which prevents sibling directory bypass — an improvement over the reference implementation in pkg/installer/template.go
  • Defense-in-depth: validates path before directory creation (line 92-96) AND before file write (line 123-127)
  • Verified other exporters (sarif, json, jsonl) use single file paths without subdirectory feature — no similar vulnerability exists

Comment @pdneo help for available commands. · Open in Neo

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 14, 2026

Walkthrough

Validate resolved subdirectory and output file paths against the configured export directory to prevent path traversal in Markdown export flow; sanitize filenames by replacing suspicious ".." sequences before further processing.

Changes

Cohort / File(s) Summary
Path Traversal Protection
pkg/reporting/exporters/markdown/markdown.go
Added resolved-path validation against the export directory before building file URLs and before writing files; added pre-sanitization to replace .. with _; imported fmt for error messages.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 In tunnels lined with code I hop and keep watch bright,
I swap each ".." for a dash and lock the burrow tight.
Paths now checked and tidy, exports snug for the night. ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: prevent path traversal in markdown exporter' directly and clearly summarizes the main security fix addressing a CWE-22 vulnerability.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

…ectory bypass

The strings.HasPrefix check without a trailing separator allows bypass
via sibling directories with matching prefixes (e.g., /reports-evil
matching /reports). Appending filepath.Separator ensures only true
subdirectories pass validation.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/reporting/exporters/markdown/markdown.go`:
- Around line 90-94: Replace the current prefix-based containment checks with a
filepath.Rel-based check: compute rel, err :=
filepath.Rel(filepath.Clean(exporter.directory),
filepath.Clean(filepath.Join(exporter.directory, subdirectory))) and return an
error if err != nil or strings.HasPrefix(rel, "..") to robustly detect path
traversal; apply the same change wherever the code currently validates
resolvedDir against exporter.directory (references: resolvedDir,
exporter.directory, subdirectory) including the other occurrence around the
123-127 region so both validations use filepath.Rel.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a460c7ba-9ee7-4fa3-82fb-4a89a5f4ed54

📥 Commits

Reviewing files that changed from the base of the PR and between 979c867 and 6820f81.

📒 Files selected for processing (1)
  • pkg/reporting/exporters/markdown/markdown.go

Comment on lines +90 to +94
// Validate the resolved path stays within the export directory
resolvedDir := filepath.Clean(filepath.Join(exporter.directory, subdirectory))
if !strings.HasPrefix(resolvedDir, filepath.Clean(exporter.directory)) {
return fmt.Errorf("path traversal detected in subdirectory: %s", subdirectory)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read-only verification: find filesystem containment checks using strings.HasPrefix in Go code.
# Expected: no path-containment logic should rely on strings.HasPrefix after remediation.
rg -nP --type=go -C3 'filepath\.Clean\(filepath\.Join\([^\n]*\)\)[\s\S]{0,200}?strings\.HasPrefix\('

Repository: projectdiscovery/nuclei

Length of output: 49


🏁 Script executed:

cat -n pkg/reporting/exporters/markdown/markdown.go | sed -n '80,130p'

Repository: projectdiscovery/nuclei

Length of output: 2518


Path containment check should use filepath.Rel for more robust containment validation.

The code now appends a separator to prevent sibling directory bypass (e.g., /reports-evil matching /reports), but this heuristic-based approach does not prevent all path traversal vectors. At lines 90-96 and 123-127, use filepath.Rel-based containment checks instead:

Suggested fix
-		cleanBase := filepath.Clean(exporter.directory) + string(filepath.Separator)
-		resolvedDir := filepath.Clean(filepath.Join(exporter.directory, subdirectory))
-		if !strings.HasPrefix(resolvedDir, cleanBase) {
+		baseDir := filepath.Clean(exporter.directory)
+		resolvedDir := filepath.Clean(filepath.Join(baseDir, subdirectory))
+		relDir, relErr := filepath.Rel(baseDir, resolvedDir)
+		if relErr != nil || relDir == ".." || strings.HasPrefix(relDir, ".."+string(filepath.Separator)) {
 			return fmt.Errorf("path traversal detected in subdirectory: %s", subdirectory)
 		}
-	cleanBase := filepath.Clean(exporter.directory) + string(filepath.Separator)
-	finalPath := filepath.Clean(filepath.Join(exporter.directory, subdirectory, filename))
-	if !strings.HasPrefix(finalPath, cleanBase) {
+	baseDir := filepath.Clean(exporter.directory)
+	finalPath := filepath.Clean(filepath.Join(baseDir, subdirectory, filename))
+	relFile, relErr := filepath.Rel(baseDir, finalPath)
+	if relErr != nil || relFile == ".." || strings.HasPrefix(relFile, ".."+string(filepath.Separator)) {
 		return fmt.Errorf("path traversal detected in output path: %s", finalPath)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/reporting/exporters/markdown/markdown.go` around lines 90 - 94, Replace
the current prefix-based containment checks with a filepath.Rel-based check:
compute rel, err := filepath.Rel(filepath.Clean(exporter.directory),
filepath.Clean(filepath.Join(exporter.directory, subdirectory))) and return an
error if err != nil or strings.HasPrefix(rel, "..") to robustly detect path
traversal; apply the same change wherever the code currently validates
resolvedDir against exporter.directory (references: resolvedDir,
exporter.directory, subdirectory) including the other occurrence around the
123-127 region so both validations use filepath.Rel.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
pkg/reporting/exporters/markdown/markdown.go (1)

123-128: Consider extracting cleanBase computation to reduce duplication.

cleanBase is computed identically at line 92 and line 123. Since the check at lines 123-127 always runs (to validate the full path including filename), you could compute cleanBase once at the start of the Export function.

♻️ Suggested refactor

Compute cleanBase once near the beginning of Export():

func (exporter *Exporter) Export(event *output.ResultEvent) error {
    cleanBase := filepath.Clean(exporter.directory) + string(filepath.Separator)
    // ... rest of function uses cleanBase

Then remove the duplicate computation at line 92, reusing the same variable in both validation blocks.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/reporting/exporters/markdown/markdown.go` around lines 123 - 128, Compute
cleanBase once at the top of Export(): set cleanBase :=
filepath.Clean(exporter.directory) + string(filepath.Separator) near the start
of the Export method and replace the duplicate computations (the one used before
validating subdirectory and the one at the filename validation) to reuse this
single variable; remove the redundant cleanBase declaration around line 92/123
so both validation blocks reference the same cleanBase and keep the existing
filepath.Join/finalPath and path traversal checks intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@pkg/reporting/exporters/markdown/markdown.go`:
- Around line 123-128: Compute cleanBase once at the top of Export(): set
cleanBase := filepath.Clean(exporter.directory) + string(filepath.Separator)
near the start of the Export method and replace the duplicate computations (the
one used before validating subdirectory and the one at the filename validation)
to reuse this single variable; remove the redundant cleanBase declaration around
line 92/123 so both validation blocks reference the same cleanBase and keep the
existing filepath.Join/finalPath and path traversal checks intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9bcebf48-0af6-49b2-a1da-03e745aa8f9f

📥 Commits

Reviewing files that changed from the base of the PR and between 6820f81 and 31cb775.

📒 Files selected for processing (1)
  • pkg/reporting/exporters/markdown/markdown.go

Copy link
Member

@dwisiswant0 dwisiswant0 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please file an issue before submitting fix patches.

Include reproduction steps as per the template and the exact nuclei command (or custom runner PoC for Nuclei SDK) that triggers unexpected behavior. The reproduction must be based on a realistic execution path that is fully concrete and reproducible.

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants