Path Traversal Attacks: Prevention Patterns in Python, Node.js, Java, and Go

Path traversal (directory traversal) remains one of the most exploited vulnerability classes in web applications. This guide covers how the attack works, where developers go wrong in each major language, and concrete prevention patterns you can apply today.

Path traversal (CWE-22) is one of those vulnerabilities that looks obviously exploitable in a textbook example but consistently slips through code review in production code. The reason: developers intuitively understand that ../../../etc/passwd is an attack, but miss the encoded variants, OS-specific separators, and framework quirks that make detection non-trivial.

This guide covers the attack mechanics, the common implementation mistakes across Python, Node.js, Java, and Go, and canonical prevention patterns for each.

How Path Traversal Works

The goal is to cause the application to read, include, or write a file outside the intended base directory. The most basic form:

GET /files?name=report.pdf           → reads /app/uploads/report.pdf  ✓
GET /files?name=../../../etc/passwd  → reads /etc/passwd               ✗

The ../ sequences navigate up the directory tree. The application constructs a file path by concatenating a base directory with user-controlled input, and if that input isn’t sanitised, the resulting path escapes the base.

Common encoded variants:

VariantEncoding
../%2e%2e%2f
../%2e%2e/
../..%2f
..\ (Windows)..%5c
....//Double encoding bypass
Null bytesfile.pdf%00.png (legacy)

Python: The os.path.join Trap

The most common Python mistake is misunderstanding os.path.join semantics:

# VULNERABLE: os.path.join with an absolute component discards everything before it
import os

BASE_DIR = "/app/uploads"
user_input = "/etc/passwd"

path = os.path.join(BASE_DIR, user_input)
print(path)  # → /etc/passwd  (not /app/uploads/etc/passwd!)

This surprises many developers who expect os.path.join to prevent traversal. It doesn’t — when the second argument is an absolute path, it replaces the first.

Vulnerable Flask file serving:

# VULNERABLE
@app.route('/download')
def download():
    filename = request.args.get('file')
    filepath = os.path.join('/app/uploads', filename)  # bypassed by absolute path
    return send_file(filepath)

Secure Python pattern:

import os
from pathlib import Path

BASE_DIR = Path("/app/uploads").resolve()

def safe_file_path(user_input: str) -> Path:
    # Resolve to absolute path and verify it's within BASE_DIR
    requested = (BASE_DIR / user_input).resolve()
    
    # Check the resolved path starts with BASE_DIR
    try:
        requested.relative_to(BASE_DIR)
    except ValueError:
        raise PermissionError(f"Access denied: path outside allowed directory")
    
    return requested

@app.route('/download')
def download():
    try:
        filepath = safe_file_path(request.args.get('file', ''))
        if not filepath.is_file():
            abort(404)
        return send_file(filepath)
    except (PermissionError, ValueError):
        abort(403)

The key is Path.resolve() which normalises ../ sequences and symlinks before the prefix check — you’re comparing resolved absolute paths, not raw strings.

Node.js: path.join vs path.resolve

Node’s path.join does not protect against traversal:

// VULNERABLE
const path = require('path');

const BASE = '/app/uploads';
const userInput = '../../../etc/passwd';

const joined = path.join(BASE, userInput);
console.log(joined);  // → /etc/passwd  (traversal succeeded)

path.join normalises separators and . components, but it resolves ../ sequences relative to the base — which means traversal works.

Secure Node.js pattern:

const path = require('path');
const fs = require('fs');

const BASE_DIR = path.resolve('/app/uploads');

function safePath(userInput) {
    // Resolve the full path
    const resolved = path.resolve(BASE_DIR, userInput);
    
    // Verify it's still within BASE_DIR
    if (!resolved.startsWith(BASE_DIR + path.sep) && resolved !== BASE_DIR) {
        throw new Error('Path traversal detected');
    }
    
    return resolved;
}

// Express handler
app.get('/download', (req, res) => {
    try {
        const filePath = safePath(req.query.file);
        if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
            return res.status(404).send('Not found');
        }
        res.sendFile(filePath);
    } catch (err) {
        res.status(403).send('Forbidden');
    }
});

Critical edge case: Note the + path.sep in the check — without it, a directory named /app/uploads-extra would pass the startsWith('/app/uploads') check. Always include the separator.

Java: Servlet File Serving

Java web applications often expose file download endpoints, and the File class provides the primitives for safe path handling:

// VULNERABLE
@GetMapping("/download")
public ResponseEntity<Resource> download(@RequestParam String filename) 
        throws IOException {
    File file = new File("/app/uploads/" + filename);  // no validation
    return ResponseEntity.ok()
        .body(new FileSystemResource(file));
}

Secure Java pattern:

import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;

