Back to Blog
critical SEVERITY8 min read

How OAuth CSRF Attacks Happen in Node.js and How to Fix Them

A missing OAuth state parameter validation in `src/account_manager.js` left the `startOAuthServer()` function vulnerable to CSRF attacks, allowing an attacker to inject their own authorization code into a victim's active OAuth session. The fix generates a cryptographically random state token using `crypto.randomBytes()`, returns it alongside the server handle, and rejects any callback where the returned state doesn't match — closing the attack window entirely. This affects all downstream consume

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is an OAuth CSRF vulnerability (CWE-352) in Node.js, specifically in the `startOAuthServer()` function of `src/account_manager.js`. Although a `state` parameter was generated during the OAuth flow, it was never validated on the callback, allowing an attacker to craft a malicious redirect URI and inject their authorization code into a victim's session. The fix uses `crypto.randomBytes(16)` to generate a cryptographically secure state token, exposes it in the server's return value, and adds an explicit state-mismatch check that returns HTTP 403 and calls `onError()` when validation fails.

Vulnerability at a Glance

cweCWE-352
fixGenerate a cryptographically random state with `crypto.randomBytes(16)`, return it from `startOAuthServer()`, and reject callbacks where `returnedState !== expectedState`
riskAttacker can inject their authorization code into a victim's OAuth session, potentially hijacking account linkage
languageJavaScript (Node.js)
root cause`startOAuthServer()` generated a state parameter but never validated the returned `state` query parameter in the callback handler
vulnerabilityOAuth CSRF (Cross-Site Request Forgery)

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:

  1. Victim initiates an OAuth flow. startOAuthServer() starts listening on one of the OAUTH_FALLBACK_PORTS.
  2. Attacker separately initiates their own OAuth flow with the same provider and obtains their own authorization_code (but intentionally does not complete the exchange).
  3. Attacker crafts a URL pointing to the victim's local callback server: http://localhost:<port>/callback?code=ATTACKER_CODE
  4. 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).
  5. The victim's startOAuthServer() callback handler sees a code parameter 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

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 — not Math.random(), not a UUID library, not a timestamp. Use the platform's cryptographic RNG.
  • Returning state from startOAuthServer() 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(), specifically url.searchParams.get('code') — attacker-controlled input arriving via the browser redirect
  • Sink: The onCode callback invocation that processes the authorization code without first verifying the state parameter — located in the request handler inside startOAuthServer() in src/account_manager.js around line 286
  • Missing control: No call to url.searchParams.get('state') and no comparison against a stored expected value before processing the code parameter
  • 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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #27

Related Articles

critical

How Hardcoded Secrets Compromise Authentication in JavaScript and How to Fix It

A critical vulnerability in `Tool/QuantumultX/Rewrite/RRSP.js` exposed hardcoded API authentication credentials—a TOKEN and UMID device identifier—directly in source code. Anyone with repository access could extract these credentials to impersonate the legitimate user and gain full account access to the RRTV API service. The fix replaced hardcoded secrets with empty placeholders, forcing users to manually configure credentials through secure channels.

critical

How origin validation bypass happens in Express.js and how to fix it

A `POST /changeData` route in `src/main/server/routes/index.js` guarded state-changing writes with an origin allowlist, but the guard was wrapped in an `if (origin && ...)` truthiness check. Any request that simply omitted both `Origin` and `Referer` — a one-line `curl` command, a local script, a background process — skipped validation entirely and modified application data. The fix removes the truthiness short-circuit so a *missing* header is now treated as a rejection, not a pass.

critical

How CSRF vulnerabilities happen in Node.js API clients and how to fix them

A critical CSRF vulnerability in `lib/client.js` allowed attackers to forge authenticated POST requests to `/remote-ssh/api/*` endpoints. The fix adds the `X-Requested-With: XMLHttpRequest` header to enable proper CSRF token validation, blocking malicious cross-site requests with a minimal one-line change.

critical

How User Enumeration Happens in Django Forms and How to Fix It

A critical user enumeration vulnerability in the volunteers application allowed attackers to systematically discover registered email addresses through distinct error messages in signup and password reset forms. The fix replaces specific error messages with generic ones, preventing information disclosure while maintaining application functionality.

critical

How Authentication Bypass Happens in Node.js WebSocket Services and How to Fix It

The HousePanel push notification service exposed GET and POST endpoints without any authentication checks, allowing unauthenticated attackers to send arbitrary push notifications to connected smart devices. This critical vulnerability was fixed by implementing mandatory token validation on all protected endpoints, ensuring only authenticated requests can trigger push operations.

critical

How Missing API Authentication Happens in Node.js and How to Fix It

The GitHub API integration in `src/github.mjs` was making unauthenticated requests, subjecting the application to GitHub's strict 60 requests/hour rate limit. This fix adds secure authentication token injection from environment variables using conditional header spreading, enabling authenticated requests with a much higher rate limit (5,000 requests/hour).