Back to Blog
critical SEVERITY9 min read

How Unauthenticated HTTP Endpoints happen in Node.js ECP Servers and how to fix it

The ECP (External Control Protocol) server in `src/server/ecp.js` exposed device control endpoints—like launching apps and sending keypresses—over the local network with zero authentication. Any attacker sharing the same Wi-Fi or LAN could send unauthenticated HTTP requests to take full control of the simulator. The fix introduces local-only binding controls and access restrictions to close this attack surface.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed August 26, 2026

Answer Summary

This is an unauthenticated network endpoint vulnerability (CWE-306: Missing Authentication for Critical Function) in a Node.js ECP server (`src/server/ecp.js`). The server bound to all network interfaces and accepted HTTP commands—such as `POST /keypress/Power` or `POST /launch/dev`—from any device on the local network without any authentication check. The fix adds `setECPLocalOnly` binding controls, exposes `isECPEnabled` for state inspection, and integrates a `remoteAccess` settings toggle so operators can restrict the server to localhost-only traffic when remote access is not needed.

Vulnerability at a Glance

cweCWE-306
fixAdded `setECPLocalOnly` binding restriction, `isECPEnabled` state guard, and a `remoteAccess` settings toggle to limit exposure to localhost-only traffic by default
riskAny LAN attacker can send device control commands (launch apps, send keypresses, power off) to the simulator without credentials
languageJavaScript (Node.js)
root causeThe ECP HTTP server bound to all network interfaces and processed every incoming request without authentication or authorization checks
vulnerabilityMissing Authentication for Critical Function (Unauthenticated ECP Endpoints)

How Unauthenticated HTTP Endpoints Happen in Node.js ECP Servers and How to Fix It

Summary

The ECP (External Control Protocol) server in src/server/ecp.js exposed device control endpoints over the local network with zero authentication. Any attacker sharing the same Wi-Fi or LAN could send unauthenticated HTTP requests—like POST /keypress/Power or POST /launch/dev—to take full control of the Roku simulator. The fix introduces local-only binding controls, a new remoteAccess settings toggle, and consistent *LocalOnly helpers across all server modules to close this attack surface.


Introduction

The src/server/ecp.js file implements a Roku External Control Protocol server—a lightweight HTTP service that accepts commands to control a Roku device simulator: launching channels, sending remote keypresses, querying device info, and more. It's a powerful interface by design. But a critical flaw turned that power against its users: the server accepted commands from anyone on the local network without checking who they were.

At line 56 of ecp.js, the server binds to a port and begins listening. There is no middleware that checks for an API key, session token, or any other credential before processing an incoming request. The enableECP function starts the server; disableECP stops it. But neither function, nor any route handler, asks the most basic security question: should this caller be allowed to do this?

This matters especially because the project is a Node.js library—vulnerabilities here propagate to every downstream consumer who embeds this simulator in their toolchain.


The Vulnerability Explained

What the ECP Server Does

The ECP protocol is Roku's mechanism for external control. A running ECP server responds to HTTP requests like:

POST http://<device-ip>:8060/keypress/Power   → powers the device off
POST http://<device-ip>:8060/launch/dev       → launches the dev channel
GET  http://<device-ip>:8060/query/device-info → returns device metadata

These are privileged operations. In a real Roku device, ECP is intentionally limited to the local network. But even on a local network, not every device should have control authority.

The Vulnerable Pattern

Before the fix, src/helpers/settings.js imported only the bare enable/disable functions:

// BEFORE — vulnerable imports
import { enableECP, disableECP } from "../server/ecp";
import { enableTelnet, disableTelnet } from "../server/telnet";
import { enableDebugServer, disableDebugServer } from "../server/debug";
import {
    enableInstaller,
    disableInstaller,
    setPort,
    isInstallerEnabled,
    setPassword,
} from "../server/installer";

There were no functions to:
- Check whether the server was currently enabled (isECPEnabled)
- Restrict the server to localhost-only traffic (setECPLocalOnly)
- Toggle remote access at the settings level (remoteAccess)

The server simply started, bound to all interfaces (0.0.0.0), and served every request it received. The settings object returned by getSettings() had no remoteAccess key, so the UI had no way to surface or enforce a restriction.

The Attack Scenario

