High severityOSV Advisory· Published Jan 1, 2026· Updated Jan 2, 2026
Signal K Server Vulnerable to Denial of Service via Unrestricted Access Request Flooding
CVE-2025-68272
Description
Signal K Server is a server application that runs on a central hub in a boat. A Denial of Service (DoS) vulnerability in versions prior to 2.19.0 allows an unauthenticated attacker to crash the SignalK Server by flooding the access request endpoint (/signalk/v1/access/requests). This causes a "JavaScript heap out of memory" error due to unbounded in-memory storage of request objects. Version 2.19.0 fixes the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
signalk-servernpm | < 2.19.0 | 2.19.0 |
Affected products
1- Range: 0.1.1, 0.1.10, 0.1.11, …
Patches
155e3574d8266Merge commit from fork
5 files changed · +219 −7
package.json+1 −0 modified@@ -97,6 +97,7 @@ "express": "^4.10.4", "express-easy-zip": "^1.1.5", "express-namespace": "^0.1.1", + "express-rate-limit": "^8.2.1", "figlet": "^1.2.0", "file-timestamp-stream": "^2.1.2", "geolib": "3.2.2",
src/serverroutes.ts+30 −6 modified@@ -54,13 +54,30 @@ import { WithWrappedEmitter } from './events' import { getAISShipTypeName } from '@signalk/signalk-schema' import availableInterfaces from './interfaces' import redirects from './redirects.json' +import rateLimit from 'express-rate-limit' const readdir = util.promisify(fs.readdir) const debug = createDebug('signalk-server:serverroutes') const ncp = ncpI.ncp const defaultSecurityStrategy = './tokensecurity' const skPrefix = '/signalk/v1' +const apiLimiter = rateLimit({ + windowMs: 10 * 60 * 1000, // 10 minutes + max: 100, + message: { + message: 'Too many requests from this IP, please try again after 10 minutes' + } +}) + +const loginStatusLimiter = rateLimit({ + windowMs: 10 * 60 * 1000, // 10 minutes + max: 10, + message: { + message: 'Too many requests from this IP, please try again after 10 minutes' + } +}) + interface ScriptsApp { addons: ModuleInfo[] pluginconfigurators: ModuleInfo[] @@ -224,9 +241,9 @@ module.exports = function ( res.json(result) } - app.get(`${SERVERROUTESPREFIX}/loginStatus`, getLoginStatus) + app.get(`${SERVERROUTESPREFIX}/loginStatus`, loginStatusLimiter, getLoginStatus) //TODO remove after a grace period - app.get(`/loginStatus`, (req: Request, res: Response) => { + app.get(`/loginStatus`, loginStatusLimiter, (req: Request, res: Response) => { console.log( `/loginStatus is deprecated, try updating webapps to the latest version` ) @@ -459,7 +476,14 @@ module.exports = function ( } ) - app.post(`${skPrefix}/access/requests`, (req: Request, res: Response) => { + app.post(`${skPrefix}/access/requests`, apiLimiter, (req: Request, res: Response) => { + if ( + req.headers['content-length'] && + parseInt(req.headers['content-length']) > 10 * 1024 + ) { + res.status(413).send('Payload too large') + return + } const config = getSecurityConfig(app) const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress if (!app.securityStrategy.requestAccess) { @@ -478,12 +502,12 @@ module.exports = function ( }) // eslint-disable-next-line @typescript-eslint/no-explicit-any .catch((err: any) => { - console.log(err.stack) - res.status(500).send(err.message) + console.error(err.message) + res.status(err.statusCode || 500).send(err.message) }) }) - app.get(`${skPrefix}/requests/:id`, (req: Request, res: Response) => { + app.get(`${skPrefix}/requests/:id`, apiLimiter, (req: Request, res: Response) => { queryRequest(req.params.id) // eslint-disable-next-line @typescript-eslint/no-explicit-any .then((reply: any) => {
src/tokensecurity.js+17 −1 modified@@ -178,6 +178,16 @@ module.exports = function (app, config) { } function setupApp() { + const rateLimit = require('express-rate-limit') + const loginLimiter = rateLimit({ + windowMs: 10 * 60 * 1000, // 10 minutes + max: 10, // Limit each IP to 10 login requests per windowMs + message: { + message: + 'Too many login attempts from this IP, please try again after 10 minutes' + } + }) + app.use(require('body-parser').urlencoded({ extended: true })) app.use(require('cookie-parser')()) @@ -194,7 +204,7 @@ module.exports = function (app, config) { return dest } - app.post(['/login', `${skAuthPrefix}/login`], (req, res) => { + app.post(['/login', `${skAuthPrefix}/login`], loginLimiter, (req, res) => { const name = req.body.username const password = req.body.password const remember = req.body.rememberMe @@ -1083,6 +1093,12 @@ module.exports = function (app, config) { strategy.requestAccess = (theConfig, clientRequest, sourceIp, updateCb) => { return new Promise((resolve, reject) => { + if (filterRequests('accessRequest', 'PENDING').length >= 100) { + const err = new Error('Too many pending access requests') + err.statusCode = 503 + reject(err) + return + } createRequest( app, 'accessRequest',
test/rate-limit.js+108 −0 added@@ -0,0 +1,108 @@ +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 < 10; 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 < 100; 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-101', + description: 'Device 101' + }) + }) + + res.status.should.equal(429) + }) + + it('should limit request status checks', async function () { + const requests = [] + for (let i = 0; i < 100; 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 < 10; i++) { + requests.push( + fetch(`${url}/loginStatus`) + ) + } + + await Promise.all(requests) + + const res = await fetch(`${url}/loginStatus`) + res.status.should.equal(429) + }) +})
test/security.js+63 −0 modified@@ -565,4 +565,67 @@ describe('Security', () => { json = await result.json() json.length.should.equal(1) }) + + it('should reject access requests > 10kb', async function () { + const largeDescription = 'a'.repeat(10 * 1024) + const res = await fetch(`${url}/signalk/v1/access/requests`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + clientId: 'device-large', + description: largeDescription + }) + }) + res.status.should.equal(413) + }) +}) + +describe('Access Request Limit', () => { + let server, url, port + + before(async function () { + this.timeout(20000) + 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 pending access requests to 100', async function () { + this.timeout(20000) + const requests = [] + for (let i = 0; i < 100; 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-101', + description: 'Device 101' + }) + }) + + res.status.should.equal(503) + }) }) +
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
5- github.com/advisories/GHSA-7rqc-ff8m-7j23ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-68272ghsaADVISORY
- github.com/SignalK/signalk-server/commit/55e3574d8266fbc0ed8e453ad4557073541566f5ghsaWEB
- github.com/SignalK/signalk-server/releases/tag/v2.19.0ghsax_refsource_MISCWEB
- github.com/SignalK/signalk-server/security/advisories/GHSA-7rqc-ff8m-7j23ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.