The Vulnerability at a Glance
| Field | Detail |
|---|---|
| File | src/account_manager.js |
| Function | startOAuthServer() |
| CWE | CWE-352 — Cross-Site Request Forgery |
| Severity | Critical |
| Impact | Authorization code injection into victim OAuth session |
Introduction
The src/account_manager.js file manages OAuth authentication flows for a Node.js library, spinning up a local HTTP server to capture the OAuth callback code. A flaw in the startOAuthServer() function meant that while a state parameter was nominally part of the design, it was never validated on the incoming callback — leaving the door wide open for an attacker to inject their own authorization code into a victim's active session.
Because this is a Node.js library, the vulnerability doesn't just affect one application — it propagates to every downstream consumer that relies on this package for OAuth account management.
The Vulnerability Explained
What Was Actually Happening
Inside startOAuthServer(), the local HTTP server listens for the OAuth provider's redirect. When the callback arrives, the handler extracted the code and error query parameters — but completely ignored the state parameter:
// BEFORE — vulnerable code
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
// 'state' is never read or validated here
The OAuth 2.0 specification (RFC 6749, Section 10.12) is explicit: the state parameter exists precisely to prevent CSRF. Without validating it, any HTTP request that arrives at the local callback port with a valid-looking code parameter will be accepted and processed — regardless of whether it came from the legitimate OAuth provider redirect.
The Attack Scenario
Here's how an attacker exploits this against a real user of this library:
- Victim initiates an OAuth flow.
startOAuthServer()starts listening on one of theOAUTH_FALLBACK_PORTS. - Attacker separately initiates their own OAuth flow with the same provider and obtains their own
authorization_code(but intentionally does not complete the exchange). - Attacker crafts a URL pointing to the victim's local callback server:
http://localhost:<port>/callback?code=ATTACKER_CODE - Attacker tricks the victim into visiting this URL (via a malicious link, an iframe in a page the victim is already viewing, or a redirect chain).
- The victim's
startOAuthServer()callback handler sees acodeparameter and processes it — exchanging the attacker's authorization code for tokens, effectively linking the attacker's identity to the victim's account.
The result: the attacker's credentials are now associated with the victim's account in the application. Depending on the OAuth provider and application logic, this can lead to full account takeover.
Why the Local Server Model Makes This Worse
Desktop and Electron-style applications often use a localhost redirect URI as the OAuth callback (rather than a hosted endpoint). This pattern is common and legitimate, but it means the "server" accepting the callback is running on the victim's own machine — making it accessible to any page the victim visits in a browser, since browsers can make cross-origin requests to localhost.
The Fix
The fix is clean, minimal, and exactly what RFC 6749 prescribes. Three coordinated changes were made to startOAuthServer():
1. Generate a Cryptographically Secure State Token
// AFTER — secure code (line ~248)
const crypto = require('crypto');
async function startOAuthServer(onCode, onError) {
const expectedState = crypto.randomBytes(16).toString('hex');
// ...
}
crypto.randomBytes(16) generates 16 bytes (128 bits) of cryptographically secure random data from Node.js's built-in crypto module. This is sufficient entropy to make the token unguessable. Using Math.random() here would have been insecure — crypto.randomBytes() is the right tool.
2. Validate the State on Every Callback
// AFTER — state validation added to callback handler
const returnedState = url.searchParams.get('state');
if (returnedState !== expectedState) {
res.writeHead(403);
res.end('Invalid state parameter');
onError('OAuth CSRF check failed: state mismatch');
return;
}
This is the critical guard. If returnedState doesn't exactly match expectedState, the server:
- Returns HTTP 403 (not a redirect, not a 200 — an explicit rejection)
- Ends the response with a human-readable message
- Calls onError() so the calling code can handle the failure gracefully
- Returns immediately, preventing any further processing of the request
The return statement is important — without it, execution would fall through to the code handling block.
3. Expose the State to the Caller
// AFTER — state returned alongside server handle
return {
server,
port: boundPort,
state: expectedState, // <-- NEW
stop: () => new Promise((resolve) => {
server.closeAllConnections();
server.close(resolve);
}),
};
Returning state from startOAuthServer() allows the calling code to include it in the OAuth authorization URL sent to the provider. This closes the loop: the state is generated here, sent to the provider, returned by the provider in the redirect, and validated here — all within the same closure.
Before vs. After
| Aspect | Before | After |
|---|---|---|
| State generated | ❌ No | ✅ Yes — crypto.randomBytes(16) |
| State validated on callback | ❌ No | ✅ Yes — strict equality check |
| Invalid state rejected | ❌ No | ✅ Yes — HTTP 403 + onError() |
| State exposed to caller | ❌ No | ✅ Yes — returned in server object |
Prevention & Best Practices
Always Validate OAuth State
The state parameter isn't optional — it's the primary CSRF defense in OAuth 2.0. Every OAuth callback handler should:
- Generate a random, unguessable state before redirecting to the provider
- Store it somewhere the callback handler can access (closure variable, session, encrypted cookie)
- Include it in the authorization URL as
&state=<value> - Validate it on every callback — reject if absent or mismatched
Use crypto.randomBytes(), Not Math.random()
// ❌ Insecure — predictable
const state = Math.random().toString(36).slice(2);
// ✅ Secure — cryptographically random
const state = require('crypto').randomBytes(16).toString('hex');
Math.random() is not cryptographically secure. An attacker who can observe timing or output patterns may be able to predict the value.
Respond With 403, Not a Redirect
When state validation fails, return HTTP 403 and stop processing. Don't redirect the user to an error page using the attacker-supplied code or state values — that can introduce open redirect vulnerabilities.
Consider PKCE for Public Clients
For desktop or mobile applications (public clients that can't keep a client secret), the OAuth 2.0 PKCE extension (RFC 7636) provides additional protection against authorization code interception attacks. PKCE and state validation are complementary — use both.
Detection with Static Analysis
This exact pattern — reading code from an OAuth callback without reading and validating state — is detectable with Semgrep:
rules:
- id: oauth-missing-state-validation
patterns:
- pattern: |
$URL.searchParams.get('code')
- pattern-not: |
$URL.searchParams.get('state')
message: OAuth callback reads 'code' without validating 'state' parameter (CWE-352)
languages: [javascript]
severity: ERROR
OWASP Reference
This vulnerability maps to OWASP's Cross-Site Request Forgery prevention guidance and is specifically called out in the OAuth 2.0 Security Best Current Practice document.
Key Takeaways
startOAuthServer()generated state but never used it — having a state parameter in the design is not enough; it must be validated on every callback or it provides zero protection.- Local localhost OAuth servers are reachable from the browser — any page a victim visits can make requests to
localhost, making CSRF validation more critical in desktop/Electron OAuth flows than in server-side flows. crypto.randomBytes(16)is the correct primitive — notMath.random(), not a UUID library, not a timestamp. Use the platform's cryptographic RNG.- Returning
statefromstartOAuthServer()is architecturally correct — the caller needs the state value to build the authorization URL; exposing it in the return object keeps the flow cohesive. - HTTP 403 is the right response for state mismatch — calling
onError()and returning immediately prevents any partial processing of a potentially malicious request.
How Orbis AppSec Detected This
- Source: The OAuth callback URL received by the local HTTP server in
startOAuthServer(), specificallyurl.searchParams.get('code')— attacker-controlled input arriving via the browser redirect - Sink: The
onCodecallback invocation that processes the authorization code without first verifying thestateparameter — located in the request handler insidestartOAuthServer()insrc/account_manager.jsaround line 286 - Missing control: No call to
url.searchParams.get('state')and no comparison against a stored expected value before processing thecodeparameter - CWE: CWE-352 — Cross-Site Request Forgery
- Fix: Added
crypto.randomBytes(16)state generation, strict equality validation of the returned state, and HTTP 403 rejection on mismatch before any authorization code processing occurs
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
The vulnerability in startOAuthServer() is a textbook example of an incomplete security control: the state parameter existed in concept but was never enforced in code. In OAuth flows, especially those using localhost redirect URIs in desktop applications, this omission is directly exploitable with nothing more than a crafted link. The fix is small — under 15 lines — but it closes a critical gap that could have allowed account hijacking for every user of this library.
When building OAuth flows in Node.js, treat state validation as non-negotiable. Generate it with crypto.randomBytes(), include it in your authorization URL, and reject any callback that doesn't return it exactly. The cost is minimal; the protection is complete.