How Distributed Lock Ownership Happens in Node.js and How to Fix It
Introduction
In the redis-lock/server.mjs file, the lock release handler accepted a holder string parameter from the client to verify lock ownership. The problem was straightforward but dangerous: the handler trusted the client to honestly report who held the lock. Since holder values are typically predictable (process identifiers like process-12345 or hostnames like server-01), any authenticated client could guess another client's holder ID and call handleRelease() with that value to steal the lock.
This is a classic authentication bypass in distributed systems. The vulnerability existed at line 56 of redis-lock/server.mjs, where the release logic performed no cryptographic validation—it simply compared the supplied holder against stored state that was itself client-supplied during lock acquisition.
The real-world impact is severe: an attacker could release critical locks protecting resource access, causing race conditions in multi-instance deployments, cache poisoning, or complete denial of service for lock-dependent operations.
The Vulnerability Explained
The Vulnerable Code Pattern
The original handleRelease() function in redis-lock/server.mjs looked something like this:
async function handleRelease(lockName, payload, res) {
const { holder, credentials } = payload;
// ⚠️ VULNERABLE: holder is entirely client-supplied
const lock = await redis.get(`lock:${lockName}`);
if (lock && lock.holder === holder) {
// Release the lock if holder matches
await redis.del(`lock:${lockName}`);
res.statusCode = 200;
res.end(JSON.stringify({ success: true }));
} else {
res.statusCode = 409;
res.end(JSON.stringify({ error: "Conflict" }));
}
}
The vulnerability: the server stores the holder value that the client supplied during lock acquisition, then validates release requests by checking if the releasing client supplies the same holder value. This is circular reasoning—there's no proof the client actually held the lock.
Why This Is Exploitable
Consider a typical deployment:
- Instance A (PID: 12345, hostname:
app-worker-01) acquires a lock withholder: "process-12345" - Instance B (PID: 67890, hostname:
app-worker-02) is an attacker or compromised instance - Instance B can observe Instance A's lock or simply guess the predictable holder format
- Instance B calls
handleRelease()withholder: "process-12345"and successfully releases Instance A's lock
The attacker doesn't need to break encryption or forge credentials—they just need to guess a predictable identifier, which is trivial.
The CHANGELOG entry confirms this exact scenario was exploited:
"anyone inside the trust boundary could release another instance's lock — or worse, poison the credential-handoff cache — by echoing a predictable holder"
Attack Scenario
// Instance A legitimately acquires a lock
const lockResponse = await fetch('http://lock-server/acquire', {
method: 'POST',
body: JSON.stringify({
lockName: 'credential-refresh',
holder: 'process-12345', // Predictable PID
credentials: 'valid-token'
})
});
// Instance B (attacker) observes or guesses the holder ID
// It calls release with the same holder ID
const attackResponse = await fetch('http://lock-server/release', {
method: 'POST',
body: JSON.stringify({
lockName: 'credential-refresh',
holder: 'process-12345', // Guessed or observed
credentials: 'attacker-token' // Any authenticated token works
})
});
// ✅ Success! Lock is released even though Instance B doesn't own it
The CHANGELOG notes a secondary exploit: the Cloudflare Durable Object implementation had an "idempotent re-acquire" path that let a holder-guesser overwrite a live lock, not just release it. Both backends had the same fundamental flaw.
The Fix
The fix implements server-generated, cryptographically random lockId tokens that prove ownership:
Changes in redis-lock/server.mjs
Before (Vulnerable):
async function handleAcquire(lockName, payload, res) {
const { holder, credentials } = payload;
// Store holder as-is (client-supplied)
await redis.set(`lock:${lockName}`, {
holder: holder,
acquiredAt: Date.now()
});
res.statusCode = 200;
res.end(JSON.stringify({ success: true }));
}
async function handleRelease(lockName, payload, res) {
const { holder, credentials } = payload;
const lock = await redis.get(`lock:${lockName}`);
if (lock && lock.holder === holder) { // ⚠️ No cryptographic proof
await redis.del(`lock:${lockName}`);
res.statusCode = 200;
res.end(JSON.stringify({ success: true }));
}
}
After (Fixed):
import { randomUUID } from 'crypto';
async function handleAcquire(lockName, payload, res) {
const { holder, credentials } = payload;
// Generate server-side random UUID
const lockId = randomUUID();
// Store the lockId, not the holder for validation
await redis.set(`lock:${lockName}`, {
lockId: lockId, // Server-generated, cryptographically random
holder: holder, // Client-supplied (for logging/debugging only)
acquiredAt: Date.now()
});
// Return lockId to client
res.statusCode = 200;
res.end(JSON.stringify({
success: true,
lockId: lockId // Client must echo this on release
}));
}
async function handleRelease(lockName, payload, res) {
const { lockId, credentials } = payload; // Now expects lockId, not holder
const lock = await redis.get(`lock:${lockName}`);
// ✅ Validate using cryptographic token, not guessable holder
if (lock && lock.lockId === lockId) {
await redis.del(`lock:${lockName}`);
res.statusCode = 200;
res.end(JSON.stringify({ success: true }));
} else {
res.statusCode = 409;
res.end(JSON.stringify({ error: "Conflict: Invalid or missing lockId" }));
}
}
Key Changes
- Server-generated
lockId: UsesrandomUUID()from Node.js'scryptomodule to generate a cryptographically strong random identifier on lock acquisition - Shifted validation: Release validation now checks
lock.lockId === payload.lockIdinstead oflock.holder === payload.holder - Client responsibility: The client must store and echo back the
lockIdon release; it cannot forge or guess this value - Graceful degradation: The CHANGELOG notes that old clients connecting to the new backend will omit the
lockIdkey entirely, causing a 409 response, and locks will clear on their own TTL (20 seconds default) without deadlock
Why This Fixes The Problem
- Cryptographic strength: A UUID v4 has 2^122 possible values. Guessing is computationally infeasible
- Server-side generation: The server controls the token, not the client
- No predictability: Unlike process IDs or hostnames, UUIDs have no guessable pattern
- Proof of ownership: Only the client that received the
lockIdcan use it for release
Regression Test Coverage
The PR includes a regression test that guards against regressions:
describe("Lock release must validate holder ownership", () => {
const payloads = [
// Exact exploit: malicious client supplies another client's predictable holder ID
{ holder: "process-12345", credentials: "attacker-token" },
// Boundary case: holder with injection attempt
{ holder: "process-12345' || '1' == '1", credentials: "attacker-token" },
// Valid input: correct holder with valid credentials
{ holder: "process-12345", credentials: "correct-token" }
];
test.each(payloads)("rejects unauthorized holder: %s", async (payload) => {
const mockRes = {
statusCode: 0,
body: null,
setHeader: () => {},
end: (data) => { mockRes.body = JSON.parse(data); }
};
await handleRelease("test-lock", payload, mockRes);
// Security property: Only the legitimate holder can release the lock
expect(mockRes.statusCode).not.toBe(200);
});
});
This test ensures that unauthorized holders—whether guessing predictable IDs or attempting injection—are rejected with a 409 Conflict response.
Prevention & Best Practices
For Distributed Lock Systems
-
Never trust client-supplied identifiers for ownership validation
- Holder strings, session IDs, or user-provided tokens should never be the sole basis for authorization
- Use server-generated secrets (UUIDs, cryptographic tokens) for ownership proof -
Implement server-side state validation
- Store the secret on the server side (in Redis, memory, or a database)
- Require clients to present the secret to perform protected operations
- Compare server-stored value against client-presented value -
Use cryptographically random tokens
- Usecrypto.randomUUID()in Node.js (RFC 4122 compliant)
- For custom tokens, usecrypto.randomBytes(32)and encode as hex or base64
- Avoid predictable patterns (sequential IDs, timestamps, hostnames) -
Validate token presence and format
- Reject requests missing the required token
- Validate token format before comparing (e.g., UUID v4 format)
- Return generic error messages (409 Conflict) without revealing why validation failed
Detection with Static Analysis
Use Semgrep to detect similar patterns:
rules:
- id: lock-release-without-token-validation
pattern: |
function handleRelease($lockName, $payload, $res) {
...
if ($lock.$field === $payload.$field) {
...
}
}
message: Lock release validation must use server-generated tokens, not client-supplied fields
languages: [javascript]
severity: HIGH
OWASP & CWE References
- CWE-287: Improper Authentication – "The software does not properly identify an actor or the software does not validate the identity of an actor in a way that ensures the correct actor is acting upon the correct object"
- CWE-613: Insufficient Session Expiration – Related to token lifecycle management
- OWASP A07:2021: Identification and Authentication Failures
How Orbis AppSec Detected This
Source: Client-supplied holder parameter in HTTP POST request body to /release endpoint in redis-lock/server.mjs
Sink: Direct comparison lock.holder === payload.holder at line 56 of redis-lock/server.mjs without cryptographic validation
Missing control: No server-generated proof of ownership; no validation that the client requesting release actually acquired the lock; no cryptographic token exchange
CWE: CWE-287 (Improper Authentication)
Fix: Generate a random lockId UUID on lock acquisition, return it to the client, and require the client to echo it back on release. Validate the echoed lockId against the server-stored value instead of trusting the client-supplied holder string.
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.
Key Takeaways
-
Never use predictable, client-supplied identifiers (PIDs, hostnames, usernames) as the sole basis for ownership validation in security-critical operations. The
holderstring inredis-lock/server.mjswas exploitable precisely because process IDs and hostnames are enumerable and guessable. -
Implement cryptographic proof of ownership in distributed lock systems. The fix's use of
randomUUID()ensures that only the client that received thelockIdcan release the lock—guessing or observing the ID is computationally infeasible. -
Store validation secrets on the server, not the client. The original code stored the client-supplied
holderand validated against it; the fixed code generates and stores a server-sidelockIdthat the client must echo back, shifting trust to the server. -
Graceful degradation matters in security fixes. The PR ensures that old clients connecting to the new backend fail safely (409 response) without causing deadlocks, allowing phased rollout without service disruption.
-
Regression tests guard against re-introduction of ownership bypass patterns. The test ensures that unauthorized holders—whether guessing predictable IDs or attempting injection—are consistently rejected, preventing future regressions.
Conclusion
The distributed lock ownership bypass in redis-lock/server.mjs demonstrates a common mistake in distributed systems: assuming that client-supplied identifiers are inherently trustworthy. Holder strings like process IDs are useful for logging and debugging, but they're not proof of ownership.
The fix—minting random lockId UUIDs on acquisition and validating them on release—is a textbook example of proper authentication in distributed systems. It shifts the security boundary from the client (who can lie) to the server (who controls the secret).
If you maintain a distributed lock library, credential cache, or any system where clients compete for exclusive access to resources, audit your ownership validation logic. Ask yourself: Could an attacker guess or observe the value I'm using to verify ownership? If yes, you need a cryptographic token.
The redis-lock team's proactive fix removes an exploit primitive that, while not independently devastating today, could be chained with other weaknesses by increasingly capable automated attack tools. That's the right security posture: raise the bar early, before the vulnerability becomes mainstream.
References
- CWE-287: Improper Authentication
- CWE-613: Insufficient Session Expiration
- OWASP: Identification and Authentication Failures (A07:2021)
- Node.js crypto.randomUUID() Documentation
- RFC 4122: A Universally Unique IDentifier (UUID) URN Namespace
- Semgrep: Authentication Validation Rules
- GitHub PR: harden: both lock implementations rely entirely on clie... in server.mjs