From a7d87fc5be71927f5e57b8bd715b066047484fee Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Sat, 22 Nov 2025 00:13:09 +0530 Subject: [PATCH] fix: Proxy Mintlify traffic through a Next.js API route (#25320) * hotfix * type fix and test fix * update env example * improvements * more fix * tada --------- Co-authored-by: Keith Williams --- .env.example | 3 +- .../api/mintlify-chat/__tests__/route.test.ts | 470 ++++++++++++++++++ .../app/api/mintlify-chat/message/route.ts | 133 +++++ apps/web/app/api/mintlify-chat/topic/route.ts | 48 ++ packages/features/kbar/Kbar.tsx | 9 +- packages/features/mintlify-chat/util.ts | 88 ++-- packages/lib/server/mintlifyChatValidation.ts | 147 ++++++ turbo.json | 3 +- 8 files changed, 853 insertions(+), 48 deletions(-) create mode 100644 apps/web/app/api/mintlify-chat/__tests__/route.test.ts create mode 100644 apps/web/app/api/mintlify-chat/message/route.ts create mode 100644 apps/web/app/api/mintlify-chat/topic/route.ts create mode 100644 packages/lib/server/mintlifyChatValidation.ts diff --git a/.env.example b/.env.example index 9e200d524a..be79e3ed26 100644 --- a/.env.example +++ b/.env.example @@ -425,7 +425,8 @@ VAPID_PRIVATE_KEY= # Mintlify chat api # Power AI chat in for docs -NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY= +MINTLIFY_CHAT_API_KEY= +NEXT_PUBLIC_ENABLE_MINTLIFY_CHAT= NEXT_PUBLIC_CHAT_API_URL= NEXT_PUBLIC_DOCS_URL= diff --git a/apps/web/app/api/mintlify-chat/__tests__/route.test.ts b/apps/web/app/api/mintlify-chat/__tests__/route.test.ts new file mode 100644 index 0000000000..839e194558 --- /dev/null +++ b/apps/web/app/api/mintlify-chat/__tests__/route.test.ts @@ -0,0 +1,470 @@ +import type { NextRequest } from "next/server"; + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import * as mintlifyChatValidation from "@calcom/lib/server/mintlifyChatValidation"; + +import { POST as topicPOST } from "../topic/route"; +import { POST as messagePOST } from "../message/route"; + +// Mock fetch +global.fetch = vi.fn(); + +// Polyfill Response.json for test environment +if (!Response.json) { + Response.json = function (data: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(data), { + ...init, + headers: { + "Content-Type": "application/json", + ...init?.headers, + }, + }); + }; +} + +describe("Mintlify Chat Proxy Endpoints", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Set environment variables + process.env.MINTLIFY_CHAT_API_KEY = "test-api-key"; + process.env.NEXT_PUBLIC_CHAT_API_URL = "https://api.mintlify.com"; + }); + + afterEach(() => { + delete process.env.MINTLIFY_CHAT_API_KEY; + delete process.env.NEXT_PUBLIC_CHAT_API_URL; + }); + + describe("POST /api/mintlify-chat/topic", () => { + it("should create a topic successfully", async () => { + const mockTopicId = "test-topic-123"; + + // Mock Mintlify API response + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ topicId: mockTopicId }), + }); + + const request = new Request("http://localhost:3000/api/mintlify-chat/topic", { + method: "POST", + }) as NextRequest; + + const response = await topicPOST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.topicId).toBe(mockTopicId); + + // Verify fetch was called with correct parameters + expect(global.fetch).toHaveBeenCalledWith( + "https://api.mintlify.com/topic", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer test-api-key", + }, + } + ); + }); + + it("should handle missing API key", async () => { + delete process.env.MINTLIFY_CHAT_API_KEY; + + const request = new Request("http://localhost:3000/api/mintlify-chat/topic", { + method: "POST", + }) as NextRequest; + + const response = await topicPOST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + // Generic error message - doesn't leak internal config details + expect(data.error).toBe("Failed to create topic. Please try again later."); + }); + + it("should handle Mintlify API errors", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + const request = new Request("http://localhost:3000/api/mintlify-chat/topic", { + method: "POST", + }) as NextRequest; + + const response = await topicPOST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe("Failed to create topic"); + }); + + it("should never expose API key in response", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ topicId: "test-topic-123" }), + }); + + const request = new Request("http://localhost:3000/api/mintlify-chat/topic", { + method: "POST", + }) as NextRequest; + + const response = await topicPOST(request); + const responseText = await response.text(); + + expect(responseText).not.toContain("test-api-key"); + expect(responseText).not.toContain("Bearer"); + }); + }); + + describe("POST /api/mintlify-chat/message", () => { + it("should send a message and stream response", async () => { + const mockMessage = "What is Cal.com?"; + const mockTopicId = "test-topic-123"; + const mockResponse = "Cal.com is a scheduling platform."; + + // Create a mock ReadableStream + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(mockResponse)); + controller.close(); + }, + }); + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + body: mockStream, + headers: new Headers({ + "content-type": "text/plain", + "x-mintlify-base-url": "https://docs.cal.com", + }), + }); + + const request = new Request("http://localhost:3000/api/mintlify-chat/message", { + method: "POST", + body: JSON.stringify({ + message: mockMessage, + topicId: mockTopicId, + }), + }) as NextRequest; + + const response = await messagePOST(request); + + expect(response.status).toBe(200); + expect(response.headers.get("x-mintlify-base-url")).toBe("https://docs.cal.com"); + + // Verify fetch was called with sanitized input + expect(global.fetch).toHaveBeenCalledWith( + "https://api.mintlify.com/message", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer test-api-key", + }, + body: JSON.stringify({ + message: mockMessage, + topicId: mockTopicId, + }), + } + ); + }); + + it("should reject invalid JSON payload", async () => { + const request = new Request("http://localhost:3000/api/mintlify-chat/message", { + method: "POST", + body: "invalid json", + }) as NextRequest; + + const response = await messagePOST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Invalid JSON payload"); + }); + + it("should reject malformed message payload", async () => { + const request = new Request("http://localhost:3000/api/mintlify-chat/message", { + method: "POST", + body: JSON.stringify({ + message: "", + topicId: "test-topic", + }), + }) as NextRequest; + + const response = await messagePOST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("Invalid message"); + }); + + it("should reject oversized messages", async () => { + const largeMessage = "a".repeat(10001); // Exceeds MAX_MESSAGE_LENGTH + + const request = new Request("http://localhost:3000/api/mintlify-chat/message", { + method: "POST", + body: JSON.stringify({ + message: largeMessage, + topicId: "test-topic", + }), + }) as NextRequest; + + const response = await messagePOST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("exceeds maximum length"); + }); + + it("should reject path traversal in topicId", async () => { + const request = new Request("http://localhost:3000/api/mintlify-chat/message", { + method: "POST", + body: JSON.stringify({ + message: "test message", + topicId: "../../../etc/passwd", + }), + }) as NextRequest; + + const response = await messagePOST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("Path traversal"); + }); + + it("should reject control characters in message", async () => { + const maliciousMessage = "test\x00message\x01with\x02control\x03chars"; + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("sanitized response")); + controller.close(); + }, + }); + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + body: mockStream, + headers: new Headers(), + }); + + const request = new Request("http://localhost:3000/api/mintlify-chat/message", { + method: "POST", + body: JSON.stringify({ + message: maliciousMessage, + topicId: "test-topic-123", + }), + }) as NextRequest; + + const response = await messagePOST(request); + + // Should either sanitize (200) or reject with validation error (400) + expect([200, 400]).toContain(response.status); + + if (response.status === 200) { + // If sanitized, verify control chars were removed from the proxied request + const fetchCall = (global.fetch as ReturnType).mock.calls[0]; + const bodyArg = fetchCall?.[1]?.body; + if (typeof bodyArg === "string") { + const parsedBody = JSON.parse(bodyArg); + expect(parsedBody.message).not.toContain("\x00"); + expect(parsedBody.message).not.toContain("\x01"); + } + } + }); + + it("should reject invalid topicId format", async () => { + const request = new Request("http://localhost:3000/api/mintlify-chat/message", { + method: "POST", + body: JSON.stringify({ + message: "test message", + topicId: "invalid topic id with spaces!@#$", + }), + }) as NextRequest; + + const response = await messagePOST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("Invalid topicId"); + }); + + it("should sanitize response headers", async () => { + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("response")); + controller.close(); + }, + }); + + const mockHeaders = new Headers({ + "content-type": "text/plain", + "x-mintlify-base-url": "https://docs.cal.com", + "x-custom-dangerous-header": "should-not-appear", + "authorization": "Bearer secret-key", + }); + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + body: mockStream, + headers: mockHeaders, + }); + + const request = new Request("http://localhost:3000/api/mintlify-chat/message", { + method: "POST", + body: JSON.stringify({ + message: "test", + topicId: "test-topic-123", + }), + }) as NextRequest; + + const response = await messagePOST(request); + + // Verify only safe headers are included + expect(response.headers.get("x-mintlify-base-url")).toBe("https://docs.cal.com"); + expect(response.headers.get("x-custom-dangerous-header")).toBeNull(); + expect(response.headers.get("authorization")).toBeNull(); + }); + + it("should never expose API key in any response", async () => { + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("response text")); + controller.close(); + }, + }); + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + body: mockStream, + headers: new Headers(), + }); + + const request = new Request("http://localhost:3000/api/mintlify-chat/message", { + method: "POST", + body: JSON.stringify({ + message: "test", + topicId: "test-topic-123", + }), + }) as NextRequest; + + const response = await messagePOST(request); + + // Verify no API key in response headers + expect(response.headers.get("authorization")).toBeNull(); + expect(response.headers.get("Authorization")).toBeNull(); + + // Read response text (for non-stream responses in test env) + const responseText = await response.text(); + expect(responseText).not.toContain("test-api-key"); + expect(responseText).not.toContain("Bearer"); + }); + + it("should handle missing message field", async () => { + const request = new Request("http://localhost:3000/api/mintlify-chat/message", { + method: "POST", + body: JSON.stringify({ + topicId: "test-topic-123", + }), + }) as NextRequest; + + const response = await messagePOST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("Invalid message"); + }); + + it("should handle missing topicId field", async () => { + const request = new Request("http://localhost:3000/api/mintlify-chat/message", { + method: "POST", + body: JSON.stringify({ + message: "test message", + }), + }) as NextRequest; + + const response = await messagePOST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("Invalid topicId"); + }); + }); + + describe("Security Tests", () => { + it("should never leak API key through error messages", async () => { + (global.fetch as ReturnType).mockRejectedValueOnce( + new Error("Upstream error with API key: test-api-key") + ); + + const request = new Request("http://localhost:3000/api/mintlify-chat/topic", { + method: "POST", + }) as NextRequest; + + const response = await topicPOST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + // Verify generic error message doesn't contain API key + expect(JSON.stringify(data)).not.toContain("test-api-key"); + expect(data.error).toBe("Failed to create topic. Please try again later."); + }); + + it("should validate environment variables on every request", async () => { + const validateSpy = vi.spyOn(mintlifyChatValidation, "validateMintlifyConfig"); + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ topicId: "test" }), + }); + + const request = new Request("http://localhost:3000/api/mintlify-chat/topic", { + method: "POST", + }) as NextRequest; + + await topicPOST(request); + + expect(validateSpy).toHaveBeenCalled(); + }); + + it("should validate message payload on every request", async () => { + const validateSpy = vi.spyOn(mintlifyChatValidation, "validateChatMessage"); + + const mockStream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + body: mockStream, + headers: new Headers(), + }); + + const request = new Request("http://localhost:3000/api/mintlify-chat/message", { + method: "POST", + body: JSON.stringify({ + message: "test", + topicId: "test-topic-123", + }), + }) as NextRequest; + + await messagePOST(request); + + expect(validateSpy).toHaveBeenCalled(); + }); + }); +}); + diff --git a/apps/web/app/api/mintlify-chat/message/route.ts b/apps/web/app/api/mintlify-chat/message/route.ts new file mode 100644 index 0000000000..c147c87b91 --- /dev/null +++ b/apps/web/app/api/mintlify-chat/message/route.ts @@ -0,0 +1,133 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +import { + validateChatMessage, + validateMintlifyConfig, + sanitizeResponseHeaders, +} from "@calcom/lib/server/mintlifyChatValidation"; + +/** + * POST /api/mintlify-chat/message + * + * Sends a message to a Mintlify chat topic and streams the response. + * This endpoint proxies requests to Mintlify's API while: + * - Keeping the API key secure on the server side + * - Validating and sanitizing user input + * - Preventing malicious payloads + * + * Expected body: + * { + * message: string, + * topicId: string + * } + */ +export async function POST(req: NextRequest) { + try { + // Validate configuration + const { apiKey, apiBaseUrl } = validateMintlifyConfig(); + + // Parse and validate request body + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON payload" }, + { status: 400 } + ); + } + + // Validate and sanitize the message payload + let message: string; + let topicId: string; + + try { + const validated = validateChatMessage(body); + message = validated.message; + topicId = validated.topicId; + } catch (validationError) { + // For validation errors, return detailed message with 400 + if (validationError instanceof Error) { + return NextResponse.json( + { error: validationError.message }, + { status: 400 } + ); + } + return NextResponse.json( + { error: "Invalid request" }, + { status: 400 } + ); + } + + // Make request to Mintlify + const messageUrl = `${apiBaseUrl}/message`; + console.log("[Mintlify Chat Message] Fetching message endpoint", { messageUrl, topicId }); + + const queryResponse = await fetch(messageUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ message, topicId }), + }); + + if (!queryResponse.ok) { + return NextResponse.json( + { error: "Failed to send message" }, + { status: queryResponse.status } + ); + } + + if (!queryResponse.body) { + return NextResponse.json( + { error: "No response body from Mintlify" }, + { status: 500 } + ); + } + + // Sanitize response headers before forwarding + const sanitizedHeaders = sanitizeResponseHeaders(queryResponse.headers); + + // Stream the response back to the client + // We need to pipe the ReadableStream from Mintlify directly to the client + const stream = new ReadableStream({ + async start(controller) { + const reader = queryResponse.body!.getReader(); + + try { + for (;;) { + const { done, value } = await reader.read(); + + if (done) { + controller.close(); + break; + } + + controller.enqueue(value); + } + } catch (error) { + console.error("[Mintlify Chat Message Stream]", error); + controller.error(error); + } + }, + }); + + return new NextResponse(stream, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Transfer-Encoding": "chunked", + ...sanitizedHeaders, + }, + }); + } catch { + // Return generic error message to avoid leaking internal details + // Validation errors are already handled above with specific messages + return NextResponse.json( + { error: "Failed to send message. Please try again later." }, + { status: 500 } + ); + } +} + diff --git a/apps/web/app/api/mintlify-chat/topic/route.ts b/apps/web/app/api/mintlify-chat/topic/route.ts new file mode 100644 index 0000000000..2438efd7c0 --- /dev/null +++ b/apps/web/app/api/mintlify-chat/topic/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +import { validateMintlifyConfig } from "@calcom/lib/server/mintlifyChatValidation"; + +/** + * POST /api/mintlify-chat/topic + * + * Creates a new Mintlify chat topic. + * This endpoint proxies requests to Mintlify's API while keeping + * the API key secure on the server side. + */ +export async function POST(_req: NextRequest) { + try { + // Validate configuration + const { apiKey, apiBaseUrl } = validateMintlifyConfig(); + + // Make request to Mintlify + const topicUrl = `${apiBaseUrl}/topic`; + console.log("[Mintlify Chat Topic] Fetching topic endpoint", { topicUrl }); + + const topicResponse = await fetch(topicUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!topicResponse.ok) { + return NextResponse.json( + { error: "Failed to create topic" }, + { status: topicResponse.status } + ); + } + + const topic = await topicResponse.json(); + + return NextResponse.json(topic); + } catch { + // Return generic error message to avoid leaking internal details + return NextResponse.json( + { error: "Failed to create topic. Please try again later." }, + { status: 500 } + ); + } +} + diff --git a/packages/features/kbar/Kbar.tsx b/packages/features/kbar/Kbar.tsx index be5c647a67..f541ddf6b8 100644 --- a/packages/features/kbar/Kbar.tsx +++ b/packages/features/kbar/Kbar.tsx @@ -17,7 +17,6 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { isMac } from "@calcom/lib/isMac"; import { trpc } from "@calcom/trpc/react"; -import type { RouterOutputs } from "@calcom/trpc/react"; import { Icon } from "@calcom/ui/components/icon"; import { Tooltip } from "@calcom/ui/components/tooltip"; @@ -27,9 +26,6 @@ type shortcutArrayType = { shortcuts?: string[]; }; -type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"]; -type EventTypeGroup = EventTypeGroups[number]; - const getApps = Object.values(appStoreMetadata).map(({ name, slug }) => ({ id: slug, name, @@ -242,7 +238,6 @@ export const KBarRoot = ({ children }: { children: React.ReactNode }) => { }, ...appStoreActions, ]; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return {children}; @@ -253,7 +248,9 @@ export const KBarContent = () => { useEventTypesAction(); const [inputText, setInputText] = useState(""); const [aiResponse, setAiResponse] = useState(""); - const showAiChat = process.env.NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY && process.env.NEXT_PUBLIC_CHAT_API_URL; + const showAiChat = + process.env.NEXT_PUBLIC_ENABLE_MINTLIFY_CHAT === "true" && + !!process.env.NEXT_PUBLIC_CHAT_API_URL; return ( diff --git a/packages/features/mintlify-chat/util.ts b/packages/features/mintlify-chat/util.ts index c73fbf616d..adfb7bb1cc 100644 --- a/packages/features/mintlify-chat/util.ts +++ b/packages/features/mintlify-chat/util.ts @@ -1,35 +1,45 @@ /** * This file contains utility functions for interacting with the Mintlify chat API. * The code was adapted from https://mintlify.com/docs/advanced/rest-api/overview#getting-started. The original source can be found at https://github.com/mintlify/discovery-api-example/tree/main/src/utils. + * + * NOTE: These functions now call our internal proxy API routes at /api/mintlify-chat/* + * instead of calling Mintlify directly. This keeps the API key secure on the server side. */ -const API_KEY = process.env.NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY; -const apiBaseUrl = process.env.NEXT_PUBLIC_CHAT_API_URL; - +/** + * Creates a new Mintlify chat topic via our internal proxy + */ export const createChat = async () => { - if (!API_KEY || !apiBaseUrl) return; + try { + const topicResponse = await fetch("/api/mintlify-chat/topic", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); - const topicResponse = await fetch(`${apiBaseUrl}/topic`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${API_KEY}`, - }, - }); + if (!topicResponse.ok) { + console.error("Failed to create chat topic:", topicResponse.statusText); + return; + } - if (!topicResponse.ok) { - return; - } + const topic: unknown = await topicResponse.json(); - const topic: unknown = await topicResponse.json(); - - if (topic && typeof topic === "object" && "topicId" in topic && typeof topic.topicId === "string") { - return topic.topicId; - } else { + if (topic && typeof topic === "object" && "topicId" in topic && typeof topic.topicId === "string") { + return topic.topicId; + } else { + return undefined; + } + } catch (error) { + console.error("Error creating chat topic:", error); return undefined; } }; +/** + * Generates a response from Mintlify via our internal proxy + * Streams the response back to the caller in chunks + */ export const generateResponse = async ({ topicId, userQuery, @@ -39,37 +49,35 @@ export const generateResponse = async ({ userQuery: string; onChunkReceived: (chunk: string, baseUrl?: string, finalChunk?: boolean) => void; }) => { - if (!API_KEY || !apiBaseUrl) return; - - const queryResponse = await fetch( - ` -${apiBaseUrl}/message`, - { + try { + const queryResponse = await fetch("/api/mintlify-chat/message", { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${API_KEY}`, }, body: JSON.stringify({ message: userQuery, topicId }), + }); + + if (!queryResponse.ok || !queryResponse.body) { + throw Error(queryResponse.statusText); } - ); + + const streamReader = queryResponse.body.getReader(); - if (!queryResponse.ok || !queryResponse.body) { - throw Error(queryResponse.statusText); - } - const streamReader = queryResponse.body.getReader(); + for (;;) { + const { done, value } = await streamReader.read(); + if (done) { + onChunkReceived("", queryResponse.headers.get("x-mintlify-base-url") ?? "", true); + return; + } + - for (;;) { - const { done, value } = await streamReader.read(); - if (done) { const newValue = new TextDecoder().decode(value); - - onChunkReceived(newValue, queryResponse.headers.get("X-Mintlify-Base-Url") ?? "", true); - return; + onChunkReceived(newValue); } - - const newValue = new TextDecoder().decode(value); - onChunkReceived(newValue); + } catch (error) { + console.error("Error generating response:", error); + throw error; } }; diff --git a/packages/lib/server/mintlifyChatValidation.ts b/packages/lib/server/mintlifyChatValidation.ts new file mode 100644 index 0000000000..f61781ec7d --- /dev/null +++ b/packages/lib/server/mintlifyChatValidation.ts @@ -0,0 +1,147 @@ +/** + * Validation and sanitization helpers for Mintlify chat proxy + * + * This module provides security utilities to validate user input before + * proxying requests to the Mintlify API, preventing: + * - Path traversal attacks + * - Control character injection + * - Malicious payloads + * - Oversized requests + */ + +const MAX_MESSAGE_LENGTH = 10000; // 10KB max message length +const MAX_TOPIC_ID_LENGTH = 200; + +const PATH_TRAVERSAL_PATTERNS = ["../", "..\\"]; // basic sequences we never expect inside identifiers + +const stripControlCharacters = (input: string) => { + if (typeof input !== "string") { + throw new Error("Input must be a string"); + } + + // Remove null bytes + let sanitized = input.replace(/\0/g, ""); + + // Remove other control characters (except newlines, tabs, carriage returns which might be needed in messages) + // eslint-disable-next-line no-control-regex + sanitized = sanitized.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, ""); + + return sanitized; +}; + +/** + * Sanitizes free-form user messages by stripping control characters. + * This intentionally does not reject path-like substrings so users can ask about "../". + */ +export function sanitizeMessageString(input: string): string { + return stripControlCharacters(input); +} + +/** + * Sanitizes identifiers (topic IDs) and rejects path traversal attempts. + */ +export function sanitizeTopicIdentifier(input: string): string { + const sanitized = stripControlCharacters(input); + + // Check for path traversal attempts + if (PATH_TRAVERSAL_PATTERNS.some((pattern) => sanitized.includes(pattern))) { + throw new Error("Path traversal detected"); + } + + return sanitized; +} + +/** + * Validates a chat message payload for the /message endpoint + */ +export function validateChatMessage(payload: unknown): { + message: string; + topicId: string; +} { + if (!payload || typeof payload !== "object") { + throw new Error("Invalid payload: must be an object"); + } + + const { message, topicId } = payload as Record; + + // Validate message + if (typeof message !== "string") { + throw new Error("Invalid message: must be a string"); + } + + if (message.length === 0) { + throw new Error("Invalid message: cannot be empty"); + } + + if (message.length > MAX_MESSAGE_LENGTH) { + throw new Error(`Invalid message: exceeds maximum length of ${MAX_MESSAGE_LENGTH} characters`); + } + + // Validate topicId + if (typeof topicId !== "string") { + throw new Error("Invalid topicId: must be a string"); + } + + if (topicId.length === 0) { + throw new Error("Invalid topicId: cannot be empty"); + } + + if (topicId.length > MAX_TOPIC_ID_LENGTH) { + throw new Error(`Invalid topicId: exceeds maximum length of ${MAX_TOPIC_ID_LENGTH} characters`); + } + + // Sanitize both fields + const sanitizedMessage = sanitizeMessageString(message); + const sanitizedTopicId = sanitizeTopicIdentifier(topicId); + + // Validate topicId format (alphanumeric, dashes, underscores only) + if (!/^[a-zA-Z0-9_-]+$/.test(sanitizedTopicId)) { + throw new Error("Invalid topicId: must contain only alphanumeric characters, dashes, and underscores"); + } + + return { + message: sanitizedMessage, + topicId: sanitizedTopicId, + }; +} + +/** + * Validates that the API key and base URL are configured + */ +export function validateMintlifyConfig(): { + apiKey: string; + apiBaseUrl: string; +} { + const apiKey = process.env.MINTLIFY_CHAT_API_KEY; + const rawApiBaseUrl = process.env.NEXT_PUBLIC_CHAT_API_URL; + const apiBaseUrl = rawApiBaseUrl?.replace(/\/+$/, ""); + + if (!apiKey) { + throw new Error("MINTLIFY_CHAT_API_KEY is not configured"); + } + + if (!apiBaseUrl) { + throw new Error("NEXT_PUBLIC_CHAT_API_URL is not configured"); + } + + return { apiKey, apiBaseUrl }; +} + +/** + * Sanitizes response headers before sending to client + * Only allows safe headers through + */ +export function sanitizeResponseHeaders(headers: Headers): Record { + const allowedHeaders = ["content-type", "x-mintlify-base-url"]; + const sanitized: Record = {}; + + allowedHeaders.forEach((header) => { + const value = headers.get(header); + if (value) { + sanitized[header] = value; + } + }); + + return sanitized; +} + diff --git a/turbo.json b/turbo.json index 4c687ab4e9..80a0ecd74c 100644 --- a/turbo.json +++ b/turbo.json @@ -225,9 +225,10 @@ "LOCAL_TESTING_DOMAIN_VERCEL", "AUTH_BEARER_TOKEN_CLOUDFLARE", "CLOUDFLARE_ZONE_ID", - "NEXT_PUBLIC_MINTLIFY_CHAT_API_KEY", + "MINTLIFY_CHAT_API_KEY", "NEXT_PUBLIC_CHAT_API_URL", "NEXT_PUBLIC_DOCS_URL", + "NEXT_PUBLIC_ENABLE_MINTLIFY_CHAT", "CLOUDFLARE_VERCEL_CNAME", "CLOUDFLARE_DNS", "EMBED_PUBLIC_EMBED_FINGER_PRINT",