From 8bc49a29e3b3a1d1cca4d3e91d60a548f3c51298 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Tue, 2 Jun 2026 14:44:42 -0700 Subject: [PATCH] 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. --- src/app/api/send/route.ts | 9 ++++++--- src/app/api/share/[slug]/route.ts | 4 ++-- src/lib/auth.ts | 19 +++++++++++++++++-- src/lib/constants.ts | 2 ++ 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index be0220bfe..d90db01d8 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -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) { diff --git a/src/app/api/share/[slug]/route.ts b/src/app/api/share/[slug]/route.ts index 4bb79fcf5..933ae6e48 100644 --- a/src/app/api/share/[slug]/route.ts +++ b/src/app/api/share/[slug]/route.ts @@ -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); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 49de033ec..1c4eab844 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 701df99c5..6a85c082f 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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';