The Vulnerability in Context
The plugin/multiplex/index.js file is the server-side component of reveal.js's multiplex plugin — the feature that lets a presenter's browser drive the slide deck seen by a remote audience. It runs a small Express HTTP server that exposes two endpoints: / (serves presentation content) and /token (generates the secret that grants presenter-level control). The token endpoint is the crown jewel: whoever holds that value can push slide changes to every connected viewer.
The problem? Until this fix, anyone on the network could call GET /token and receive a valid presenter token with zero authentication required.
This post walks through exactly what the vulnerable code looked like, how an attacker would exploit it, and what the two-part fix does to close the gap.
The Vulnerability Explained
The Unauthenticated /token Endpoint
Here is the vulnerable route handler as it existed before the patch (starting around line 43 of plugin/multiplex/index.js):
// BEFORE — no authentication whatsoever
app.get("/token", function(req, res) {
var ts = new Date().getTime();
var rand = Math.floor(Math.random() * 9999999);
var secret = ts.toString() + rand.toString();
res.send(createHash(secret));
});
There is no middleware, no session check, no API key validation, and no IP restriction. The handler fires unconditionally for every GET /token request regardless of who is asking.
The createHash function that produces the token had its own problem:
// BEFORE — broken cryptographic call
var createHash = function(secret) {
var cipher = crypto.createCipher('blowfish', secret);
return(cipher.final('hex'));
};
crypto.createCipher is a symmetric encryption function, not a hash function. Calling .final() without ever calling .update() means no data is passed through the cipher — the output is an artifact of the cipher's finalization step on empty input, not a meaningful transformation of secret. Node.js deprecated crypto.createCipher in v10 and removed it in v22 precisely because it uses a weak key-derivation step (MD5, no salt). The resulting "token" is both cryptographically weak and reproducible in ways the author did not intend.
How an Attacker Exploits This
The attack chain is two steps and requires nothing beyond curl:
Step 1 — Retrieve the token:
curl http://target-host:1948/token
# Response: a3f9e1c2b4d6... (the presenter secret)
Step 2 — Use the token to control the presentation:
The multiplex plugin's client-side JavaScript connects to the Socket.IO server and listens for events authenticated by the token. With the token in hand, an attacker can emit slidechanged events to push arbitrary slide positions to every viewer's browser, effectively hijacking the presentation in real time.
Because the /token endpoint has no rate limiting either, an attacker can also harvest tokens continuously to ensure they always have a current valid credential.
Real-World Impact
- Presentation hijacking: An attacker in the same network (conference Wi-Fi, corporate LAN, or any internet-exposed deployment) can take over slide navigation during a live talk.
- Speaker notes exposure: Depending on the multiplex configuration, the token also gates access to speaker notes — private content not meant for the audience.
- Downstream consumers: reveal.js is a library. Every project that bundles this plugin and exposes port 1948 inherits this vulnerability.
The Fix
The patch makes two distinct changes to plugin/multiplex/index.js.
Change 1: Localhost-Only Restriction on /token
// AFTER — IP allowlist added before token generation
app.get("/token", function(req, res) {
var ip = req.ip || req.connection.remoteAddress;
if (ip !== '127.0.0.1' && ip !== '::1' && ip !== '::ffff:127.0.0.1') {
res.status(403).send('Forbidden');
return;
}
var ts = new Date().getTime();
var rand = Math.floor(Math.random() * 9999999);
var secret = ts.toString() + rand.toString();
res.send(createHash(secret));
});
The fix reads the caller's IP address from req.ip (Express's resolved address, respecting trust proxy settings) with a fallback to req.connection.remoteAddress. It then checks against all three loopback representations:
| Value | Meaning |
|---|---|
'127.0.0.1' |
IPv4 loopback |
'::1' |
IPv6 loopback |
'::ffff:127.0.0.1' |
IPv4-mapped IPv6 loopback |
Any caller whose IP does not match one of these values receives HTTP 403 Forbidden and the function returns immediately. The token generation code is never reached.
This is the correct threat model for a token-generation endpoint: only the presenter (running locally) should ever need to call /token. Remote audiences connect to the Socket.IO endpoint using a token the presenter shares with them out-of-band — they have no legitimate reason to call /token themselves.
Change 2: Replace Broken Cryptography with SHA-256
- var cipher = crypto.createCipher('blowfish', secret);
- return(cipher.final('hex'));
+ return crypto.createHash('sha256').update(secret).digest('hex');
The fix replaces the misused createCipher call with a proper crypto.createHash('sha256') pipeline:
.update(secret)— feeds the actual secret value into the hash function..digest('hex')— finalises the hash and returns a hex-encoded string.
This produces a deterministic, well-defined 64-character hex string derived from secret, which is the intended behavior. It also removes the dependency on the deprecated (and in newer Node versions, absent) Blowfish cipher path.
Before vs. After — side by side:
// BEFORE
var createHash = function(secret) {
var cipher = crypto.createCipher('blowfish', secret);
return(cipher.final('hex')); // secret is never fed in; output is meaningless
};
// AFTER
var createHash = function(secret) {
return crypto.createHash('sha256').update(secret).digest('hex'); // correct
};
Prevention & Best Practices
1. Apply Authentication Middleware at the Router Level
Rather than adding IP checks inside individual route handlers, consider an Express middleware that guards an entire router:
function localhostOnly(req, res, next) {
const ip = req.ip || req.connection.remoteAddress;
const loopback = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];
if (!loopback.includes(ip)) {
return res.status(403).send('Forbidden');
}
next();
}
app.get('/token', localhostOnly, tokenHandler);
This pattern is easier to audit and harder to accidentally bypass than per-route checks scattered through the codebase.
2. Never Expose Administrative Endpoints Without Authentication
Any endpoint that generates credentials, tokens, or secrets should require proof of identity before responding. The minimum viable controls are:
- Localhost binding: bind the server to
127.0.0.1rather than0.0.0.0if remote access is not required. - IP allowlist: as implemented in this fix.
- API key or bearer token: for endpoints that legitimately need to be reachable remotely.
3. Use Correct Cryptographic APIs
Node.js's crypto module distinguishes clearly between ciphers (encrypt/decrypt) and hashes (one-way digest). Using a cipher where a hash is needed — especially without calling .update() — produces silent, incorrect output. Always:
- Use
crypto.createHash()for one-way token fingerprinting. - Use
crypto.createHmac()when the output needs to be keyed (MAC). - Avoid deprecated functions (
createCipher,createDecipher) entirely.
4. Lint for Deprecated Crypto Calls
Add node/no-deprecated-api (from eslint-plugin-node) to your ESLint config to catch calls to deprecated Node.js APIs at development time:
{
"plugins": ["node"],
"rules": {
"node/no-deprecated-api": "error"
}
}
5. Relevant Standards
- OWASP API Security Top 10: API2:2023 — Broken Authentication
- CWE-306 — Missing Authentication for Critical Function
- CWE-326 — Inadequate Encryption Strength (the Blowfish/
createCipherissue) - OWASP Authentication Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
Key Takeaways
- The
/tokenroute inplugin/multiplex/index.jshad zero authentication — any caller on the network received a valid presenter token. Always treat token-generation endpoints as privileged operations requiring identity verification. crypto.createCipher('blowfish', secret)without.update()produces output that does not depend onsecret— a subtle bug that would survive code review unless a reviewer knew to look for the missing.update()call.- Localhost IP checks must cover all three loopback representations (
127.0.0.1,::1,::ffff:127.0.0.1) to be effective in dual-stack Node.js environments. - Library vulnerabilities have a multiplier effect — every downstream project that embeds this plugin inherits the unauthenticated endpoint. Fixing it upstream protects the entire ecosystem.
- Deprecated Node.js crypto APIs are not just a style issue —
crypto.createCipherwas removed in Node 22; code relying on it will throw at runtime on modern runtimes, making the cryptographic weakness a reliability issue as well.
How Orbis AppSec Detected This
- Source: Inbound HTTP
GET /tokenrequest — specificallyreq.ip/req.connection.remoteAddress— from any arbitrary network client. - Sink: The unconditional
res.send(createHash(secret))call at line 50 ofplugin/multiplex/index.js, which returned a presenter token to any caller without an intervening identity check. - Missing control: No authentication middleware, no IP restriction, and no session or API-key validation existed on the
/tokenroute prior to this fix. - CWE: CWE-306 — Missing Authentication for Critical Function (secondary: CWE-326 — Inadequate Encryption Strength for the
createCiphermisuse). - Fix: An IP allowlist block was inserted at the top of the
/tokenhandler to returnHTTP 403for all non-loopback callers, andcreateCipherwas replaced withcreateHash('sha256').
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
An unauthenticated token endpoint is one of the most direct paths to privilege escalation an attacker can find: no exploitation chain, no memory corruption, no race condition — just an HTTP GET and a credential lands in their terminal. The reveal.js multiplex plugin's /token route was exactly that: a door left open because the server was assumed to be local-only, but never enforced to be local-only.
The fix is compact — eleven lines changed — but it closes two distinct weaknesses simultaneously: the missing access control and the broken cryptographic primitive. Both changes are instructive reminders that security assumptions must be expressed in code, not documentation, and that the Node.js crypto module requires careful API selection to produce meaningful output.
When building or auditing Express servers that manage tokens or credentials, always ask: "What stops an unauthenticated caller from reaching this handler?" If the answer is "nothing," the handler needs a guard.
References
- CWE-306: Missing Authentication for Critical Function
- CWE-326: Inadequate Encryption Strength
- OWASP Authentication Cheat Sheet
- OWASP API Security Top 10: API2:2023 Broken Authentication
- Node.js
crypto.createHashdocumentation - Semgrep rules: Express authentication
- fix: the express application in both plugin/multiple... in index.js