Signal K Server has Unauthenticated State Pollution leading to Remote Code Execution (RCE)
Description
Signal K Server is a server application that runs on a central hub in a boat. Prior to version 2.19.0, an unauthenticated attacker can pollute the internal state (restoreFilePath) of the server via the /skServer/validateBackup endpoint. This allows the attacker to hijack the administrator's "Restore" functionality to overwrite critical server configuration files (e.g., security.json, package.json), leading to account takeover and Remote Code Execution (RCE). Version 2.19.0 patches this vulnerability.
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
15c211eaf33f0Merge commit from fork
2 files changed · +59 −16
src/modules.ts+12 −4 modified@@ -193,19 +193,27 @@ export function runNpm( debug(`${command}: ${packageString}`) + const npmArgs = isTheServerModule(name, config) + ? [command, '-g'] + : ['--save', command] + + if (packageString) { + npmArgs.push(packageString) + } + if (isTheServerModule(name, config)) { if (process.platform === 'win32') { - npm = spawn('cmd', ['/c', `npm ${command} -g ${packageString} `], opts) + npm = spawn('npm.cmd', npmArgs, opts) } else { - npm = spawn('sudo', ['npm', command, '-g', packageString], opts) + npm = spawn('sudo', ['npm', ...npmArgs], opts) } } else { opts.cwd = config.configPath if (process.platform === 'win32') { - npm = spawn('cmd', ['/c', `npm --save ${command} ${packageString}`], opts) + npm = spawn('npm.cmd', npmArgs, opts) } else { - npm = spawn('npm', ['--save', command, packageString], opts) + npm = spawn('npm', npmArgs, opts) } }
src/serverroutes.ts+47 −12 modified@@ -94,7 +94,7 @@ module.exports = function ( getSecurityConfig: SecurityConfigGetter ) { let securityWasEnabled = false - let restoreFilePath: string + const restoreSessions = new Map<string, string>() const logopath = path.resolve(app.config.configPath, 'logo.svg') if (fs.existsSync(logopath)) { @@ -1008,16 +1008,28 @@ module.exports = function ( }) } - app.post(`${SERVERROUTESPREFIX}/restore`, (req: Request, res: Response) => { - if (!restoreFilePath) { - res.status(400).send('not exting restore file') - } else if (!fs.existsSync(restoreFilePath)) { - res.status(400).send('restore file does not exist') - } else { - res.status(202).send() - } + app.post( + `${SERVERROUTESPREFIX}/restore`, + (req: Request, res: Response) => { + if ( + !app.securityStrategy.isDummy() && + !app.securityStrategy.allowConfigure(req) + ) { + res.status(401).send('Restore not allowed') + return + } + const sessionId = getCookie(req, 'restoreSession') + const restoreFilePath = sessionId ? restoreSessions.get(sessionId) : undefined + + if (!restoreFilePath) { + res.status(400).send('not exting restore file') + } else if (!fs.existsSync(restoreFilePath)) { + res.status(400).send('restore file does not exist') + } else { + res.status(202).send() + } - listSafeRestoreFiles(restoreFilePath) + listSafeRestoreFiles(restoreFilePath!) .then((files) => { const wanted = files.filter((name) => { return req.body[name] @@ -1032,7 +1044,7 @@ module.exports = function ( i / wanted.length ) ncp( - path.join(restoreFilePath, name), + path.join(restoreFilePath!, name), path.join(app.config.configPath, name), { stopOnErr: true }, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1072,6 +1084,13 @@ module.exports = function ( app.post( `${SERVERROUTESPREFIX}/validateBackup`, (req: Request, res: Response) => { + if ( + !app.securityStrategy.isDummy() && + !app.securityStrategy.allowConfigure(req) + ) { + res.status(401).send('Validate backup not allowed') + return + } const bb = busboy({ headers: req.headers }) bb.on( 'file', @@ -1095,7 +1114,12 @@ module.exports = function ( return } const tmpDir = os.tmpdir() - restoreFilePath = fs.mkdtempSync(`${tmpDir}${path.sep}`) + const restoreFilePath = fs.mkdtempSync(`${tmpDir}${path.sep}`) + const sessionId = Date.now() + '_' + Math.random().toString(36).substr(2, 9) + restoreSessions.set(sessionId, restoreFilePath) + setTimeout(() => restoreSessions.delete(sessionId), 15 * 60 * 1000) + res.cookie('restoreSession', sessionId, { httpOnly: true, sameSite: 'strict' }) + const zipFileDir = fs.mkdtempSync(`${tmpDir}${path.sep}`) const zipFile = path.join(zipFileDir, 'backup.zip') const unzipStream = unzipper.Extract({ path: restoreFilePath }) @@ -1185,3 +1209,14 @@ const setNoCache = (res: Response) => { res.header('Pragma', 'no-cache') res.header('Expires', '0') } + +function getCookie(req: Request, name: string): string | undefined { + if (req.headers.cookie) { + const value = '; ' + req.headers.cookie + const parts = value.split('; ' + name + '=') + if (parts.length === 2) { + return parts.pop()?.split(';').shift() + } + } + return undefined +}
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-w3x5-7c4c-66p9ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-66398ghsaADVISORY
- github.com/SignalK/signalk-server/commit/5c211eaf33f0ccadbaed6720264780d92afbd7f8ghsaWEB
- github.com/SignalK/signalk-server/releases/tag/v2.19.0ghsax_refsource_MISCWEB
- github.com/SignalK/signalk-server/security/advisories/GHSA-w3x5-7c4c-66p9ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.