diff --git a/src/app/api/core/utils/authenticate.ts b/src/app/api/core/utils/authenticate.ts index bf70d038a..e5ebd9c33 100644 --- a/src/app/api/core/utils/authenticate.ts +++ b/src/app/api/core/utils/authenticate.ts @@ -5,6 +5,7 @@ import User from '@api/core/models/User.model' import httpStatus from 'http-status' import { NextRequest } from 'next/server' import { z } from 'zod' +import { isNilUuid } from '@/utils/uuid' import { withRetry } from './withRetry' import * as Sentry from '@sentry/nextjs' @@ -22,6 +23,10 @@ export const _authenticateWithToken = async (token: string, customApiKey?: strin throw new APIError(httpStatus.UNAUTHORIZED, 'Failed to authenticate token') } + if (isNilUuid(payload.data.internalUserId) || isNilUuid(payload.data.clientId)) { + throw new APIError(httpStatus.UNAUTHORIZED, 'Failed to authenticate token') + } + // Set sentry user for better tracking Sentry.setUser({ id: payload.data.internalUserId || `${payload.data.clientId}:${payload.data.companyId}` }) diff --git a/src/app/api/tests/utils/authenticate.test.ts b/src/app/api/tests/utils/authenticate.test.ts index 0fc9d8aae..0ae0e6963 100644 --- a/src/app/api/tests/utils/authenticate.test.ts +++ b/src/app/api/tests/utils/authenticate.test.ts @@ -50,6 +50,24 @@ describe('authenticate util', () => { } }) + it('throws APIError when token payload contains nil UUID user ids', async () => { + const { CopilotAPI } = jest.requireMock('@/utils/CopilotAPI') as { + CopilotAPI: jest.Mock + } + CopilotAPI.mockImplementationOnce(() => ({ + getTokenPayload: jest.fn().mockResolvedValue({ + internalUserId: '00000000-0000-0000-0000-000000000000', + workspaceId: 'workspace-id', + }), + })) + + const req = buildNextRequest(`/?token=nil-uuid-token`) + await expect(authenticate(req)).rejects.toMatchObject({ + status: httpStatus.UNAUTHORIZED, + message: 'Failed to authenticate token', + }) + }) + it('captures assembly metadata headers when present', async () => { const req = new NextRequest( new Request(process.env.VERCEL_URL + '/?token=iu-token', { diff --git a/src/utils/CopilotAPI.nil-uuid.test.ts b/src/utils/CopilotAPI.nil-uuid.test.ts new file mode 100644 index 000000000..88454c743 --- /dev/null +++ b/src/utils/CopilotAPI.nil-uuid.test.ts @@ -0,0 +1,61 @@ +import APIError from '@/app/api/core/exceptions/api' +import httpStatus from 'http-status' +import { NIL_UUID } from '@/utils/uuid' + +const mockRetrieveInternalUser = jest.fn() +const mockRetrieveClient = jest.fn() + +jest.mock('@/app/api/core/utils/withRetry', () => ({ + withRetry: async (fn: (...args: unknown[]) => Promise, args: unknown[]) => fn(...args), + RETRY_404_ENABLED: false, +})) + +jest.mock('copilot-node-sdk', () => ({ + copilotApi: () => ({ + retrieveInternalUser: mockRetrieveInternalUser, + retrieveClient: mockRetrieveClient, + getTokenPayload: jest.fn(), + }), +})) + +import { CopilotAPI } from '@/utils/CopilotAPI' + +describe('CopilotAPI nil UUID guards', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('rejects nil UUID for getInternalUser without calling Copilot', async () => { + const copilot = new CopilotAPI('token') + + await expect(copilot.getInternalUser(NIL_UUID)).rejects.toBeInstanceOf(APIError) + await expect(copilot.getInternalUser(NIL_UUID)).rejects.toMatchObject({ + status: httpStatus.BAD_REQUEST, + message: 'Invalid internal user id', + }) + expect(mockRetrieveInternalUser).not.toHaveBeenCalled() + }) + + it('rejects nil UUID for getClient without calling Copilot', async () => { + const copilot = new CopilotAPI('token') + + await expect(copilot.getClient(NIL_UUID)).rejects.toBeInstanceOf(APIError) + await expect(copilot.getClient(NIL_UUID)).rejects.toMatchObject({ + status: httpStatus.BAD_REQUEST, + message: 'Invalid client id', + }) + expect(mockRetrieveClient).not.toHaveBeenCalled() + }) + + it('returns null from me when token id is nil UUID', async () => { + const copilot = new CopilotAPI('token') + jest.spyOn(copilot, 'getTokenPayload').mockResolvedValue({ + internalUserId: NIL_UUID, + workspaceId: 'workspace-id', + }) + + await expect(copilot.me()).resolves.toBeNull() + expect(mockRetrieveInternalUser).not.toHaveBeenCalled() + expect(mockRetrieveClient).not.toHaveBeenCalled() + }) +}) diff --git a/src/utils/CopilotAPI.ts b/src/utils/CopilotAPI.ts index eed475e79..09f45e0dc 100644 --- a/src/utils/CopilotAPI.ts +++ b/src/utils/CopilotAPI.ts @@ -45,6 +45,7 @@ import { DISPATCHABLE_EVENT } from '@/types/webhook' import Bottleneck from 'bottleneck' import type { CopilotAPI as SDK } from 'copilot-node-sdk' import { copilotApi } from 'copilot-node-sdk' +import { isNilUuid } from '@/utils/uuid' import { cache } from 'react' import { z } from 'zod' @@ -116,7 +117,7 @@ export class CopilotAPI { console.info('CopilotAPI#_me', this.token) const tokenPayload = await this.getTokenPayload() const id = tokenPayload?.internalUserId || tokenPayload?.clientId - if (!tokenPayload || !id) return null + if (!tokenPayload || !id || isNilUuid(id)) return null const retrieveCurrentUserInfo = tokenPayload.internalUserId ? this.copilot.retrieveInternalUser @@ -153,6 +154,9 @@ export class CopilotAPI { } async _getClient(id: string): Promise { + if (isNilUuid(id)) { + throw new APIError(httpStatus.BAD_REQUEST, 'Invalid client id') + } console.info('CopilotAPI#_getClient', this.token) return ClientResponseSchema.parse(await this.copilot.retrieveClient({ id })) } @@ -228,6 +232,9 @@ export class CopilotAPI { } async _getInternalUser(id: string): Promise { + if (isNilUuid(id)) { + throw new APIError(httpStatus.BAD_REQUEST, 'Invalid internal user id') + } console.info('CopilotAPI#_getInternalUser', this.token) return InternalUsersSchema.parse(await this.copilot.retrieveInternalUser({ id })) } diff --git a/src/utils/uuid.test.ts b/src/utils/uuid.test.ts new file mode 100644 index 000000000..60b3f084a --- /dev/null +++ b/src/utils/uuid.test.ts @@ -0,0 +1,14 @@ +import { NIL_UUID, isNilUuid } from '@/utils/uuid' + +describe('uuid utils', () => { + it('identifies the nil UUID', () => { + expect(isNilUuid(NIL_UUID)).toBe(true) + }) + + it('rejects real and empty ids', () => { + expect(isNilUuid('591aaab2-f128-419e-8f40-65fb83e71a5e')).toBe(false) + expect(isNilUuid('')).toBe(false) + expect(isNilUuid(null)).toBe(false) + expect(isNilUuid(undefined)).toBe(false) + }) +}) diff --git a/src/utils/uuid.ts b/src/utils/uuid.ts new file mode 100644 index 000000000..ac4e8fb72 --- /dev/null +++ b/src/utils/uuid.ts @@ -0,0 +1,3 @@ +export const NIL_UUID = '00000000-0000-0000-0000-000000000000' as const + +export const isNilUuid = (id: string | null | undefined): id is typeof NIL_UUID => id === NIL_UUID