@GetMapping("/download")
public ResponseEntity<Resource> download(@RequestParam String filename) {
    try {
        Path basePath = Paths.get("/app/uploads").toRealPath();
        Path requestedPath = basePath.resolve(filename).normalize();
        
        // Verify the resolved path is within the base directory
        if (!requestedPath.startsWith(basePath)) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }
        
        File file = requestedPath.toFile();
        if (!file.exists() || !file.isFile()) {
            return ResponseEntity.notFound().build();
        }
        
        Resource resource = new FileSystemResource(file);
        return ResponseEntity.ok()
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .header(HttpHeaders.CONTENT_DISPOSITION, 
                    "attachment; filename=\"" + file.getName() + "\"")
            .body(resource);
            
    } catch (IOException e) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }
}

Path.normalize() resolves .. components, and Path.startsWith() performs a proper prefix check on path components (not string characters). The toRealPath() on the base directory resolves symlinks — important if the uploads directory itself is a symlink.

Spring’s PathMatchingResourcePatternResolver is NOT safe for user-controlled paths — use the java.nio.file.Path approach above.

Go: Filepath Cleaning and Join Behaviour

Go’s filepath.Join cleans paths including ../ traversal:

// filepath.Join normalises ../ — but doesn't prevent traversal
package main

import (
    "fmt"
    "path/filepath"
)

func main() {
    base := "/app/uploads"
    userInput := "../../../etc/passwd"
    
    joined := filepath.Join(base, userInput)
    fmt.Println(joined)  // → /etc/passwd  (traversal still works)
}

Secure Go pattern:

package main

import (
    "fmt"
    "net/http"
    "os"
    "path/filepath"
    "strings"
)

const BaseDir = "/app/uploads"

func safePath(userInput string) (string, error) {
    // Clean the input path
    cleaned := filepath.Clean(userInput)
    
    // Join with base and get absolute path
    fullPath := filepath.Join(BaseDir, cleaned)
    
    // Ensure it's within base directory
    // Use filepath.EvalSymlinks for symlink safety in production
    absBase, err := filepath.Abs(BaseDir)
    if err != nil {
        return "", fmt.Errorf("base directory error: %w", err)
    }
    
    absPath, err := filepath.Abs(fullPath)
    if err != nil {
        return "", fmt.Errorf("path resolution error: %w", err)
    }
    
    if !strings.HasPrefix(absPath, absBase+string(filepath.Separator)) {
        return "", fmt.Errorf("path traversal detected")
    }
    
    return absPath, nil
}

func downloadHandler(w http.ResponseWriter, r *http.Request) {
    filename := r.URL.Query().Get("file")
    
    safeFP, err := safePath(filename)
    if err != nil {
        http.Error(w, "Forbidden", http.StatusForbidden)
        return
    }
    
    info, err := os.Stat(safeFP)
    if err != nil || info.IsDir() {
        http.Error(w, "Not found", http.StatusNotFound)
        return
    }
    
    http.ServeFile(w, r, safeFP)
}

Allowlist vs Denylist

All the patterns above use canonicalisation (resolving the path then checking the prefix). This is the right approach. Denylist approaches — filtering out ../, %2e, and similar sequences from user input — are consistently bypassed via:

  • Double encoding (%252e%252e%252f)
  • Mixed encoding (%2e./)
  • Unicode normalization (..%c0%af../ in some parsers)
  • OS-specific separators (..\\ on Windows, ..\/ mixed)
  • Null byte injection (legacy, less common)

If you must validate at the input layer (for early rejection), do so in addition to the canonicalisation check, not instead of it.

File Inclusion vs File Read

Path traversal vulnerabilities come in two main variants:

File read (most common): the application reads and returns the file content or serves it as a download. Covered by the patterns above.

File inclusion (in PHP/legacy languages): the application include()s or require()s a file, executing its contents. File inclusion leads to remote code execution if the attacker can control a file path that points to attacker-controlled content. Prevention is the same canonicalisation approach, but the risk is significantly higher.

Testing Your Implementation

Quick test cases to validate your path-handling code:

# Test vectors — all should be rejected or return the safe base path
test_cases = [
    "../../../etc/passwd",
    "..%2f..%2f..%2fetc%2fpasswd",
    "....//....//etc//passwd",
    "/etc/passwd",                    # Absolute path injection
    "uploads/../../../etc/passwd",
    "file.pdf\x00.txt",              # Null byte (Python 3 raises ValueError)
    "C:\\Windows\\System32\\cmd.exe", # Windows path on Unix (safe, but test)
]

Run these against your safePath implementation before shipping. A correct implementation resolves them all to paths outside the base directory and raises an exception or returns a 403.

Path traversal is a solved problem — every language ecosystem has the primitives to implement safe file serving in under 20 lines. The fixes above are the canonical implementations; copy, test, and move on.