From 2a9d9780449c175f337ee5d026b5ab528eaa022c Mon Sep 17 00:00:00 2001 From: OpenInspect Date: Sat, 27 Jun 2026 06:15:54 +0000 Subject: [PATCH] fix(types): validate unsafe response casts --- package-lock.json | 3 +- .../src/control-plane-responses.test.ts | 17 +++++++++ .../github-bot/src/control-plane-responses.ts | 12 +++++++ packages/github-bot/src/handlers.ts | 15 +++++--- packages/web/package.json | 3 +- packages/web/src/lib/auth.test.ts | 21 +++++++++-- packages/web/src/lib/auth.ts | 17 ++++++--- .../web/src/lib/github-email-schema.test.ts | 35 +++++++++++++++++++ packages/web/src/lib/github-email-schema.ts | 12 +++++++ 9 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 packages/github-bot/src/control-plane-responses.test.ts create mode 100644 packages/github-bot/src/control-plane-responses.ts create mode 100644 packages/web/src/lib/github-email-schema.test.ts create mode 100644 packages/web/src/lib/github-email-schema.ts diff --git a/package-lock.json b/package-lock.json index 5474d8bd8..e2010729f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16076,7 +16076,8 @@ "sonner": "^2.0.7", "swr": "^2.4.0", "tailwind-merge": "^3.5.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^4.4.3" }, "devDependencies": { "@opennextjs/cloudflare": "1.19.11", diff --git a/packages/github-bot/src/control-plane-responses.test.ts b/packages/github-bot/src/control-plane-responses.test.ts new file mode 100644 index 000000000..4c3058ac9 --- /dev/null +++ b/packages/github-bot/src/control-plane-responses.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { createSessionResponseSchema, sendPromptResponseSchema } from "./control-plane-responses"; + +describe("control-plane response schemas", () => { + it("parses valid session and prompt responses", () => { + expect(createSessionResponseSchema.safeParse({ sessionId: "session-123" }).success).toBe(true); + expect(sendPromptResponseSchema.safeParse({ messageId: "msg-456" }).success).toBe(true); + }); + + it("rejects malformed or partial responses", () => { + expect(createSessionResponseSchema.safeParse({ sessionId: 123 }).success).toBe(false); + expect(createSessionResponseSchema.safeParse({}).success).toBe(false); + expect(sendPromptResponseSchema.safeParse({ messageId: null }).success).toBe(false); + expect(sendPromptResponseSchema.safeParse({}).success).toBe(false); + }); +}); diff --git a/packages/github-bot/src/control-plane-responses.ts b/packages/github-bot/src/control-plane-responses.ts new file mode 100644 index 000000000..8a6d15b12 --- /dev/null +++ b/packages/github-bot/src/control-plane-responses.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const createSessionResponseSchema = z.object({ + sessionId: z.string(), +}); + +export const sendPromptResponseSchema = z.object({ + messageId: z.string(), +}); + +export type CreateSessionResponse = z.infer; +export type SendPromptResponse = z.infer; diff --git a/packages/github-bot/src/handlers.ts b/packages/github-bot/src/handlers.ts index 16ec6a39d..1c0858078 100644 --- a/packages/github-bot/src/handlers.ts +++ b/packages/github-bot/src/handlers.ts @@ -9,6 +9,7 @@ import type { import type { Logger } from "./logger"; import { generateInstallationToken, postReaction, checkSenderPermission } from "./github-auth"; import { buildCodeReviewPrompt, buildCommentActionPrompt } from "./prompts"; +import { createSessionResponseSchema, sendPromptResponseSchema } from "./control-plane-responses"; import { getGitHubConfig, type ResolvedGitHubConfig } from "./utils/integration-config"; export type HandlerResult = @@ -65,8 +66,11 @@ async function createSession( const body = await response.text(); throw new Error(`Session creation failed: ${response.status} ${body}`); } - const result = (await response.json()) as { sessionId: string }; - return result.sessionId; + const result = createSessionResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new Error("Session creation failed: invalid response"); + } + return result.data.sessionId; } async function sendPrompt( @@ -84,8 +88,11 @@ async function sendPrompt( const body = await response.text(); throw new Error(`Prompt delivery failed: ${response.status} ${body}`); } - const result = (await response.json()) as { messageId: string }; - return result.messageId; + const result = sendPromptResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new Error("Prompt delivery failed: invalid response"); + } + return result.data.messageId; } function stripMention(body: string, botUsername: string): string { diff --git a/packages/web/package.json b/packages/web/package.json index 6525515df..2ca9541d8 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -51,7 +51,8 @@ "sonner": "^2.0.7", "swr": "^2.4.0", "tailwind-merge": "^3.5.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^4.4.3" }, "devDependencies": { "@opennextjs/cloudflare": "1.19.11", diff --git a/packages/web/src/lib/auth.test.ts b/packages/web/src/lib/auth.test.ts index 8f72495f4..e2b28845b 100644 --- a/packages/web/src/lib/auth.test.ts +++ b/packages/web/src/lib/auth.test.ts @@ -9,9 +9,10 @@ import { getVerifiedGitHubEmails, } from "./auth"; -vi.mock("@open-inspect/shared", () => ({ - DEFAULT_APP_NAME: "Open-Inspect", -})); +vi.mock("@open-inspect/shared", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, DEFAULT_APP_NAME: "Open-Inspect" }; +}); vi.mock("next-auth/providers/github", () => ({ default: (config: unknown) => ({ @@ -388,6 +389,20 @@ describe("getVerifiedGitHubEmails", () => { await expect(getVerifiedGitHubEmails({ accessToken: "token" })).resolves.toEqual([]); }); + it("returns empty array for malformed GitHub email responses", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify([{ email: "user@company.com", verified: true }])) + ); + + await expect(getVerifiedGitHubEmails({ accessToken: "token" })).resolves.toEqual([]); + + expect(warn).toHaveBeenCalledWith( + "[github-email-fetch] invalid response", + expect.objectContaining({ elapsedMs: expect.any(Number) }) + ); + }); + it("returns empty array when GitHub email lookup fails", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 403 })); diff --git a/packages/web/src/lib/auth.ts b/packages/web/src/lib/auth.ts index 782527746..408abd85b 100644 --- a/packages/web/src/lib/auth.ts +++ b/packages/web/src/lib/auth.ts @@ -1,7 +1,7 @@ import type { Account, NextAuthOptions, Profile, Session } from "next-auth"; import type { JWT } from "next-auth/jwt"; import GitHubProvider from "next-auth/providers/github"; -import type { GithubEmail, GithubProfile } from "next-auth/providers/github"; +import type { GithubProfile } from "next-auth/providers/github"; import GoogleProvider from "next-auth/providers/google"; import { DEFAULT_APP_NAME } from "@open-inspect/shared"; import { @@ -16,6 +16,7 @@ import { type GitHubOrganizationAccessResult, } from "./github-org-membership"; import { type AuthProvider, isAuthProvider } from "./build-auth-identity"; +import { githubEmailListSchema, type GitHubEmail } from "./github-email-schema"; const GITHUB_EMAIL_FETCH_TIMEOUT_MS = 5_000; @@ -26,7 +27,7 @@ interface GitHubEmailFetchParams { timeoutMs?: number; } -type GitHubProfileWithEmails = GithubProfile & { verifiedEmails?: GithubEmail[] }; +type GitHubProfileWithEmails = GithubProfile & { verifiedEmails?: GitHubEmail[] }; /** * Fetch verified email addresses from GitHub's API. @@ -40,7 +41,7 @@ export async function getVerifiedGitHubEmails({ fetchImpl = fetch, userAgent = "Open-Inspect", timeoutMs = GITHUB_EMAIL_FETCH_TIMEOUT_MS, -}: GitHubEmailFetchParams): Promise { +}: GitHubEmailFetchParams): Promise { if (!accessToken) return []; const controller = new AbortController(); @@ -73,8 +74,14 @@ export async function getVerifiedGitHubEmails({ }); return []; } - const emails = (await response.json()) as GithubEmail[]; - return emails.filter((e) => e.verified); + const result = githubEmailListSchema.safeParse(await response.json()); + if (!result.success) { + console.warn("[github-email-fetch] invalid response", { + elapsedMs: Math.round(performance.now() - startedAt), + }); + return []; + } + return result.data.filter((e) => e.verified); } catch (error) { console.warn("[github-email-fetch] request error", { error: error instanceof Error ? error.name : "unknown", diff --git a/packages/web/src/lib/github-email-schema.test.ts b/packages/web/src/lib/github-email-schema.test.ts new file mode 100644 index 000000000..f0e2079be --- /dev/null +++ b/packages/web/src/lib/github-email-schema.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { githubEmailListSchema } from "./github-email-schema"; + +describe("githubEmailListSchema", () => { + it("parses valid GitHub email API responses", () => { + const result = githubEmailListSchema.safeParse([ + { email: "user@example.com", primary: true, verified: true, visibility: "private" }, + ]); + + expect(result.success).toBe(true); + }); + + it("accepts nullable visibility from GitHub", () => { + const result = githubEmailListSchema.safeParse([ + { email: "user@example.com", primary: true, verified: true, visibility: null }, + ]); + + expect(result.success).toBe(true); + }); + + it("rejects malformed or partial email responses", () => { + expect(githubEmailListSchema.safeParse({ email: "user@example.com" }).success).toBe(false); + expect( + githubEmailListSchema.safeParse([ + { email: "user@example.com", primary: true, verified: true }, + ]).success + ).toBe(false); + expect( + githubEmailListSchema.safeParse([ + { email: "user@example.com", primary: true, verified: "yes", visibility: null }, + ]).success + ).toBe(false); + }); +}); diff --git a/packages/web/src/lib/github-email-schema.ts b/packages/web/src/lib/github-email-schema.ts new file mode 100644 index 000000000..585406e27 --- /dev/null +++ b/packages/web/src/lib/github-email-schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const githubEmailSchema = z.object({ + email: z.string(), + primary: z.boolean(), + verified: z.boolean(), + visibility: z.string().nullable(), +}); + +export const githubEmailListSchema = z.array(githubEmailSchema); + +export type GitHubEmail = z.infer;