Files
calendar/packages/lib/server/mintlifyChatValidation.ts
T
a7d87fc5be 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 <keithwillcode@gmail.com>
2025-11-21 18:43:09 +00:00

148 lines
4.1 KiB
TypeScript

/**
* 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<string, unknown>;
// 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<string, string> {
const allowedHeaders = ["content-type", "x-mintlify-base-url"];
const sanitized: Record<string, string> = {};
allowedHeaders.forEach((header) => {
const value = headers.get(header);
if (value) {
sanitized[header] = value;
}
});
return sanitized;
}