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
Published August 26, 2026Reviewed 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

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 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.


References

Frequently Asked Questions

What is a missing authentication vulnerability in an ECP server?

It means the server accepts and executes control commands from any network client without verifying who sent the request, allowing unauthorized actors to control the device.

How do you prevent unauthenticated endpoints in Node.js?

Bind servers to `127.0.0.1` by default, add token or session-based authentication middleware, and expose a settings toggle that requires explicit opt-in for remote access.

What CWE is missing authentication for critical functions?

CWE-306 — Missing Authentication for Critical Function, which covers cases where security-sensitive operations can be triggered without identity verification.

Is binding to localhost enough to prevent this vulnerability?

Localhost binding prevents remote LAN attackers but does not protect against malicious local processes or SSRF attacks. Defense-in-depth with authentication is still recommended.

Can static analysis detect missing authentication on HTTP endpoints?

Yes. Tools like Semgrep can flag HTTP route handlers that lack authentication middleware, and AI-assisted scanners like Orbis AppSec can reason about whether any auth check exists on a given route.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #314

Related Articles

high

How Authorization Bypass and Balance Corruption happen in Node.js and how to fix it

A high-severity authorization bypass in `commands/profile/transfer.js` allowed any user to transfer coins directly to owner/admin accounts, bypassing privilege checks entirely. Compounding the issue, the absence of a numeric guard on `targetDb.coin` could corrupt balances with `NaN` when the field was uninitialized. Three targeted lines of code closed both attack surfaces without changing any valid transfer behavior.

critical

How broken authentication happens in Node.js Express APIs and how to fix it

A critical authentication bypass in the `/api/posts` endpoint allowed any unauthenticated user to create, update, or delete posts without verification. The POST endpoint had zero authentication checks, while PUT and DELETE endpoints used a trivially bypassable username comparison that attackers could forge by simply including the target username in their request body. The fix validates user identity by looking up the userId in the database before any post operations.

critical

How Insufficient Origin Validation Happens in Express.js and How to Fix It

A critical security vulnerability in the `/changeData` endpoint allowed any remote attacker to modify user data without authorization. The Express.js route handler accepted requests from any origin and passed user-supplied data directly to the `changeData()` function. The fix implements origin validation using a regex pattern to restrict requests to trusted local sources only.

critical

How Missing Authorization Checks Happen in Node.js WhatsApp Bots and How to Fix Them

A critical authorization bypass was discovered in `plugins/tools-delete.js` where the delete command handler lacked an admin privilege check, allowing any WhatsApp group member to delete arbitrary messages. The fix adds `handler.admin = true` to enforce that only group administrators can invoke the delete functionality, preventing unauthorized message deletion by unprivileged users.

critical

How missing authentication checks happen in React route handlers and how to fix it

A critical vulnerability in ManageMembers.jsx and Settings.jsx allowed any user with network access to perform privileged operations like adding, editing, and deleting members without authentication. The fix implements route-level authentication checks using React Router's Navigate component to redirect unauthenticated users to the login page.

critical

How eval() Code Injection happens in JavaScript and how to fix it

A critical code injection vulnerability was discovered in `js/lib/jsencrypt.js` at line 195, where a direct `eval()` call executed a JavaScript string shim for the `process` object in browser environments. If an attacker could influence the string passed to `eval()`—through a compromised dependency, a man-in-the-middle attack, or supply chain tampering—they could achieve arbitrary JavaScript execution in any user's browser. The fix replaces the `eval()` call with the equivalent inline JavaScript