Skip to content

[Security]: GitHub connect callback has no auth -- userId is fully attacker-controlled #161

@anshul23102

Description

@anshul23102

Summary

The GET /api/connect/github/callback route in apps/backend/src/routes/connect.ts has no authentication guard. The userId used to store the connected GitHub token is read directly from a base64-encoded URL state parameter that the attacker controls entirely.

Attack scenario

  1. Attacker learns or guesses a victim's internal userId (e.g. from any public API response that returns user data).
  2. Attacker completes a GitHub OAuth flow using their own account, intercepting the code before it is submitted to the server.
  3. Attacker crafts a state payload: base64({"userId":"<victim-uuid>","nonce":"anything"}).
  4. Attacker POSTs GET /api/connect/github/callback?code=<their_code>&state=<forged_state>.
  5. Server stores the attacker's GitHub token under the victim's account.
  6. The follow button on the victim's DevCard now triggers GitHub follows using the attacker's credentials, bypassing the victim's consent.

Root cause

// connect.ts line 55 -- no preHandler:
app.get('/github/callback', async (request, reply) => {
  ...
  const userId = decodedState.userId;  // attacker-supplied, never verified against session
  await app.prisma.oAuthToken.upsert({ ... userId ... });

The /github initiation route and all other connect routes correctly require preHandler: [app.authenticate]. The callback is the only exception.

Related issue: weak OAuth state entropy

Both auth.ts (line 322) and connect.ts (line 173) use Math.random().toString(36) to generate OAuth state tokens. Math.random is not a CSPRNG and provides only ~42 bits of entropy, which is insufficient for security-sensitive nonces.

function generateState(): string {
  return Math.random().toString(36).substring(2, 15); // not CSPRNG
}

Fix

  1. Add preHandler: [app.authenticate] to the callback route.
  2. After decoding state, verify decodedState.userId === request.user.id before proceeding.
  3. Replace Math.random() with crypto.randomBytes(32).toString('hex') in both files.

/assign gssoc

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions