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:
- The ECP server starts on
0.0.0.0:8060(all interfaces). - An attacker on the same network runs a quick scan:
nmap -p 8060 192.168.1.0/24. - 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 - The simulator launches the dev channel. No authentication required. No log entry that looks suspicious. No error returned.
- 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-infoleaks 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 withtrue, rebinds the server to127.0.0.1instead of0.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 |
Prevention & Best Practices
1. Default to Localhost Binding
Any server that doesn't require remote access should bind to 127.0.0.1 by default. Network exposure should be an explicit, documented, user-initiated action.
// Secure default
const server = app.listen(ECP_PORT, '127.0.0.1');
// Only when user opts in
const server = app.listen(ECP_PORT, remoteAccess ? '0.0.0.0' : '127.0.0.1');
2. Add Authentication Middleware Even for Local Servers
Localhost binding reduces the attack surface but doesn't eliminate it. Malicious local processes or SSRF vulnerabilities can still reach 127.0.0.1. For privileged operations, add token-based authentication:
app.use('/keypress', (req, res, next) => {
const token = req.headers['x-ecp-token'];
if (!isValidToken(token)) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
});
3. Apply the *LocalOnly Pattern Consistently
The fix correctly applies setECPLocalOnly, setTelnetLocalOnly, setDebugLocalOnly, and setInstallerLocalOnly uniformly. When you have multiple server modules, ensure binding restrictions are applied to all of them, not just the one that was flagged. A single unprotected server can be used as a pivot point.
4. Expose Security State in Settings/UI
The remoteAccess: ["enabled"] addition to getSettings() is a good pattern: security-relevant server states should be visible and configurable in the application's settings interface. Hidden or always-on network listeners are a common source of vulnerabilities.
5. Use Static Analysis to Catch Missing Auth
Tools like Semgrep can detect Express/Node.js route handlers that lack authentication middleware:
# Semgrep rule concept
rules:
- id: express-route-no-auth
pattern: app.$METHOD($PATH, $HANDLER)
message: Route handler may lack authentication middleware
Relevant Standards
- OWASP API Security Top 10 — API2: Broken Authentication: Unauthenticated API endpoints are one of the most common API security failures.
- CWE-306: Missing Authentication for Critical Function — directly applicable here.
- CWE-284: Improper Access Control — the broader category covering unauthorized command execution.
- OWASP ASVS V4: Authentication and session management verification requirements for APIs.
Key Takeaways
setECPLocalOnly(true)is the primary defense: Rebinding the ECP server to127.0.0.1eliminates the LAN attack surface entirely without breaking local development workflows.- The
remoteAccesstoggle enforces secure-by-default: Remote network access is now an explicit opt-in ingetSettings(), not an implicit always-on behavior. - Apply binding restrictions uniformly: The fix correctly updated
ecp,telnet,debug, andinstallermodules — missing even one would leave a gap attackers could exploit. isECPEnabledprevents 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.jsis 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/:keyand/launch/:appIdwithout 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
setECPLocalOnlyto rebind the server to127.0.0.1by default,isECPEnabledfor state inspection, and aremoteAccesssettings 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.
References
- CWE-306: Missing Authentication for Critical Function
- CWE-284: Improper Access Control
- OWASP API Security Top 10 — API2: Broken Authentication
- OWASP Authentication Cheat Sheet
- Node.js net.Server listen() documentation
- Semgrep rules: express authentication
- fix: the ecp (external control protocol) server expo... in ecp.js