CVE-2026-39320
Description
Signal K Server is a server application that runs on a central hub in a boat. Versions prior to 2.25.0 are vulnerable to an unauthenticated Regular Expression Denial of Service (ReDoS) attack within the 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. Version 2.25.0 contains a fix.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
signalk-servernpm | < 2.25.0 | 2.25.0 |
Affected products
1Patches
1215d81eb700dfix(security): add rate limiting to WebSocket login endpoint (#2568)
6 files changed · +475 −229
src/interfaces/ws.ts+36 −7 modified@@ -24,6 +24,10 @@ import { InvalidTokenError, WithSecurityStrategy } from '../security' +import { + LoginRateLimiter, + LOGIN_RATE_LIMIT_MESSAGE +} from '../login-rate-limiter' import { WithConfig } from '../app' import { findRequest, @@ -135,6 +139,7 @@ interface SecurityStrategy { timeToLive?: number | null }> isDummy: () => boolean + loginRateLimiter?: LoginRateLimiter } interface SubscriptionManager { @@ -668,6 +673,22 @@ function wsInterface(app: WsApp): WsApi { }) } + function getClientIp(app: WsApp, spark: Spark): string { + if ( + app.config.settings.trustProxy && + app.config.settings.trustProxy !== 'false' + ) { + const forwardedFor = spark.request.headers['x-forwarded-for'] + if (typeof forwardedFor === 'string') { + const firstIp = forwardedFor.split(',')[0].trim() + if (firstIp) { + return firstIp + } + } + } + return spark.request.connection.remoteAddress + } + function processAccessRequest( app: WsApp, spark: Spark, @@ -681,13 +702,7 @@ function wsInterface(app: WsApp): WsApi { message: 'A request has already been submitted' }) } else { - const forwardedFor = spark.request.headers['x-forwarded-for'] - const clientIp = - (app.config.settings.trustProxy && - app.config.settings.trustProxy !== 'false' && - typeof forwardedFor === 'string' && - forwardedFor) || - spark.request.connection.remoteAddress + const clientIp = getClientIp(app, spark) requestAccess( app as unknown as WithSecurityStrategy & WithConfig, msg, @@ -725,6 +740,20 @@ function wsInterface(app: WsApp): WsApi { } function processLoginRequest(app: WsApp, spark: Spark, msg: WsMessage): void { + const rateLimiter = app.securityStrategy.loginRateLimiter + if (rateLimiter) { + const { allowed } = rateLimiter.check(getClientIp(app, spark)) + if (!allowed) { + spark.write({ + requestId: msg.requestId, + state: 'COMPLETED', + statusCode: 429, + message: LOGIN_RATE_LIMIT_MESSAGE + }) + return + } + } + app.securityStrategy .login(msg.login!.username, msg.login!.password) .then((reply) => {
src/login-rate-limiter.ts+53 −0 added@@ -0,0 +1,53 @@ +export const LOGIN_RATE_LIMIT_MESSAGE = + 'Too many login attempts from this IP, please try again later' + +export interface LoginRateLimiter { + check(ip: string): { allowed: boolean; retryAfterMs: number } + dispose(): void +} + +interface Entry { + count: number + resetTime: number +} + +export function createLoginRateLimiter( + windowMs: number, + max: number +): LoginRateLimiter { + const entries = new Map<string, Entry>() + + const cleanup = setInterval(() => { + const now = Date.now() + for (const [ip, entry] of entries) { + if (now >= entry.resetTime) { + entries.delete(ip) + } + } + }, windowMs) + cleanup.unref() + + return { + check(ip: string): { allowed: boolean; retryAfterMs: number } { + const now = Date.now() + let entry = entries.get(ip) + + if (!entry || now >= entry.resetTime) { + entry = { count: 0, resetTime: now + windowMs } + entries.set(ip, entry) + } + + entry.count++ + + if (entry.count > max) { + return { allowed: false, retryAfterMs: entry.resetTime - now } + } + + return { allowed: true, retryAfterMs: 0 } + }, + dispose(): void { + clearInterval(cleanup) + entries.clear() + } + } +}
src/security.ts+4 −0 modified@@ -32,6 +32,7 @@ import { generate } from 'selfsigned' import { Mode } from 'stat-mode' import { WithConfig } from './app' import { createDebug } from './debug' +import { LoginRateLimiter } from './login-rate-limiter' import dummysecurity from './dummysecurity' import { ICallback } from './types' const debug = createDebug('signalk-server:security') @@ -241,6 +242,9 @@ export interface SecurityStrategy { username: string, password: string ) => Promise<{ statusCode: number }> + + /** Shared login rate limiter (optional - only available when token security is active) */ + loginRateLimiter?: LoginRateLimiter } export class InvalidTokenError extends Error {
src/tokensecurity.ts+16 −11 modified@@ -28,12 +28,15 @@ import { Path } from '@signalk/server-api' import ms, { StringValue } from 'ms' -import rateLimit from 'express-rate-limit' import bodyParser from 'body-parser' import cookieParser from 'cookie-parser' import { createHash, randomBytes } from 'crypto' import { createDebug } from './debug' +import { + createLoginRateLimiter, + LOGIN_RATE_LIMIT_MESSAGE +} from './login-rate-limiter' import { InvalidTokenError, SecurityConfig, @@ -45,7 +48,6 @@ import { LoginStatusResponse, saveSecurityConfig, RequestStatusData, - getRateLimitValidationOptions, ACL, SecurityStrategy, isOIDCUserIdentifier @@ -606,15 +608,18 @@ function tokenSecurityFactory( } } - const loginLimiter = rateLimit({ - windowMs: loginWindowMs, - max: loginMax, - message: { - message: - 'Too many login attempts from this IP, please try again after 10 minutes' - }, - validate: getRateLimitValidationOptions(app) - }) + const loginRateLimiter = createLoginRateLimiter(loginWindowMs, loginMax) + strategy.loginRateLimiter = loginRateLimiter + + const loginLimiter = (req: Request, res: Response, next: NextFunction) => { + const { allowed, retryAfterMs } = loginRateLimiter.check(req.ip ?? '') + if (!allowed) { + res.set('Retry-After', String(Math.ceil(retryAfterMs / 1000))) + res.status(429).json({ message: LOGIN_RATE_LIMIT_MESSAGE }) + return + } + next() + } app.use(bodyParser.urlencoded({ extended: true }))
test/rate-limit.js+0 −211 removed@@ -1,211 +0,0 @@ -const chai = require('chai') -chai.Should() -const { startServerP } = require('./servertestutilities') -const { freeport } = require('./ts-servertestutilities') - -describe('Rate Limiting', () => { - let server, url, port - - before(async function () { - port = await freeport() - url = `http://0.0.0.0:${port}` - const securityConfig = { - allowNewUserRegistration: true, - allowDeviceAccessRequests: true - } - server = await startServerP(port, true, {}, securityConfig) - }) - - after(async function () { - await server.stop() - }) - - it('should limit login attempts', async function () { - const requests = [] - for (let i = 0; i < 100; i++) { - requests.push( - fetch(`${url}/signalk/v1/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: 'admin', - password: 'wrongpassword' - }) - }) - ) - } - - await Promise.all(requests) - - const res = await fetch(`${url}/signalk/v1/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: 'admin', - password: 'wrongpassword' - }) - }) - - res.status.should.equal(429) - }) - - it('should limit access requests', async function () { - const requests = [] - for (let i = 0; i < 1000; i++) { - requests.push( - fetch(`${url}/signalk/v1/access/requests`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - clientId: `device-${i}`, - description: `Device ${i}` - }) - }) - ) - } - - await Promise.all(requests) - - const res = await fetch(`${url}/signalk/v1/access/requests`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - clientId: 'device-1001', - description: 'Device 1001' - }) - }) - - res.status.should.equal(429) - }) - - it('should limit request status checks', async function () { - const requests = [] - for (let i = 0; i < 1000; i++) { - requests.push(fetch(`${url}/signalk/v1/requests/123`)) - } - - await Promise.all(requests) - - const res = await fetch(`${url}/signalk/v1/requests/123`) - res.status.should.equal(429) - }) - - it('should limit login status checks', async function () { - const requests = [] - for (let i = 0; i < 1000; i++) { - requests.push(fetch(`${url}/loginStatus`)) - } - - await Promise.all(requests) - - const res = await fetch(`${url}/loginStatus`) - res.status.should.equal(429) - }) -}) - -describe('Rate Limiting with trustProxy enabled', () => { - let server, url, port - let consoleErrorSpy, consoleLogSpy, capturedLogs - - before(async function () { - port = await freeport() - url = `http://0.0.0.0:${port}` - const securityConfig = { - allowNewUserRegistration: true, - allowDeviceAccessRequests: true - } - - // Capture console output to check for ERR_ERL_PERMISSIVE_TRUST_PROXY errors - capturedLogs = [] - const originalConsoleError = console.error - const originalConsoleLog = console.log - consoleErrorSpy = console.error = (...args) => { - capturedLogs.push(args.join(' ')) - originalConsoleError.apply(console, args) - } - consoleLogSpy = console.log = (...args) => { - capturedLogs.push(args.join(' ')) - originalConsoleLog.apply(console, args) - } - - // Enable trustProxy: true to verify no ERR_ERL_PERMISSIVE_TRUST_PROXY errors - const extraConfig = { - settings: { - trustProxy: true - } - } - server = await startServerP(port, true, extraConfig, securityConfig) - }) - - after(async function () { - await server.stop() - // Restore console methods - console.error = consoleErrorSpy - console.log = consoleLogSpy - }) - - it('should start without rate limiter errors logged and handle requests', async function () { - // Make a request to trigger rate limiting logic - const res = await fetch(`${url}/signalk/v1/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: 'admin', - password: 'wrongpassword' - }) - }) - - // Should get 401 (unauthorized) not a server error - res.status.should.equal(401) - - // Verify no rate limiter validation errors were logged - const allLogs = capturedLogs.join('\n') - allLogs.should.not.include('ERR_ERL_PERMISSIVE_TRUST_PROXY') - allLogs.should.not.include('ERR_ERL_UNEXPECTED_X_FORWARDED_FOR') - }) - - it('should respect X-Forwarded-For header when trustProxy is enabled', async function () { - // With trustProxy enabled, rate limiting should use X-Forwarded-For for client IP - // Make 2 requests from same actual client but with different X-Forwarded-For IPs - // Both should succeed (not rate limited) because they appear to come from different IPs - - const res1 = await fetch(`${url}/loginStatus`, { - headers: { - 'X-Forwarded-For': '192.168.1.100' - } - }) - res1.status.should.be.oneOf([200, 401, 403]) - - const res2 = await fetch(`${url}/loginStatus`, { - headers: { - 'X-Forwarded-For': '192.168.1.101' - } - }) - res2.status.should.be.oneOf([200, 401, 403]) - - // Now make 1001 requests with the SAME X-Forwarded-For IP - // First 1000 should succeed, but some will be rate limited after reaching the limit - const requests = [] - for (let i = 0; i < 1001; i++) { - requests.push( - fetch(`${url}/loginStatus`, { - headers: { - 'X-Forwarded-For': '192.168.1.200' - } - }) - ) - } - - const results = await Promise.all(requests) - - // Count how many succeeded vs rate-limited - const succeeded = results.filter((r) => - [200, 401, 403].includes(r.status) - ).length - const rateLimited = results.filter((r) => r.status === 429).length - - // Exactly 1000 should succeed, at least 1 should be rate limited - succeeded.should.equal(1000) - rateLimited.should.be.at.least(1) - }) -})
test/rate-limit.ts+366 −0 added@@ -0,0 +1,366 @@ +import chai from 'chai' +import WebSocket from 'ws' +import { startServerP } from './servertestutilities' +import { freeport } from './ts-servertestutilities' + +chai.should() + +interface ServerInstance { + stop: () => Promise<void> +} + +function wsLogin( + ws: WebSocket, + requestId: string, + username: string, + password: string +): Promise<{ + statusCode: number + requestId: string + state: string + message?: string +}> { + return new Promise((resolve, reject) => { + function cleanup() { + clearTimeout(timer) + ws.removeListener('message', onMessage) + } + const timer = setTimeout(() => { + cleanup() + reject(new Error(`Timed out waiting for ${requestId} response`)) + }, 10000) + function onMessage(data: WebSocket.Data) { + const msg = JSON.parse(data.toString()) + if (msg.requestId === requestId) { + cleanup() + resolve(msg) + } + } + ws.on('message', onMessage) + ws.send(JSON.stringify({ requestId, login: { username, password } })) + }) +} + +function openWs(port: number): Promise<WebSocket> { + return new Promise((resolve, reject) => { + const ws = new WebSocket( + `ws://0.0.0.0:${port}/signalk/v1/stream?subscribe=none` + ) + const timer = setTimeout( + () => reject(new Error('WS connection timeout')), + 10000 + ) + ws.on('message', function onHello(data: WebSocket.Data) { + const msg = JSON.parse(data.toString()) + if (msg.name && msg.version) { + clearTimeout(timer) + ws.removeListener('message', onHello) + resolve(ws) + } + }) + ws.on('error', (err) => { + clearTimeout(timer) + reject(err) + }) + }) +} + +const LOGIN_MAX = 100 +const API_MAX = 1000 + +const securityConfig = { + allowNewUserRegistration: true, + allowDeviceAccessRequests: true +} + +describe('HTTP login rate limiting', () => { + let server: ServerInstance + let url: string + + before(async function () { + const port = await freeport() + url = `http://0.0.0.0:${port}` + server = await startServerP(port, true, {}, securityConfig) + }) + + after(async function () { + await server.stop() + }) + + it(`should return 429 after ${LOGIN_MAX} attempts`, async function () { + const requests = [] + for (let i = 0; i < LOGIN_MAX; i++) { + requests.push( + fetch(`${url}/signalk/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: 'admin', + password: 'wrongpassword' + }) + }) + ) + } + await Promise.all(requests) + + const res = await fetch(`${url}/signalk/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: 'admin', + password: 'wrongpassword' + }) + }) + + res.status.should.equal(429) + }) +}) + +describe('WebSocket login rate limiting', () => { + let server: ServerInstance + let port: number + + before(async function () { + port = await freeport() + server = await startServerP(port, true, {}, securityConfig) + }) + + after(async function () { + await server.stop() + }) + + it(`should return 429 after ${LOGIN_MAX} attempts`, async function () { + this.timeout(30000) + const ws = await openWs(port) + + try { + for (let i = 0; i < LOGIN_MAX; i++) { + await wsLogin(ws, `ws-rate-${i}`, 'admin', 'wrongpassword') + } + + const blocked = await wsLogin( + ws, + 'ws-rate-blocked', + 'admin', + 'wrongpassword' + ) + blocked.statusCode.should.equal(429) + } finally { + ws.close() + } + }) +}) + +describe('Cross-protocol login rate limiting', () => { + let server: ServerInstance + let url: string + let port: number + + before(async function () { + port = await freeport() + url = `http://0.0.0.0:${port}` + server = await startServerP(port, true, {}, securityConfig) + }) + + after(async function () { + await server.stop() + }) + + it('should share the rate limit budget between HTTP and WebSocket', async function () { + this.timeout(30000) + + const half = LOGIN_MAX / 2 + const httpRequests = [] + for (let i = 0; i < half; i++) { + httpRequests.push( + fetch(`${url}/signalk/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: 'admin', + password: 'wrongpassword' + }) + }) + ) + } + await Promise.all(httpRequests) + + const ws = await openWs(port) + try { + for (let i = 0; i < half; i++) { + await wsLogin(ws, `ws-cross-${i}`, 'admin', 'wrongpassword') + } + + const blockedWs = await wsLogin( + ws, + 'ws-cross-blocked', + 'admin', + 'wrongpassword' + ) + blockedWs.statusCode.should.equal(429) + + const blockedHttp = await fetch(`${url}/signalk/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: 'admin', + password: 'wrongpassword' + }) + }) + blockedHttp.status.should.equal(429) + } finally { + ws.close() + } + }) +}) + +describe('HTTP API rate limiting', () => { + let server: ServerInstance + let url: string + + before(async function () { + const port = await freeport() + url = `http://0.0.0.0:${port}` + server = await startServerP(port, true, {}, securityConfig) + }) + + after(async function () { + await server.stop() + }) + + it('should limit access requests', async function () { + const requests = [] + for (let i = 0; i < API_MAX; i++) { + requests.push( + fetch(`${url}/signalk/v1/access/requests`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + clientId: `device-${i}`, + description: `Device ${i}` + }) + }) + ) + } + + await Promise.all(requests) + + const res = await fetch(`${url}/signalk/v1/access/requests`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + clientId: 'device-1001', + description: 'Device 1001' + }) + }) + + res.status.should.equal(429) + }) + + it('should limit request status checks', async function () { + const requests = [] + for (let i = 0; i < API_MAX; i++) { + requests.push(fetch(`${url}/signalk/v1/requests/123`)) + } + + await Promise.all(requests) + + const res = await fetch(`${url}/signalk/v1/requests/123`) + res.status.should.equal(429) + }) + + it('should limit login status checks', async function () { + const requests = [] + for (let i = 0; i < API_MAX; i++) { + requests.push(fetch(`${url}/loginStatus`)) + } + + await Promise.all(requests) + + const res = await fetch(`${url}/loginStatus`) + res.status.should.equal(429) + }) +}) + +describe('Rate limiting with trustProxy enabled', () => { + let server: ServerInstance + let url: string + let originalConsoleError: typeof console.error + let originalConsoleLog: typeof console.log + let capturedLogs: string[] + + before(async function () { + const port = await freeport() + url = `http://0.0.0.0:${port}` + + capturedLogs = [] + originalConsoleError = console.error + originalConsoleLog = console.log + console.error = (...args: unknown[]) => { + capturedLogs.push(args.join(' ')) + originalConsoleError.apply(console, args) + } + console.log = (...args: unknown[]) => { + capturedLogs.push(args.join(' ')) + originalConsoleLog.apply(console, args) + } + + const extraConfig = { + settings: { + trustProxy: true + } + } + server = await startServerP(port, true, extraConfig, securityConfig) + }) + + after(async function () { + await server.stop() + console.error = originalConsoleError + console.log = originalConsoleLog + }) + + it('should start without rate limiter errors logged and handle requests', async function () { + const res = await fetch(`${url}/signalk/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: 'admin', + password: 'wrongpassword' + }) + }) + + res.status.should.equal(401) + + const allLogs = capturedLogs.join('\n') + allLogs.should.not.include('ERR_ERL_PERMISSIVE_TRUST_PROXY') + allLogs.should.not.include('ERR_ERL_UNEXPECTED_X_FORWARDED_FOR') + }) + + it('should use X-Forwarded-For for per-IP rate limit buckets', async function () { + const requests = [] + for (let i = 0; i < API_MAX; i++) { + requests.push( + fetch(`${url}/loginStatus`, { + headers: { + 'X-Forwarded-For': '192.168.1.200' + } + }) + ) + } + await Promise.all(requests) + + const blocked = await fetch(`${url}/loginStatus`, { + headers: { + 'X-Forwarded-For': '192.168.1.200' + } + }) + blocked.status.should.equal(429) + + const differentIp = await fetch(`${url}/loginStatus`, { + headers: { + 'X-Forwarded-For': '192.168.1.201' + } + }) + differentIp.status.should.be.oneOf([200, 401, 403]) + }) +})
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/SignalK/signalk-server/commit/215d81eb700d5419c3396a0fbf23f2e246dfac2dnvdPatchWEB
- github.com/SignalK/signalk-server/pull/2568nvdIssue TrackingPatchWEB
- github.com/SignalK/signalk-server/security/advisories/GHSA-7gcj-phff-2884nvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-7gcj-phff-2884ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-39320ghsaADVISORY
- github.com/SignalK/signalk-server/releases/tag/v2.25.0nvdProductRelease NotesWEB
News mentions
0No linked articles in our index yet.