Fix share token confusion vulnerability

Cache tokens from /api/send and share tokens were both signed with the same secret and parsed without distinguishing claim types, allowing a public cache token to be replayed as x-umami-share-token to read any website's analytics. Add explicit token type claims (share/cache) and require the matching type when parsing each.
This commit is contained in:
Mike Cao
2026-06-02 14:45:21 -07:00
parent c4b1452c29
commit 8bc49a29e3
4 changed files with 27 additions and 7 deletions
+6 -3
View File
@@ -3,7 +3,7 @@ import { isbot } from 'isbot';
import { serializeError } from 'serialize-error';
import { z } from 'zod';
import clickhouse from '@/lib/clickhouse';
import { COLLECTION_TYPE, EVENT_TYPE } from '@/lib/constants';
import { CACHE_TOKEN_TYPE, COLLECTION_TYPE, EVENT_TYPE } from '@/lib/constants';
import { getSalt, hash, secret, uuid } from '@/lib/crypto';
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
import { createToken, parseToken } from '@/lib/jwt';
@@ -106,7 +106,7 @@ export async function POST(request: Request) {
if (cacheHeader) {
const result = await parseToken(cacheHeader, secret());
if (result) {
if (result?.type === CACHE_TOKEN_TYPE) {
cache = result;
}
}
@@ -308,7 +308,10 @@ export async function POST(request: Request) {
});
}
const token = createToken({ websiteId, sessionId, visitId, iat }, secret());
const token = createToken(
{ websiteId, sessionId, visitId, iat, type: CACHE_TOKEN_TYPE },
secret(),
);
return json({ cache: token, sessionId, visitId });
} catch (e) {
+2 -2
View File
@@ -1,5 +1,5 @@
import { getBoardEntityIds } from '@/lib/boards';
import { ENTITY_TYPE, ROLES } from '@/lib/constants';
import { ENTITY_TYPE, ROLES, SHARE_TOKEN_TYPE } from '@/lib/constants';
import { secret } from '@/lib/crypto';
import { createToken } from '@/lib/jwt';
import prisma from '@/lib/prisma';
@@ -91,7 +91,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
return notFound();
}
data.token = createToken(data, secret());
data.token = createToken({ ...data, type: SHARE_TOKEN_TYPE }, secret());
const accountId = await getAccountId(entity);
+17 -2
View File
@@ -1,5 +1,11 @@
import debug from 'debug';
import { ROLE_PERMISSIONS, ROLES, SHARE_CONTEXT_HEADER, SHARE_TOKEN_HEADER } from '@/lib/constants';
import {
ROLE_PERMISSIONS,
ROLES,
SHARE_CONTEXT_HEADER,
SHARE_TOKEN_HEADER,
SHARE_TOKEN_TYPE,
} from '@/lib/constants';
import { createAuthKey, secret } from '@/lib/crypto';
import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt';
import redis from '@/lib/redis';
@@ -79,7 +85,16 @@ export async function hasPermission(role: string, permission: string | string[])
export function parseShareToken(request: Request) {
try {
return parseToken(request.headers.get(SHARE_TOKEN_HEADER), secret());
const token: any = parseToken(request.headers.get(SHARE_TOKEN_HEADER), secret());
// Only accept tokens explicitly minted as share tokens. This prevents other
// tokens signed with the same secret (e.g. the cache token from /api/send)
// from being replayed as share tokens to gain analytics access.
if (token?.type !== SHARE_TOKEN_TYPE) {
return null;
}
return token;
} catch (e) {
log(e);
return null;
+2
View File
@@ -10,6 +10,8 @@ export const LAST_TEAM_CONFIG = 'umami.last-team';
export const VERSION_CHECK = 'umami.version-check';
export const SHARE_TOKEN_HEADER = 'x-umami-share-token';
export const SHARE_CONTEXT_HEADER = 'x-umami-share-context';
export const SHARE_TOKEN_TYPE = 'share';
export const CACHE_TOKEN_TYPE = 'cache';
export const HOMEPAGE_URL = 'https://umami.is';
export const DOCS_URL = 'https://umami.is/docs';
export const REPO_URL = 'https://github.com/umami-software/umami';