Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed
- Fixed upload-key-from-filename and serve-no-nosniff false positives triggered by loggers, db updates, or benign stream usage in `upload_security.py`.


## [0.11.0] — 2026-07-11

Distribution & integration round — reach every agent host, run as a local guardrail, and compose with
Expand Down
8 changes: 4 additions & 4 deletions src/websec_validator/extractors/upload_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@
ALLOW_LIST = re.compile(r"isAllowedMediaType|allowedMimeTypes|allow[_-]?list|whitelist|ALLOWED_(?:MIME|TYPES|EXT)"
r"|ACCEPTED_(?:MIME|TYPES?|EXT)|accepted(?:Mime|File|Content)?(?:Types?|Extensions?)"
r"|\bfile-type\b|fileTypeFrom|magic[_-]?byte|detectContentType|\.fromBuffer\b|sniff", re.I)
KEY_FROM_NAME = re.compile(r"(?:Key|key|path|filename|filepath|destination|filename\s*\()\s*[:=(][^;\n]{0,90}"
r"\b(?:originalname|originalName|file\.name)\b"
KEY_FROM_NAME = re.compile(r"(?:(?:upload)?[Kk]ey|path|filepath|destination|uploadDir)\s*[:=(][^;\n]{0,90}\b(?:originalname|originalName|file\.name)\b"
r"|(?:const|let|var)\s+filename\s*=\s*[^;\n]{0,90}\b(?:originalname|originalName|file\.name)\b"
r"|filename\s*[:(]\s*(?:function|\([^)]*\)\s*=>)[^;\n]{0,90}\b(?:originalname|originalName|file\.name)\b"
r"|`[^`]*\$\{[^}]*\boriginalname\b[^}]*\}[^`]*`", re.I)
TRUST_CLIENT_MIME = re.compile(r"(?:req\.files?\.[\w$.]*\.|\bfile\.)mimetype\b|headers\[['\"]content-type['\"]\]", re.I)
ACCEPT_SVG = re.compile(r"image/svg\+xml|['\"]svg['\"]", re.I)
# file-serving: streaming a STORED/PROXIED object back to the client. Tightened to genuine
# file-bytes sinks — the old rule matched a bare `getObject` token (a local coercion helper) and a
# Prometheus `res.set('Content-Type', registry.contentType)` (the /metrics endpoint), both FPs.
SERVE_FILE = re.compile(r"res\.sendFile|\.sendFile\s*\(|\.getObject\s*\(|createReadStream|proxyMedia"
r"|streamObject|\.pipe\s*\(\s*res\b|fs\.createReadStream", re.I)
SERVE_FILE = re.compile(r"res\.sendFile|\.sendFile\s*\(|createReadStream|proxyMedia|streamObject|\.pipe\s*\(\s*res\b|fs\.createReadStream", re.I)
NOSNIFF = re.compile(r"nosniff", re.I)
# `Content-Disposition: attachment` fully defeats the MIME-sniff→stored-XSS vector (the browser
# downloads instead of rendering), so a serve site that sets it is SAFE even without nosniff.
Expand Down
1 change: 1 addition & 0 deletions tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def test_end_to_end_post_commit_runs(self):
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(f.read_bytes())
hooks.install(self.root)
subprocess.run(["git", "config", "core.hooksPath", ".git/hooks"], cwd=self.root, check=True)
env = dict(os.environ)
# Ensure the hook's interpreter can import websec_validator from source.
env["PYTHONPATH"] = str(ROOT / "src") + os.pathsep + env.get("PYTHONPATH", "")
Expand Down
18 changes: 18 additions & 0 deletions tests/test_pentest_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,24 @@ def test_safe_upload_and_serve_clean(self):
self.assertEqual(out["findings"], [])


def test_upload_security_fps(self):
# ensure logging or other non-storage usage of filename is not flagged
ctx1 = repo({
"log.ts": "const multer=require('multer'); const u=multer(); app.post('/', u.single('f'), (req,res)=>{ logger.info({ filename: req.file.originalname }); res.send(); });\n",
"db.ts": "const u=multer(); app.post('/', u.single('f'), async (req,res)=>{ await db.update({ display_filename: req.file.originalname }); });\n"
})
from websec_validator.extractors.upload_security import UploadSecurityExtractor
out1 = UploadSecurityExtractor().extract(ctx1, {})
self.assertFalse(any(f["kind"] == "upload-key-from-filename" for f in out1["findings"]))

# ensure non-serve file reads and logging are not flagged
ctx2 = repo({
"s3.ts": "async function fetchConfig() { return await s3.getObject({ Key: 'cfg' }).promise(); }\n",
})
out2 = UploadSecurityExtractor().extract(ctx2, {})
self.assertFalse(any(f["kind"] == "serve-no-nosniff" for f in out2["findings"]))


class PiiExposureTests(unittest.TestCase): # N4
MASKER = "export function maskContactPii(c){ return {...c, phone: mask(c.phone)}; }\n"

Expand Down