diff --git a/server/index.ts b/server/index.ts index 7ec32b1..a5cbbca 100644 --- a/server/index.ts +++ b/server/index.ts @@ -13,6 +13,7 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import routes from '@server/routes'; import restartFlag from '@server/utils/restartFlag'; +import { sanitizeErrorMessage } from '@server/utils/errorResponse'; // imageproxy removed - not needed for collections-only app import { getAppVersion } from '@server/utils/appVersion'; import { getClientIp } from '@supercharge/request-ip'; @@ -1333,10 +1334,34 @@ app // eslint-disable-next-line @typescript-eslint/no-unused-vars _next: NextFunction ) => { - // format error - res.status(err.status || 500).json({ + // Log full error details internally (including stack trace for debugging) + logger.error('Unhandled API error', { + label: 'Server', + status: err.status, message: err.message, errors: err.errors, + stack: err instanceof Error ? err.stack : undefined, + }); + + // Return sanitized error response + const safeMessage = sanitizeErrorMessage( + err.message, + 'An unexpected error occurred' + ); + + // Safely handle errors array (may be undefined, string, or array) + let safeErrors: string[] | undefined; + if (Array.isArray(err.errors)) { + safeErrors = err.errors.map((e) => + sanitizeErrorMessage(e, 'Validation error') + ); + } else if (typeof err.errors === 'string') { + safeErrors = [sanitizeErrorMessage(err.errors, 'Validation error')]; + } + + res.status(err.status || 500).json({ + message: safeMessage, + ...(safeErrors && safeErrors.length > 0 && { errors: safeErrors }), }); } ); diff --git a/server/routes/overlayTest.ts b/server/routes/overlayTest.ts index 76179d0..625abce 100644 --- a/server/routes/overlayTest.ts +++ b/server/routes/overlayTest.ts @@ -14,6 +14,7 @@ import { } from '@server/lib/overlays/OverlayTemplateRenderer'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { createErrorResponse } from '@server/utils/errorResponse'; import { Router } from 'express'; const overlayTestRouter = Router(); @@ -384,16 +385,12 @@ overlayTestRouter.post('/', async (req, res) => { context: allContext, }); } catch (error) { - logger.error('Failed to test overlay', { - label: 'OverlayTest', - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); - - return res.status(500).json({ - error: 'Failed to test overlay', - message: error instanceof Error ? error.message : String(error), - }); + const errorResponse = createErrorResponse( + error, + 'OverlayTest', + 'Failed to test overlay' + ); + return res.status(500).json(errorResponse); } }); diff --git a/server/routes/search.ts b/server/routes/search.ts index 93c4f86..a996aac 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,6 +1,7 @@ import type { PlexLibraryItem } from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi'; import logger from '@server/logger'; +import { createErrorResponse } from '@server/utils/errorResponse'; import { Router } from 'express'; const searchRouter = Router(); @@ -150,16 +151,12 @@ searchRouter.get('/search', async (req, res) => { totalResults: filteredResults.length, }); } catch (error) { - logger.error('Failed to search Plex', { - label: 'PlexSearch', - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); - - return res.status(500).json({ - error: 'Failed to search Plex', - message: error instanceof Error ? error.message : String(error), - }); + const errorResponse = createErrorResponse( + error, + 'PlexSearch', + 'Failed to search Plex' + ); + return res.status(500).json(errorResponse); } }); diff --git a/server/utils/errorResponse.ts b/server/utils/errorResponse.ts new file mode 100644 index 0000000..2c7a735 --- /dev/null +++ b/server/utils/errorResponse.ts @@ -0,0 +1,99 @@ +/** + * Utility for creating safe API error responses + * Prevents leaking internal implementation details in production + */ + +import logger from '@server/logger'; + +const isDev = process.env.NODE_ENV !== 'production'; + +/** + * Patterns that indicate SAFE, user-friendly error messages + * Only messages matching these patterns will be shown to users in production + */ +const SAFE_MESSAGE_PATTERNS = [ + /^(not found|invalid|missing|required|failed to|unable to|cannot|unauthorized|forbidden)/i, + /^(no .+ found|.+ is required|.+ not configured)/i, + /^(connection|network|timeout)/i, +]; + +/** + * Patterns that indicate internal/sensitive error details + * Messages matching these will always be masked + */ +const SENSITIVE_PATTERNS = [ + /at\s+\S+\s+\([^)]+\)/i, // Stack trace lines + /\/home\/|\/root\/|\/var\/|\/usr\/|\/mnt\/|C:\\|D:\\/i, // File paths + /ENOENT|EACCES|EPERM|ECONNREFUSED|ETIMEDOUT|ENOTFOUND/i, // System errors + /password|secret|token|apikey|api_key|authorization/i, // Credentials + /node_modules|\.ts:\d+|\.js:\d+/i, // Internal paths/source locations + /sql|query|database|table|column|constraint/i, // Database internals + /localhost|127\.0\.0\.1|192\.168\.|10\.\d+\.|172\.(1[6-9]|2\d|3[01])\./i, // Internal IPs +]; + +/** + * Check if an error message is safe to show to users + */ +function isSafeMessage(message: string): boolean { + // First check if it contains anything sensitive + if (SENSITIVE_PATTERNS.some((pattern) => pattern.test(message))) { + return false; + } + // Then check if it matches known safe patterns + return SAFE_MESSAGE_PATTERNS.some((pattern) => pattern.test(message)); +} + +/** + * Sanitize an error message for client response + * In production, only shows messages that are explicitly safe + * This is a whitelist approach - safer than trying to blacklist sensitive data + */ +export function sanitizeErrorMessage( + error: unknown, + fallbackMessage = 'An unexpected error occurred' +): string { + const message = error instanceof Error ? error.message : String(error); + + // In development, always show full error for debugging + if (isDev) { + return message; + } + + // In production, use whitelist approach - only show known-safe messages + if (isSafeMessage(message)) { + return message; + } + + // Default to fallback for unknown/potentially sensitive messages + return fallbackMessage; +} + +/** + * Create a standardized error response object + * Logs the full error internally while returning a safe response + */ +export function createErrorResponse( + error: unknown, + label: string, + userMessage: string +): { error: string; message: string } { + const fullMessage = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + + // Log full error details internally + logger.error(`${userMessage}: ${fullMessage}`, { + label, + error: fullMessage, + stack, + }); + + // Return sanitized response + const safeMessage = sanitizeErrorMessage(error, userMessage); + + // Always include message field for API compatibility + // Some clients may depend on this field being present + return { + error: userMessage, + message: safeMessage, + }; +}