CVE-2026-41893
Description
Signal K Server is a server application that runs on a central hub in a boat. Prior to version 2.25.0, the HTTP login endpoints (POST /login and POST /signalk/v1/auth/login) are protected by express-rate-limit (default: 100 attempts per 10-minute window, configurable via HTTP_RATE_LIMITS). The WebSocket login path — sending {login: {username, password}} messages over an established WebSocket connection — calls app.securityStrategy.login() directly without any rate limiting. An attacker can bypass HTTP rate limiting entirely by opening a WebSocket connection and attempting unlimited password guesses at the speed bcrypt allows (~20 attempts/sec with 10 salt rounds). This issue has been patched in version 2.25.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
signalk-servernpm | < 2.25.0 | 2.25.0 |
Patches
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
Synthesis attempt was rejected by the grounding validator. Re-run pending.
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-vmfm-ch9h-5c7gnvdExploitMitigationVendor AdvisoryWEB
- github.com/advisories/GHSA-vmfm-ch9h-5c7gghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-41893ghsaADVISORY
- github.com/SignalK/signalk-server/releases/tag/v2.25.0nvdProductRelease NotesWEB
News mentions
0No linked articles in our index yet.