Skip to content

[Security] medium: Email-confirmation tokens still stored in cleartext in Redis (incomplete remediation of #313; only password-reset half was hashed) #473

Description

@Kravalg

Summary

#313 is marked FIXED with the claim that BOTH password-reset AND email-confirmation tokens are now SHA-256 hashed at rest. Verification of the current code shows the fix was only applied to password-reset tokens. Confirmation tokens are still persisted in cleartext: ConfirmationTokenFactory generates the raw token via bin2hex(random_bytes(32)); the ConfirmationToken entity stores that exact string in its tokenValue field with no transformation; and RedisTokenRepository::save() writes the serialized token under the Redis key 'token-tokenValue-<RAW_TOKEN>' and also 'token-userID-', i.e. the live email-verification secret is both the value and part of the key, in cleartext, with a 24h TTL. The same raw token is what gets embedded in the confirmation email link and later looked up by exact key in ConfirmUserProcessor / ConfirmUserMutationResolver via tokenRepository->find($rawToken). Anyone with read visibility into the Redis instance (Redis compromise, an unauthenticated/shared Redis, a leaked RDB/AOF backup, SSRF-to-Redis, or key enumeration via SCAN over the 'token-tokenValue-*' namespace) obtains fully usable confirmation tokens for every pending account and the user-id mapping, and can confirm arbitrary accounts (and, depending on downstream flows, complete registration side effects) without the victim. Password-reset tokens are protected against exactly this by storing only sha256(token) in Mongo (MongoDBPasswordResetTokenRepository:47); confirmation tokens should be stored the same way (store hash('sha256', token) as the key/value, hash the inbound token before lookup) so a store compromise yields only non-reversible digests.

Severity: MEDIUM • Category: A02:2021 Cryptographic Failures - sensitive data stored in cleartext • OWASP: A02:2021 Cryptographic Failures; A04:2021 Insecure Design (token-at-rest handling); CWE-312 Cleartext Storage of Sensitive Information
Found by an authorized automated adversarial pentest loop and confirmed by 2 independent skeptic verifiers (unanimous).

Affected code / location

src/User/Infrastructure/Repository/RedisTokenRepository.php:16-22,40-55,100-108 (keys/stores by raw getTokenValue()); src/User/Domain/Entity/ConfirmationToken.php:28-35,58-61 (stores tokenValue as-is, no hashing); src/User/Domain/Factory/ConfirmationTokenFactory.php:20-23 (emits raw bin2hex(random_bytes(32))); contrast src/User/Infrastructure/Repository/MongoDBPasswordResetTokenRepository.php:46-48 (password-reset IS sha256-hashed at rest)

Exploit scenario

  1. Trigger confirmation emails for target accounts (registration / resend). 2) Gain read access to the Redis cache used by RedisTokenRepository (shared/misconfigured Redis, backup dump, SSRF, or in a co-tenant/compromise scenario). 3) SCAN MATCH 'token-tokenValue-*' — each key literally contains a valid confirmation token; or read 'token-userID-' to get the token for a specific victim uid. 4) Submit the recovered raw token to the confirmation endpoint/GraphQL confirmUser mutation (tokenRepository->find() matches by exact plaintext key) to confirm the victim's account. No brute force is required because the secret is not hashed at rest. The password-reset path is immune to the identical attack because it stores only sha256(token), demonstrating the intended, and here missing, control.

Suggested remediation

Mirror the password-reset design: store hash('sha256', token) (never the raw token) as the Redis key and inside the persisted document, keep the plaintext only transiently for email delivery (as PasswordResetToken does with its transient plainToken), and hash the inbound token before find(). Reopen #313 as an incomplete fix rather than treating email-confirmation as remediated.

Verification notes

  • Verifier 1 (CONFIRMED, exploitable=true, duplicate=false): Traced the full confirmation-token path in current code. ConfirmationTokenFactory (config/services.yaml:241-243, CONFIRMATION_TOKEN_LENGTH=32) emits raw bin2hex(random_bytes(32)). The ConfirmationToken entity stores tokenValue with no transformation. RedisTokenRepository is the SOLE implementer of TokenRepositoryInterface and is autowired for confirmation tokens; its save() writes the serialized token under cleartext keys 'token-tokenValue-' and 'token-userID-' (24h TTL), and find() matches by exact plaintext key. ConfirmUserProcessor::process() calls tokenRepository->find($data->token), so a recovered raw key confirms an account directly. grep for hash( in the three confirmation-path files returns nothing, and no serializer normalizer redacts tokenValue; even redacting the value would not help because the RAW token is embedded in the Redis KEY itself and exposed via SCAN token-tokenValue-*. This is exactly the control the password-reset path DOES have: MongoDBPasswordResetTokenRepository::findByToken() stores/queries hash('sha256', $token). So [Security] high: Password-reset and email-confirmation tokens stored in plaintext at rest #313's fix (claimed to cover BOTH token types) is incomplete — only the password-reset half was hashed. Materially different from FIXED [Security] high: Password-reset and email-confirmation tokens stored in plaintext at rest #313 because it pinpoints the still-broken confirmation half. Present and reachable in current code; threat model (Redis/backup read, SCAN, SSRF-to-Redis) is identical to the accepted password-reset hardening. Impact lower than PW-reset (confirm pending accounts + leak uid<->token mapping, not full takeover), consistent with medium.

  • Verifier 2 (CONFIRMED, exploitable=true, duplicate=false): Code confirms the claim. ConfirmationTokenFactory.php:20-23 emits a raw bin2hex(random_bytes()) token; ConfirmationToken.php:28-29,58-61 stores it verbatim with no transformation; RedisTokenRepository.php:16-22,40-47,100-103 keys AND stores the serialized token under 'token-tokenValue-<RAW_TOKEN>' and 'token-userID-' via plain string concatenation in buildKey() (no hashing), TTL 86400s, and find() (49-55) looks up by exact raw plaintext key. This is the live implementation of TokenRepositoryInterface, consumed by ConfirmUserProcessor and ConfirmUserMutationResolver, so it is reachable. By contrast MongoDBPasswordResetTokenRepository.php:46-48 stores/queries hash('sha256',$token), and PasswordResetToken.php:36 hashes at construction — grep shows PasswordResetToken, AuthRefreshToken, RecoveryCode all hash at rest while ConfirmationToken does not. Git history is decisive: commit 585d89e 'fix(security): hash password-reset tokens at rest ([Security] high: Password-reset and email-confirmation tokens stored in plaintext at rest #313)' modified only the password-reset path, leaving the email-confirmation token in cleartext. Anyone with Redis read visibility (compromise, RDB/AOF backup, SSRF-to-Redis, or SCAN MATCH token-tokenValue-*) obtains fully-usable, non-brute-forced confirmation tokens plus the user-id mapping and can confirm arbitrary pending accounts. Not a duplicate: [Security] high: Password-reset and email-confirmation tokens stored in plaintext at rest #313 is marked FIXED as covering BOTH token types, but the confirmation-token half was never remediated; this is the concrete, still-open gap. Medium severity is appropriate — lower value than password-reset but enables account confirmation of arbitrary accounts. Remediation should mirror password-reset: store/lookup hash('sha256', token) as both key and value.

Related

Part of the enterprise security-hardening effort — umbrella tracker #348; security NFR issues #426 (securability), #441 (vulnerability), #362 (confidentiality), #389 (integrity). Prior security wave: #312#324. Not a duplicate of the already-fixed items in that range.


Acceptance = the exploit path is closed AND a regression test (unit/Behat/Schemathesis) proves it stays closed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendphpPull requests that update Php codesecuritySecurity vulnerability

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions