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:
- 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.)
- Drop or remove all non-admin entries from
settings.users. Only is_admin: true users will then mint usable tokens. (Implausible for most deployments.)
- 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).
GHSA-01 — JWT
adminclaim presence-only check allows full HTTP API access for non-admin OAuth usersSeverity: Critical
CVSS v3.1 vector:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:LCVSS 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
adminclaim presence-only check inAPIHandlerlets non-admin OAuth users invoke every Etherpad HTTP API endpointDescription
Etherpad's HTTP API at
/api/2/*authenticates requests in one of two modes:authenticationMethod: "apikey"— Bearer-key compared toAPIKEY.txt."sso"insettings.json.template) — Bearer JWT verified against the embedded OIDC provider's public key.In the JWT path, the
authorization_codebranch verified the token signature with jose'sjwtVerify(..., { requiredClaims: ["admin"] }). TherequiredClaimsoption 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
adminclaim asadmin: account?.is_admin(seesrc/node/security/OAuth2Provider.ts). For every user defined insettings.userswithis_admin: falseset explicitly, the issued access token contains{"admin": false}. That payload passes the presence-onlyrequiredClaimscheck, so the API gate grants access.Effect: any user whose entry in
settings.usershasis_admin: false(explicitly) and who can complete the embedded OIDCauthorization_codeflow 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 corruptiondeletePad,copyPad,movePad— destroy or relocate any pad on the instancerestoreRevision— roll a pad back to any earlier revisionanonymizeAuthor— wipe author attributionlistAllPads,listAuthorsOfPad— enumerate the full pad inventoryThe
client_credentialsbranch in the same handler is also affected to a lesser degree: it accepts any signed token whosesubmatches 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
settings.usersis enough).Affected versions
ep_etherpad-lite >= 2.1.0, <= 3.0.0. The vulnerable JWT branch was introduced in63e9b2d"Fixed api header authorization" (#6399), first tagged in v2.1.0 (2024-05-22). All releases throughv3.0.0(latest released as of 2026-05-17) contain it.authenticationMethodset to something other than"apikey"AND a non-emptysettings.usersmap with at least oneis_admin: falseentry are exploitable. Instances using the legacyapikeymode are not affected by this specific issue.Patched versions
ep_etherpad-lite >= 3.1.0— the fix is ondevelopHEAD as commit8c6104c. Update this field with the actual tagged release version (expected3.0.1or3.0.2) when it ships.Proof of concept
Decoding the JWT shows
{"admin": false}. TherequiredClaimscheck on the server passes because the claim key is present.Workarounds
If upgrading immediately is not possible:
authenticationMethod: "apikey"insettings.jsonand authenticate withAPIKEY.txt. (The patched release still supports apikey mode for operators who prefer it.)settings.users. Onlyis_admin: trueusers will then mint usable tokens. (Implausible for most deployments.)/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 === truefor theauthorization_codebranch. Drop thejwtDecode(unverified) hop — readsuboff the verified payload.Commit:
8c6104c(PR #7784).References
8c6104c).JWTClaimVerificationOptions.requiredClaims: documented as a list of claim names that must be present in the verified payload. Source:panva/josepublished TypeScript definitions (see theJWTClaimVerificationOptionsinterface). The option asserts presence only; value validation is the caller's responsibility.Credits
Reported during an internal security audit by Claude (via @JohnMcLear).