From 15101a0b94ce93e69cbff8cf1ca3d37ef644ddee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 19:54:51 +0000 Subject: [PATCH 1/2] Handle invalid client launch tokens safely Co-authored-by: Neil Raina --- src/app/client/page.tsx | 19 ++++++------- src/utils/tokenPayload.test.ts | 50 ++++++++++++++++++++++++++++++++++ src/utils/tokenPayload.ts | 35 ++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 src/utils/tokenPayload.test.ts create mode 100644 src/utils/tokenPayload.ts diff --git a/src/app/client/page.tsx b/src/app/client/page.tsx index 8de9b75a2..6412b1959 100644 --- a/src/app/client/page.tsx +++ b/src/app/client/page.tsx @@ -13,12 +13,12 @@ import { SilentError } from '@/components/templates/SilentError' import { apiUrl } from '@/config' import { ClientSideStateUpdate } from '@/hoc/ClientSideStateUpdate' import { RealTime } from '@/hoc/RealTime' -import { Token, TokenSchema, UrlActionParamsType } from '@/types/common' +import { UrlActionParamsType } from '@/types/common' import { CreateAttachmentRequest } from '@/types/dto/attachments.dto' import { TaskResponse } from '@/types/dto/tasks.dto' import { WorkflowStateResponse } from '@/types/dto/workflowStates.dto' import { UserType } from '@/types/interfaces' -import { CopilotAPI } from '@/utils/CopilotAPI' +import { getSafeTokenPayload } from '@/utils/tokenPayload' import { getPreviewMode } from '@/utils/previewMode' import { redirectIfTaskCta } from '@/utils/redirect' import { UserRole } from '@api/core/types/user' @@ -43,12 +43,6 @@ async function getAllTasks(token: string): Promise { return data.tasks } -async function getTokenPayload(token: string): Promise { - const copilotClient = new CopilotAPI(token) - const payload = TokenSchema.parse(await copilotClient.getTokenPayload()) - return payload as Token -} - export default async function ClientPage(props: { searchParams: Promise<{ token: string } & UrlActionParamsType> }) { const searchParams = await props.searchParams const token = searchParams.token @@ -56,11 +50,16 @@ export default async function ClientPage(props: { searchParams: Promise<{ token: return } redirectIfTaskCta(searchParams, UserType.CLIENT_USER) - const [workflowStates, tasks, viewSettings, tokenPayload] = await Promise.all([ + + const tokenPayload = await getSafeTokenPayload({ token }) + if (!tokenPayload) { + return + } + + const [workflowStates, tasks, viewSettings] = await Promise.all([ getAllWorkflowStates(token), getAllTasks(token), getViewSettings(token), - getTokenPayload(token), ]) const previewMode = getPreviewMode(tokenPayload) diff --git a/src/utils/tokenPayload.test.ts b/src/utils/tokenPayload.test.ts new file mode 100644 index 000000000..0d14ed131 --- /dev/null +++ b/src/utils/tokenPayload.test.ts @@ -0,0 +1,50 @@ +import { mockTokenPayloads } from '@/app/api/tests/__mocks__/mockData' +import { CopilotAPI } from '@/utils/CopilotAPI' +import { getSafeTokenPayload } from '@/utils/tokenPayload' + +const mockGetTokenPayload = jest.fn() + +jest.mock('@/utils/CopilotAPI', () => ({ + CopilotAPI: jest.fn().mockImplementation(() => ({ + getTokenPayload: mockGetTokenPayload, + })), +})) + +describe('getSafeTokenPayload', () => { + beforeEach(() => { + jest.clearAllMocks() + mockGetTokenPayload.mockReset() + }) + + it('returns a valid token payload', async () => { + mockGetTokenPayload.mockResolvedValueOnce(mockTokenPayloads.client) + + await expect(getSafeTokenPayload({ token: 'client-token' })).resolves.toEqual(mockTokenPayloads.client) + expect(CopilotAPI).toHaveBeenCalledWith('client-token') + }) + + it('returns null when Copilot cannot authorize the token', async () => { + mockGetTokenPayload.mockRejectedValueOnce(new Error('Unable to authorize Copilot SDK')) + + await expect(getSafeTokenPayload({ token: 'invalid-token' })).resolves.toBeNull() + }) + + it('returns null when Copilot returns an auth failure status', async () => { + mockGetTokenPayload.mockRejectedValueOnce({ status: 401 }) + + await expect(getSafeTokenPayload({ token: 'invalid-token' })).resolves.toBeNull() + }) + + it('returns null for malformed token payloads', async () => { + mockGetTokenPayload.mockResolvedValueOnce({ workspaceId: 123 }) + + await expect(getSafeTokenPayload({ token: 'malformed-token' })).resolves.toBeNull() + }) + + it('throws unexpected errors', async () => { + const error = new Error('network unavailable') + mockGetTokenPayload.mockRejectedValueOnce(error) + + await expect(getSafeTokenPayload({ token: 'client-token' })).rejects.toThrow(error) + }) +}) diff --git a/src/utils/tokenPayload.ts b/src/utils/tokenPayload.ts new file mode 100644 index 000000000..0202c9fb7 --- /dev/null +++ b/src/utils/tokenPayload.ts @@ -0,0 +1,35 @@ +import { Token, TokenSchema } from '@/types/common' +import { CopilotAPI } from '@/utils/CopilotAPI' +import { ZodError } from 'zod' + +type GetSafeTokenPayloadArgs = { + token: string +} + +const getErrorStatus = (error: unknown): number | null => { + if (!error || typeof error !== 'object') return null + + const status = 'status' in error ? error.status : undefined + if (typeof status === 'number') return status + + const statusCode = 'statusCode' in error ? error.statusCode : undefined + return typeof statusCode === 'number' ? statusCode : null +} + +const isTokenAuthorizationError = (error: unknown): boolean => { + const status = getErrorStatus(error) + if (status && [401, 403].includes(status)) return true + + return error instanceof ZodError || (error instanceof Error && error.message.includes('Unable to authorize Copilot SDK')) +} + +export async function getSafeTokenPayload({ token }: GetSafeTokenPayloadArgs): Promise { + try { + const payload = await new CopilotAPI(token).getTokenPayload() + const parsedPayload = TokenSchema.safeParse(payload) + return parsedPayload.success ? parsedPayload.data : null + } catch (error: unknown) { + if (isTokenAuthorizationError(error)) return null + throw error + } +} From 332aa05c2062a4288b629711b95fc12e2465d2c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 20:01:07 +0000 Subject: [PATCH 2/2] Short-circuit malformed Copilot launch tokens Co-authored-by: Neil Raina --- src/utils/tokenPayload.test.ts | 18 ++++++++++++------ src/utils/tokenPayload.ts | 7 +++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/utils/tokenPayload.test.ts b/src/utils/tokenPayload.test.ts index 0d14ed131..787dea3ef 100644 --- a/src/utils/tokenPayload.test.ts +++ b/src/utils/tokenPayload.test.ts @@ -3,6 +3,7 @@ import { CopilotAPI } from '@/utils/CopilotAPI' import { getSafeTokenPayload } from '@/utils/tokenPayload' const mockGetTokenPayload = jest.fn() +const launchToken = 'a'.repeat(64) jest.mock('@/utils/CopilotAPI', () => ({ CopilotAPI: jest.fn().mockImplementation(() => ({ @@ -19,32 +20,37 @@ describe('getSafeTokenPayload', () => { it('returns a valid token payload', async () => { mockGetTokenPayload.mockResolvedValueOnce(mockTokenPayloads.client) - await expect(getSafeTokenPayload({ token: 'client-token' })).resolves.toEqual(mockTokenPayloads.client) - expect(CopilotAPI).toHaveBeenCalledWith('client-token') + await expect(getSafeTokenPayload({ token: launchToken })).resolves.toEqual(mockTokenPayloads.client) + expect(CopilotAPI).toHaveBeenCalledWith(launchToken) + }) + + it('returns null without constructing the SDK for impossible token strings', async () => { + await expect(getSafeTokenPayload({ token: 'invalid-token' })).resolves.toBeNull() + expect(CopilotAPI).not.toHaveBeenCalled() }) it('returns null when Copilot cannot authorize the token', async () => { mockGetTokenPayload.mockRejectedValueOnce(new Error('Unable to authorize Copilot SDK')) - await expect(getSafeTokenPayload({ token: 'invalid-token' })).resolves.toBeNull() + await expect(getSafeTokenPayload({ token: launchToken })).resolves.toBeNull() }) it('returns null when Copilot returns an auth failure status', async () => { mockGetTokenPayload.mockRejectedValueOnce({ status: 401 }) - await expect(getSafeTokenPayload({ token: 'invalid-token' })).resolves.toBeNull() + await expect(getSafeTokenPayload({ token: launchToken })).resolves.toBeNull() }) it('returns null for malformed token payloads', async () => { mockGetTokenPayload.mockResolvedValueOnce({ workspaceId: 123 }) - await expect(getSafeTokenPayload({ token: 'malformed-token' })).resolves.toBeNull() + await expect(getSafeTokenPayload({ token: launchToken })).resolves.toBeNull() }) it('throws unexpected errors', async () => { const error = new Error('network unavailable') mockGetTokenPayload.mockRejectedValueOnce(error) - await expect(getSafeTokenPayload({ token: 'client-token' })).rejects.toThrow(error) + await expect(getSafeTokenPayload({ token: launchToken })).rejects.toThrow(error) }) }) diff --git a/src/utils/tokenPayload.ts b/src/utils/tokenPayload.ts index 0202c9fb7..bc49f7a8a 100644 --- a/src/utils/tokenPayload.ts +++ b/src/utils/tokenPayload.ts @@ -6,6 +6,11 @@ type GetSafeTokenPayloadArgs = { token: string } +const COPILOT_TOKEN_PATTERN = /^[0-9a-f]+$/i + +const isPotentialCopilotLaunchToken = (token: string): boolean => + token.length >= 64 && token.length % 32 === 0 && COPILOT_TOKEN_PATTERN.test(token) + const getErrorStatus = (error: unknown): number | null => { if (!error || typeof error !== 'object') return null @@ -24,6 +29,8 @@ const isTokenAuthorizationError = (error: unknown): boolean => { } export async function getSafeTokenPayload({ token }: GetSafeTokenPayloadArgs): Promise { + if (!isPotentialCopilotLaunchToken(token)) return null + try { const payload = await new CopilotAPI(token).getTokenPayload() const parsedPayload = TokenSchema.safeParse(payload)