fix(security): verify the signature before writing a token to the revocation list

revokeToken() base64-decoded the payload without checking the signature and
inserted a row keyed on id-iat-type, the same key isTokenRevoked() matches
for real sessions. The logout endpoints are unauthenticated, so anyone could
forge a payload naming another user's id, type and login second and log them
out remotely; a far-future exp also left rows that cleanup never swept.

Expiry is still ignored so logging out an expired session stays idempotent.

(cherry picked from commit 0ca0e4a922)
This commit is contained in:
Paul Nothaft
2026-09-03 12:12:12 +02:00
parent 6481708def
commit c6d401685f
2 changed files with 85 additions and 7 deletions
+16 -7
View File
@@ -3,6 +3,7 @@
* Provides ability to invalidate tokens before expiration
*/
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const logger = require('./logger');
@@ -28,15 +29,23 @@ function buildTokenId(payload) {
async function revokeToken(token, reason, metadata = {}) {
try {
// Extract token info without full verification (it might be compromised)
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid token format');
// The signature MUST be verified before anything is written. The
// revocation key is `${id}-${iat}-${type}` (buildTokenId), and the
// logout endpoints are unauthenticated, so a raw base64 decode let
// anyone forge a three-part string naming another user's id, type and
// login second and insert a row that isTokenRevoked() then matched for
// that user's real session -- a remote forced logout of any admin,
// customer or gallery session, plus never-swept rows when `exp` was set
// far in the future. Expiry is ignored on purpose: revoking an already
// expired token is harmless and keeps logout idempotent.
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
ignoreExpiration: true,
});
if (!payload || typeof payload !== 'object') {
throw new Error('Invalid token payload');
}
// Decode payload
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
// user_id is integer-typed in revoked_tokens; for non-admin tokens
// we may not have an integer (customer) or any id at all (gallery
// tokens use eventId). Coerce to null instead of letting an