Consider a developer running this simulator on a laptop connected to a shared office Wi-Fi:

  1. The ECP server starts on 0.0.0.0:8060 (all interfaces).
  2. An attacker on the same network runs a quick scan: nmap -p 8060 192.168.1.0/24.
  3. They find the simulator's IP and send:
    POST http://192.168.1.42:8060/launch/dev HTTP/1.1 Host: 192.168.1.42:8060 Content-Length: 0
  4. The simulator launches the dev channel. No authentication required. No log entry that looks suspicious. No error returned.
  5. The attacker can also exfiltrate device metadata, simulate keypresses to navigate the UI, or repeatedly power-cycle the simulator to disrupt development workflows.

Because this is a library, the same pattern affects every application that embeds it—CI/CD runners, automated test harnesses, developer workstations—all potentially exposed on shared networks.

Real-World Impact

  • Unauthorized control: Any LAN peer can launch, stop, or manipulate the simulated device.
  • Information disclosure: GET /query/device-info leaks device model, firmware version, and network configuration without credentials.
  • Denial of service: Repeated power-off or reboot commands disrupt development and testing pipelines.
  • Supply chain exposure: As a library, this vulnerability is inherited by all consumers.

The Fix

What Changed

The fix operates at two levels: the server module API and the settings integration layer.

1. New Exported Functions in Each Server Module

The fix adds isECPEnabled and setECPLocalOnly to src/server/ecp.js, and mirrors this pattern across all server modules:

// AFTER — secure imports in src/helpers/settings.js
import { enableECP, disableECP, isECPEnabled, setECPLocalOnly } from "../server/ecp";
import { enableTelnet, disableTelnet, isTelnetEnabled, setTelnetLocalOnly } from "../server/telnet";
import { enableDebugServer, disableDebugServer, isDebugEnabled, setDebugLocalOnly } from "../server/debug";
import {
    enableInstaller,
    disableInstaller,
    setPort,
    isInstallerEnabled,
    setInstallerLocalOnly,
    setPassword,
} from "../server/installer";
  • isECPEnabled: Allows the settings layer to check current server state before making decisions—preventing race conditions where settings changes could re-enable a disabled server.
  • setECPLocalOnly(true/false): When called with true, rebinds the server to 127.0.0.1 instead of 0.0.0.0, making it unreachable from the network. This is the primary attack surface reduction.

2. remoteAccess Settings Toggle

The getSettings() function now includes a remoteAccess key in the returned settings object:

// BEFORE
{
    ecp: ["enabled"],
    telnet: ["enabled"],
    debug: ["enabled"],
}

// AFTER
{
    ecp: ["enabled"],
    telnet: ["enabled"],
    debug: ["enabled"],
    remoteAccess: ["enabled"],  // ← NEW
}

This means:
- The UI can now render a Remote Access toggle that users must explicitly enable.
- The default state is local-only — remote access is opt-in, not opt-out.
- When remoteAccess.enabled is false, all four servers (ecp, telnet, debug, installer) are instructed via their respective set*LocalOnly(true) calls to bind only to 127.0.0.1.

3. Port Constants Imported for Consistency

// AFTER
import { WEB_INSTALLER_PORT, DEFAULT_USRPWD, ECP_PORT, TELNET_PORT, DEBUG_PORT } from "../constants";

Centralizing port constants prevents misconfiguration where a server might accidentally bind on an unexpected interface/port combination.

Before vs. After: The Security Boundary

Scenario Before Fix After Fix
LAN attacker sends POST /keypress/Power ✅ Accepted, executed ❌ Rejected (server on 127.0.0.1)
GET /query/device-info from remote host ✅ Returns full device info ❌ Connection refused
Developer enables remote access intentionally N/A (always on) ✅ Explicit opt-in via settings
Settings UI shows remote access state ❌ Not visible ✅ remoteAccess toggle visible

Key Takeaways

  • setECPLocalOnly(true) is the primary defense: Rebinding the ECP server to 127.0.0.1 eliminates the LAN attack surface entirely without breaking local development workflows.
  • The remoteAccess toggle enforces secure-by-default: Remote network access is now an explicit opt-in in getSettings(), not an implicit always-on behavior.
  • Apply binding restrictions uniformly: The fix correctly updated ecp, telnet, debug, and installer modules — missing even one would leave a gap attackers could exploit.
  • isECPEnabled prevents state confusion: Checking server state before applying settings changes prevents subtle bugs where a disabled server could be inadvertently re-exposed.
  • Library vulnerabilities multiply: Because src/server/ecp.js is part of a Node.js library, this unauthenticated endpoint affected every downstream consumer — the blast radius was far larger than a single application.

How Orbis AppSec Detected This

  • Source: Incoming HTTP requests on 0.0.0.0:8060 — any device on the local network could initiate a connection to the ECP server.
  • Sink: Route handlers in src/server/ecp.js (around line 56) that process commands like /keypress/:key and /launch/:appId without any preceding authentication check.
  • Missing control: No authentication middleware, no API token validation, no IP allowlist, and no binding restriction to localhost — the server accepted and executed every request it received.
  • CWE: CWE-306 — Missing Authentication for Critical Function.
  • Fix: Added setECPLocalOnly to rebind the server to 127.0.0.1 by default, isECPEnabled for state inspection, and a remoteAccess settings toggle that must be explicitly enabled for network-wide access.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

Unauthenticated network endpoints are one of the most straightforward vulnerabilities to introduce and one of the most impactful to exploit. The ECP server in src/server/ecp.js is a perfect example: it was doing exactly what it was designed to do—accept and execute device control commands—but it had no mechanism to distinguish a legitimate caller from an attacker on the same Wi-Fi network.

The fix is elegant in its approach: rather than bolting authentication onto every route handler, it addresses the problem at the binding layer. By defaulting to 127.0.0.1 and requiring an explicit remoteAccess opt-in, the server is now secure by default. Developers who need remote access can enable it intentionally; everyone else gets a hardened default.

For developers building similar control protocol servers—whether ECP, Telnet bridges, or debug servers—the lesson is clear: network listeners are attack surfaces. Every port you open on 0.0.0.0 is a door you're leaving unlocked. Start with localhost, add authentication, and make remote access an explicit choice.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #314

Related Articles

critical

Discord Bot Auto-Role CWE-269: Privilege Escalation via role.position

A Discord.js bot's auto-role feature allowed privilege escalation where any user with ManageRoles permission could add Administrator roles to automatic assignment lists, regardless of their own role hierarchy. The vulnerability existed because role.position validation checked only the bot's permissions, not the invoking user's role hierarchy relative to the target role.

high

CVE-2026-54290: hono cors() Reflects Any Origin With Credentials

The `hono` CORS middleware, as resolved in this service at 4.12.8, reflected the caller's `Origin` header back in `Access-Control-Allow-Origin` while also emitting `Access-Control-Allow-Credentials: true` whenever the `origin` option was left at its `'*'` default. That combination makes any website a trusted origin for credentialed cross-origin reads. The dependency range was raised from `^4.7.1` to `^4.13.5`, moving the installed copy from 4.12.8 to 4.13.5.

critical

POST /api/generate Lacks Authentication, Allowing Unauthenticated

A resume generation endpoint in a Node.js backend accepted requests from any caller with network access, allowing attackers to consume OpenAI API quota without restriction. The vulnerability stemmed from missing authentication middleware on a cost-bearing endpoint. The fix adds mandatory API key validation via HTTP headers before processing any generation requests.

critical

SchedulePush.disableReminder Missing Authorization Check in push.js

The `disableReminder` method in the `SchedulePush` class allowed any user to disable push notification reminders for arbitrary user IDs by manipulating the `e.user_id` event parameter. The fix adds a `checkFriend()` authorization gate that verifies the requesting user has a valid friendship relationship with the bot before modifying subscription state.

critical

How Broken Object-Level Authorization happens in Express.js and how to fix it

A critical authorization flaw in `src/v1/routes/index.js` allowed any authenticated API key holder to access arbitrary budgets by manipulating the `budgetSyncId` URL parameter. The fix introduces an environment-based allowlist that validates budget access before processing requests.

high

How Missing Rate Limiting Enables Denial of Service Attacks in Node.js and How to Fix It

The k-skill-proxy server exposed multiple public API endpoints (`/health`, `/v1/vworld/search`, `/v1/fine-dust/report`, `/v1/assembly/bills`) without consistent rate limiting middleware, leaving them vulnerable to denial-of-service attacks. A `buildRateLimiter` function existed but wasn't applied to all endpoints. This fix ensures rate limiting is enforced on all public endpoints, preventing resource exhaustion attacks.