secure-path
v1.0.1
Published
TypeScript-powered secure path resolution with browser support - prevents path traversal attacks
Maintainers
Readme
secure-path
Securely resolve paths within a trusted directory. Addresses CWE-73 (External Control of File Name or Path) by enforcing strict path validation and containment within a trusted base directory.
Works in both Node.js and browsers with TypeScript support.
Features
- 🛡️ Path Traversal Protection: Prevents
../and absolute path escapes - 🌐 Browser Support: Frontend validation before backend processing
- 🔒 Symlink Security: Detects and prevents symlink-based escapes (Node.js)
- 🚫 Blacklist Patterns: Block sensitive paths with glob patterns (
.env,.ssh/,secrets/) - 🏭 Factory Helper: Optional convenience wrapper reduces boilerplate by ~50%
- 📝 TypeScript First: Written in TypeScript with branded types
- ✨ Windows Support: Handles reserved names and device paths
- 📦 Zero Dependencies: No external runtime dependencies
- 🎯 Intent-Based API: Three specialized functions for different use cases
- ⚡ Fast: Optimized two-layer architecture
- 📊 Multiple Formats: ESM, CJS, IIFE, UMD builds included
Installation
npm install secure-pathQuick Start
Browser (Frontend Validation)
<!-- Via CDN (IIFE) -->
<script src="https://unpkg.com/secure-path/dist/browser.iife.js"></script>
<script>
const result = SecurePath.validatePathSyntax(userInput);
if (result.valid) {
// Send sanitized path to backend
fetch('/api/files', {
method: 'POST',
body: JSON.stringify({ path: result.sanitized })
});
} else {
console.error('Invalid path:', result.error);
}
</script>// ES Modules (bundler/modern browsers)
import { validatePathSyntax } from 'secure-path/browser';
// Pre-validate on frontend (UX improvement)
const result = validatePathSyntax(userInput);
if (result.valid) {
// Send to backend for real validation
await submitPath(result.sanitized);
}Node.js (Backend - Full Validation)
import {
resolveLexicalPath,
resolveExistingPath,
resolveNewPath
} from 'secure-path';
const baseDir = '/var/www/uploads';
// Three intent-based functions (replaces mode parameter)
// 1. Lexical validation only (no filesystem access)
const lexicalPath = resolveLexicalPath(userInput, baseDir);
// 2. Validate existing file/directory
const existingPath = resolveExistingPath(userInput, baseDir);
// 3. Validate path for new file creation
const newPath = resolveNewPath(userInput, baseDir);
// All return PathResolutionResult with metadata
console.log(existingPath.path); // ValidatedPath (branded type)
console.log(existingPath.normalized); // Human-readable path
console.log(existingPath.isSymlink); // Symlink detection
console.log(existingPath.realPath); // After symlink resolutionSecure I/O Helpers (TOCTOU-Aware)
import { readSecureFile, writeSecureFile, FilePermission } from 'secure-path';
// TOCTOU-aware: reduces race window via descriptor verification
const content = await readSecureFile(userInput, baseDir);
// Overwrite mode: verify-before-truncate (narrows race window)
await writeSecureFile(userInput, baseDir, content);
// Exclusive create: O_EXCL — strongest protection for new files
await writeSecureFile(userInput, baseDir, content, {
exclusive: true,
mode: FilePermission.OWNER_READ_WRITE // 0o600
});Factory Helper (Optional - Reduces Boilerplate)
import { createResolver } from 'secure-path';
// Create resolver with pre-configured base and policy
const resolver = createResolver('/app/data', {
maxPathLength: 2048,
blacklistedPaths: ['**/.ssh/**', '*.env', '**/secrets/**']
});
// All methods pre-configured with base directory
const result = resolver.resolveLexical('file.txt');
const content = await resolver.readFile('config.json');
await resolver.writeFile('output.txt', 'data');
// Works with destructuring
const { resolveLexical, readFile, writeFile } = resolver;Two-Layer Architecture
Layer 1: Browser String Validation (No filesystem)
- Pure string validation
- Path traversal detection (
../, absolute paths) - Character validation
- Works in browsers
- Fast client-side feedback
Layer 2: Node.js Full Validation (With filesystem)
- All Layer 1 checks PLUS:
- Real filesystem verification
- Symlink resolution and validation
- Parent directory existence checks
- TOCTOU-aware I/O operations (descriptor-based verification)
Security Guarantees
Path Validation
- ✅ No false positives:
...filenames are valid - ✅ Null byte rejection: Prevents C-level string truncation attacks
- ✅ Path length limits: Default 4096 chars (prevents DoS)
- ✅ Traversal detection: Segment-based validation (no regex bypasses)
- ✅ Device path blocking: Win32 namespace, named pipes, UNC paths
- ✅ Reserved names: Windows COM1-COM9, LPT1-LPT9, CON, PRN, etc.
- ✅ 8.3 short name blocking:
PROGRA~1blacklist bypass prevention - ✅ ADS detection: NTFS Alternate Data Streams (
file.txt:hidden:$DATA) - ✅ Trailing dots/spaces: Windows-specific blacklist bypass prevention
Unicode Safety
- ✅ Bidirectional override detection: RLO/LRO display spoofing (
file\u202Eexe.txt) - ✅ Zero-width character detection: ZWSP, ZWJ, ZWNJ, BOM
- ✅ Homograph detection: Mixed Latin + Cyrillic scripts (
аdminvsadmin)
Error Security
- ✅ Log injection prevention: Auto-sanitized error messages
- ✅ Prototype pollution: Options validated with
Object.hasOwn() - ✅ ANSI escape neutralization: Terminal control codes escaped
Testing
- ✅ 472 tests across 22 suites
- ✅ 95%+ coverage target (lines), 90%+ (branches)
- ✅ Platform-tested on Windows and POSIX
See CHANGELOG.md for detailed changes.
Vulnerability Coverage
This library mitigates vulnerabilities flagged by major SAST scanners. Full details in Security Reference.
CWE Coverage (11 weaknesses)
| CWE | Name | ErrorReason |
|-----|------|-------------|
| CWE-22 | Path Traversal | traversal_detected |
| CWE-23 | Relative Path Traversal | traversal_detected |
| CWE-36 | Absolute Path Traversal | absolute_not_allowed, device_path, reserved_name |
| CWE-59 | Improper Link Resolution | symlink_escape, symlink_loop, broken_symlink |
| CWE-61 | UNIX Symlink Following | symlink_escape |
| CWE-158 | Null Byte Injection | null_byte |
| CWE-400 | Resource Consumption | path_too_long, symlink_loop |
| CWE-434 | Unrestricted File Upload | invalid_extension |
| CWE-552 | Accessible Sensitive Files | blacklisted_path |
| CWE-1321 | Prototype Pollution | invalid_type |
SAST Rules Coverage
| Rule | Mitigation |
|------|------------|
| javascript.lang.security.audit.path-traversal.path-join-resolve-traversal | resolveLexicalPath validates before resolving |
| javascript.lang.security.audit.path-traversal.express-path-join | Replace path.join(dir, req.params) with resolveExistingPath() |
| javascript.lang.security.audit.path-traversal.path-traversal-join | All resolve functions validate containment |
| javascript.lang.security.audit.path-traversal.path-traversal-non-literal-fs-filename | Use readSecureFile() / writeSecureFile() |
| javascript.lang.security.audit.prototype-pollution.prototype-pollution-assignment | validateOptionsObject() blocks __proto__ |
| javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop | validateOptionsObject() recursive depth check |
| javascript.lang.security.detect-non-literal-fs-filename | I/O helpers validate before every fs call |
| javascript.lang.security.audit.detect-non-literal-require | resolveLexicalPath validates paths |
| Rule | Mitigation |
|------|------------|
| js/path-injection | All resolve functions validate containment |
| js/zipslip | resolveLexicalPath blocks ../ in extracted names |
| js/prototype-polluting-assignment | validateOptionsObject() |
| Rule | Mitigation |
|------|------------|
| S2083 | Path traversal injection — 5-layer detection |
| S5144 | Server-side path injection — all resolve functions |
| S4507 | Debug features in production — error messages sanitized |
| Rule | Mitigation |
|------|------------|
| security/detect-non-literal-fs-filename | I/O helpers wrap all fs calls |
| security/detect-object-injection | getOwnProperty() safe accessor |
| Attack | Mitigation |
|--------|------------|
| Path Traversal | 5-layer detection (../, ..%2f, %252e, ....//, lexical + real path) |
| Embedding Null Code | Null byte rejection at validation layer |
| Unrestricted File Upload | Extension allowlist via allowedExtensions |
| CWE | Class | Mitigation |
|-----|-------|------------|
| CWE-73 | External Control of File Name or Path | Core threat model — all resolve functions enforce containment within trusted base directory |
| CWE-367 | TOCTOU Race Condition | Uses descriptor-based verification (fstat vs lstat) and O_NOFOLLOW where supported to detect certain races and reduce the TOCTOU window. Note: O_NOFOLLOW is only enforced on platforms that support it (Linux/macOS). On Windows, protection is reduced. See also: Race Conditions (PortSwigger) |
Full mapping with code examples: docs/security-reference.md
⚠️ Security Warning
Browser validation is for UX only! It can be bypassed by attackers. Always re-validate on the backend:
// ❌ INSECURE - Never trust client validation alone
app.post('/upload', (req, res) => {
fs.writeFileSync(req.body.path, req.body.content); // NO!
});
// ✅ SECURE - Always validate on backend
app.post('/upload', async (req, res) => {
const safePath = resolveNewPath(req.body.path, BASE_DIR);
await writeSecureFile(safePath.path, BASE_DIR, req.body.content);
});Documentation
Complete documentation is available in the /docs directory:
- API Documentation - Complete API reference with all function signatures and examples
- Security Reference - Error codes, CWE/OWASP/Semgrep/CodeQL/SonarQube mappings, attack vector matrix
- System Architecture - Two-layer validation design, security model, threat analysis
- Code Standards - Implementation guidelines, patterns, and best practices
- Project Overview & PDR - Requirements, security guarantees, and roadmap
- Codebase Summary - Module organization and file structure
Advanced Features
Blacklist Path Patterns
Block sensitive paths using glob patterns for defense-in-depth security:
import { resolveLexicalPath } from 'secure-path';
const policy = {
blacklistedPaths: [
'**/.ssh/**', // SSH keys and config
'**/secrets/**', // Secret directories
'*.env', // Environment files
'**/.env.*', // .env variants (.env.local, etc)
'**/.git/**', // Git internals
'**/private/**', // Private data
'**/*.key', // Key files
'**/*.pem' // Certificates
]
};
// Throws PathValidationError if path matches any pattern
const result = resolveLexicalPath(userInput, '/app/data', { policy });Pattern Syntax:
*= matches any characters except/(single segment)**= matches any characters including/(recursive)?= matches exactly one character
Common Patterns:
'*.env' // Blocks: .env, config.env, test.env
'**/.ssh/**' // Blocks: .ssh/id_rsa, home/.ssh/config
'**/secrets/**' // Blocks: secrets/api.key, app/secrets/token
'**/.git/**' // Blocks: .git/config, repo/.git/HEADError Handling:
try {
resolveLexicalPath('.env', '/app', {
policy: { blacklistedPaths: ['*.env'] }
});
} catch (error) {
if (error.reason === 'blacklisted_path') {
console.log(error.message); // "Path matches blacklisted pattern: *.env"
}
}Factory Helper Benefits
- Reduces Boilerplate: ~50% less code for repeated operations
- Consistent Policy: Same security rules across all operations
- Tree-Shakeable: Zero impact if not used
- Type-Safe: Full TypeScript support with branded types
- Immutable: Frozen instance prevents accidental modification
Before (Functional API):
const result1 = resolveLexicalPath('file1.txt', '/app/data', policy);
const result2 = resolveLexicalPath('file2.txt', '/app/data', policy);
const content = await readSecureFile('file3.txt', '/app/data', policy);After (Factory API):
const resolver = createResolver('/app/data', policy);
const result1 = resolver.resolveLexical('file1.txt');
const result2 = resolver.resolveLexical('file2.txt');
const content = await resolver.readFile('file3.txt');Quick API Reference
Three Intent-Based Functions
// 1. Fast: String validation only (no filesystem)
const result = resolveLexicalPath(userInput, baseDir);
// 2. Full: With filesystem + symlink verification
const result = resolveExistingPath(userInput, baseDir);
// 3. Create: For new file validation (parent must exist)
const result = resolveNewPath(userInput, baseDir);
// TOCTOU-aware: reduces race window via descriptor verification
const content = await readSecureFile(userInput, baseDir);
await writeSecureFile(userInput, baseDir, content);Return Type
PathResolutionResult {
path: ValidatedPath; // Branded type for compile-time safety
normalized: string; // Human-readable path
isSymlink: boolean; // Symlink detection (Node.js)
realPath: string | null; // Resolved target (Node.js)
metadata?: { exists, isDirectory }
}Error Handling
try {
const result = resolveLexicalPath(userInput, baseDir);
} catch (error) {
if (error instanceof PathValidationError) {
switch (error.reason) {
case 'traversal_detected':
case 'symlink_escape':
case 'absolute_not_allowed':
// ... 10 other typed error reasons
}
}
}Configuration
const policy: SecurityPolicy = {
allowAbsolute?: boolean; // Allow absolute paths
maxPathLength?: number; // Default: 4096
allowedExtensions?: string[]; // Whitelist extensions
blacklistedPaths?: string[]; // Glob patterns to block
caseSensitive?: boolean; // Platform default
};
const result = resolveLexicalPath(userInput, baseDir, { policy });Build Formats & Compatibility
| Format | Package | Use Case |
|--------|---------|----------|
| ESM | dist/index.mjs | Modern Node.js & bundlers |
| CommonJS | dist/index.cjs | Traditional Node.js |
| Browser ESM | dist/browser.esm.js | Bundlers (Webpack, Vite) |
| IIFE | dist/browser.iife.js | <script> tags |
| UMD | dist/browser.umd.js | AMD/CommonJS/global |
Browser import: import { validatePathSyntax } from 'secure-path/browser'
TypeScript: Full type definitions included. Branded types prevent using unvalidated paths.
Size & Performance
- Node.js: 15 KB (ESM/CJS each)
- Browser: 8 KB (IIFE/UMD each)
- Lexical validation: < 1ms (string only)
- Filesystem validation: 1-5ms (with I/O)
- Zero dependencies
Contributing & Security
Contributions welcome! For security vulnerabilities, please email [email protected] instead of opening issues.
See Code Standards for development guidelines.
License
MIT © DungGramer
