Skip to content

JWT `admin` claim presence-only check lets non-admin OAuth users invoke every Etherpad HTTP API endpoint

Critical
JohnMcLear published GHSA-qfmh-fph3-mw8q Jun 10, 2026

Package

npm ep_etherpad-lite (npm)

Affected versions

>= 2.1.0, <= 3.0.0

Patched versions

>= 3.1.0

Description

GHSA-01 — JWT admin claim presence-only check allows full HTTP API access for non-admin OAuth users

Severity: Critical
CVSS v3.1 vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:L
CVSS suggested base score: ~9.8 — Critical
(Re-validate in the first.gov calculator before filing; my hand-computed score is in the 9.0–9.8 band depending on rounding. The vector string is the authoritative input.)
CWE: CWE-863 Incorrect Authorization

Title

JWT admin claim presence-only check in APIHandler lets non-admin OAuth users invoke every Etherpad HTTP API endpoint

Description

Etherpad's HTTP API at /api/2/* authenticates requests in one of two modes:

  • authenticationMethod: "apikey" — Bearer-key compared to APIKEY.txt.
  • Anything else (the default "sso" in settings.json.template) — Bearer JWT verified against the embedded OIDC provider's public key.

In the JWT path, the authorization_code branch verified the token signature with jose's jwtVerify(..., { requiredClaims: ["admin"] }). The requiredClaims option only asserts that the named claim is present in the verified payload — it does not inspect the claim's value.

The embedded OIDC provider issues the admin claim as admin: account?.is_admin (see src/node/security/OAuth2Provider.ts). For every user defined in settings.users with is_admin: false set explicitly, the issued access token contains {"admin": false}. That payload passes the presence-only requiredClaims check, so the API gate grants access.

Effect: any user whose entry in settings.users has is_admin: false (explicitly) and who can complete the embedded OIDC authorization_code flow can call every Etherpad HTTP API function with full admin privileges, including:

  • setHTML, setText, appendText — write arbitrary content to any pad → stored content injection / data corruption
  • deletePad, copyPad, movePad — destroy or relocate any pad on the instance
  • restoreRevision — roll a pad back to any earlier revision
  • anonymizeAuthor — wipe author attribution
  • listAllPads, listAuthorsOfPad — enumerate the full pad inventory

The client_credentials branch in the same handler is also affected to a lesser degree: it accepts any signed token whose sub matches a configured client without any further scope/claim check. That is partially by design for service accounts but contributes to the same single-point-of-failure.

Severity rationale

  • AV:N — exploitable over the network.
  • AC:L — no special conditions; a valid OIDC login is all that's required.
  • PR:L — attacker needs a low-privilege account (a normal non-admin entry in settings.users is enough).
  • UI:N — no user interaction.
  • S:C — scope changes from authenticated user to all stored pads on the instance.
  • C:H / I:H / A:L — high confidentiality + integrity (full data access), low availability (deletePad allows wipes, but not a sustained DoS).

Affected versions

  • ep_etherpad-lite >= 2.1.0, <= 3.0.0. The vulnerable JWT branch was introduced in 63e9b2d "Fixed api header authorization" (#6399), first tagged in v2.1.0 (2024-05-22). All releases through v3.0.0 (latest released as of 2026-05-17) contain it.
  • Only instances with authenticationMethod set to something other than "apikey" AND a non-empty settings.users map with at least one is_admin: false entry are exploitable. Instances using the legacy apikey mode are not affected by this specific issue.

Patched versions

  • ep_etherpad-lite >= 3.1.0 — the fix is on develop HEAD as commit 8c6104c. Update this field with the actual tagged release version (expected 3.0.1 or 3.0.2) when it ships.

Proof of concept

# Server: settings.users = {"alice": {"password": "x", "is_admin": true},
#                           "bob":   {"password": "x", "is_admin": false}}
# Server: authenticationMethod = "sso" (default)

# 1. Bob logs in via the embedded OIDC provider and gets an access token.
#    (Browser flow: /oidc/auth?response_type=code&client_id=...
#     &redirect_uri=...&scope=openid+admin&state=...)

# 2. Exchange code for access_token (standard OAuth2 boilerplate).
ACCESS_TOKEN="<bob's access_token>"

# 3. Issue an admin-only API call.
curl -X POST https://pad.example/api/2/pads/text \
  -H "Authorization: Bearer ${ACCESS_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"padID":"any-existing-pad","text":"pwned","authorId":"a.x"}'
# Returns: {"code":0,"message":"ok","data":null}   <-- should be 401

Decoding the JWT shows {"admin": false}. The requiredClaims check on the server passes because the claim key is present.

Workarounds

If upgrading immediately is not possible:

  1. Stop using the OAuth/JWT path. Set authenticationMethod: "apikey" in settings.json and authenticate with APIKEY.txt. (The patched release still supports apikey mode for operators who prefer it.)
  2. Drop or remove all non-admin entries from settings.users. Only is_admin: true users will then mint usable tokens. (Implausible for most deployments.)
  3. Block /api/* at the reverse proxy for any traffic not originating from a trusted operator IP.

Fix

Verify the JWT signature first, then explicitly require payload.admin === true for the authorization_code branch. Drop the jwtDecode (unverified) hop — read sub off the verified payload.

Commit: 8c6104c (PR #7784).

- const payload = jwtDecode(jwtToCheck)
- if (clientIds.includes(<string>payload.sub)) {
-   await jwtVerify(jwtToCheck, publicKeyExported!, {algorithms: ['RS256']})
- } else {
-   await jwtVerify(jwtToCheck, publicKeyExported!, {algorithms: ['RS256'],
-     requiredClaims: ["admin"]})
- }
+ const {payload: verified} = await jwtVerify(
+     jwtToCheck, publicKeyExported!, {algorithms: ['RS256']});
+ const isClientCredentials = clientIds.includes(verified.sub as string);
+ if (!isClientCredentials) {
+   if (verified.admin !== true) {
+     throw new createHTTPError.Unauthorized('admin claim missing or not true');
+   }
+ }

References

  • Patched in: #7784 (squash commit 8c6104c).
  • Vulnerable code introduced in: 63e9b2d (PR #6399), released in v2.1.0.
  • jose's JWTClaimVerificationOptions.requiredClaims: documented as a list of claim names that must be present in the verified payload. Source: panva/jose published TypeScript definitions (see the JWTClaimVerificationOptions interface). The option asserts presence only; value validation is the caller's responsibility.

Credits

Reported during an internal security audit by Claude (via @JohnMcLear).

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:L

CVE ID

CVE-2026-55089

Weaknesses

Incorrect Authorization

The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. Learn more on MITRE.