Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }),
});
}
);
Expand Down
17 changes: 7 additions & 10 deletions server/routes/overlayTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
});

Expand Down
17 changes: 7 additions & 10 deletions server/routes/search.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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);
}
});

Expand Down
99 changes: 99 additions & 0 deletions server/utils/errorResponse.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading