mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-07 03:59:36 +02:00
185 lines
5.9 KiB
TypeScript
185 lines
5.9 KiB
TypeScript
const MAX_LOG_ERROR_LENGTH = 1_000;
|
|
const MAX_LOG_ERROR_INPUT_LENGTH = 8_000;
|
|
const MAX_LOG_ERROR_SANITIZE_INPUT_LENGTH = MAX_LOG_ERROR_INPUT_LENGTH + MAX_LOG_ERROR_LENGTH;
|
|
const SENSITIVE_KEY_PATTERN =
|
|
'(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|client[_-]?secret|private[_-]?key|token|password|passwd|secret|credentials?)';
|
|
const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>]+/gi;
|
|
const QUOTED_SECRET_PATTERN = new RegExp(
|
|
`(["'])(${SENSITIVE_KEY_PATTERN})\\1\\s*:\\s*(["'])([^"']*)(\\3)`,
|
|
'gi',
|
|
);
|
|
const KEY_VALUE_SECRET_PATTERN = new RegExp(
|
|
`\\b(${SENSITIVE_KEY_PATTERN})\\b(\\s*[:=]\\s*)(["']?)([^\\s"',;&}]+)(\\3)`,
|
|
'gi',
|
|
);
|
|
const AUTHORIZATION_PATTERN =
|
|
/\b(authorization)(\s*[:=]\s*)(["']?)(?:(Bearer|Basic)\s+)?[^\s"',;&}]+(\3)/gi;
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null;
|
|
}
|
|
|
|
function getStringProperty(value: unknown, keys: string[]): string | undefined {
|
|
if (!isRecord(value)) return undefined;
|
|
|
|
for (const key of keys) {
|
|
const candidate = Reflect.get(value, key);
|
|
if (typeof candidate === 'string' && candidate.trim() !== '') return candidate;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function getNumberProperty(value: unknown, keys: string[]): number | undefined {
|
|
if (!isRecord(value)) return undefined;
|
|
|
|
for (const key of keys) {
|
|
const candidate = Reflect.get(value, key);
|
|
if (typeof candidate === 'number') return candidate;
|
|
if (typeof candidate === 'string') {
|
|
const parsed = Number(candidate);
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function truncate(value: string, maxLength = MAX_LOG_ERROR_LENGTH): string {
|
|
if (value.length <= maxLength) return value;
|
|
return `${value.slice(0, maxLength)}...`;
|
|
}
|
|
|
|
function sanitizeUrlForLog(value: string): string {
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.username) url.username = 'REDACTED';
|
|
if (url.password) url.password = 'REDACTED';
|
|
url.search = '';
|
|
url.hash = '';
|
|
return url.toString();
|
|
} catch {
|
|
return (value.split(/[?#]/, 1)[0] ?? value).replace(/\/\/[^/@\s]+@/, '//REDACTED@');
|
|
}
|
|
}
|
|
|
|
function sanitizeForLog(value: string): string {
|
|
return value
|
|
.replace(URL_PATTERN, (url) => sanitizeUrlForLog(url))
|
|
.replace(
|
|
AUTHORIZATION_PATTERN,
|
|
(_match, key: string, separator: string, quote: string, scheme: string | undefined) =>
|
|
`${key}${separator}${quote}${scheme ? `${scheme} ` : ''}[REDACTED]${quote}`,
|
|
)
|
|
.replace(QUOTED_SECRET_PATTERN, '$1$2$1: $3[REDACTED]$5')
|
|
.replace(KEY_VALUE_SECRET_PATTERN, '$1$2$3[REDACTED]$5');
|
|
}
|
|
|
|
function takeSanitizedLogSample(value: string): string {
|
|
const trimmed = value.trimStart();
|
|
const sanitizable =
|
|
trimmed.length > MAX_LOG_ERROR_SANITIZE_INPUT_LENGTH
|
|
? trimmed.slice(0, MAX_LOG_ERROR_SANITIZE_INPUT_LENGTH)
|
|
: trimmed;
|
|
const sanitized = sanitizeForLog(sanitizable);
|
|
const sample =
|
|
sanitized.length > MAX_LOG_ERROR_INPUT_LENGTH
|
|
? sanitized.slice(0, MAX_LOG_ERROR_INPUT_LENGTH)
|
|
: sanitized;
|
|
return sample.trimEnd();
|
|
}
|
|
|
|
function decodeBasicHtmlEntities(value: string): string {
|
|
return value
|
|
.replaceAll('•', ' ')
|
|
.replaceAll(' ', ' ')
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll(''', "'");
|
|
}
|
|
|
|
function htmlToText(value: string): string {
|
|
return decodeBasicHtmlEntities(value)
|
|
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
|
|
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function extractHtmlTagText(value: string, tag: string): string | undefined {
|
|
const match = value.match(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i'));
|
|
if (!match?.[1]) return undefined;
|
|
const text = htmlToText(match[1]);
|
|
return text || undefined;
|
|
}
|
|
|
|
function extractCloudflareRayId(value: string): string | undefined {
|
|
return value.match(/Cloudflare Ray ID:\s*(?:<[^>]+>|\s)*([a-f0-9]+)/i)?.[1];
|
|
}
|
|
|
|
function extractCloudflareHost(value: string, text: string): string | undefined {
|
|
const htmlMatch = value.match(/unable_to_access[^>]*>[^<]*<\/span>\s*([^<\s]+)/i);
|
|
if (htmlMatch?.[1]) return htmlMatch[1];
|
|
|
|
return text.match(/You are unable to access\s+([^\s]+)/i)?.[1];
|
|
}
|
|
|
|
function isHtmlResponse(value: string): boolean {
|
|
return /<!doctype html|<html[\s>]/i.test(value);
|
|
}
|
|
|
|
function summarizeHtmlResponse(value: string): string {
|
|
const text = htmlToText(value);
|
|
const rayId = extractCloudflareRayId(value);
|
|
const blockedHost = extractCloudflareHost(value, text);
|
|
|
|
if (/cloudflare/i.test(text)) {
|
|
return [
|
|
`Cloudflare blocked an HTTP request${blockedHost ? ` to ${blockedHost}` : ''}`,
|
|
rayId ? `Ray ID: ${rayId}` : undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join('; ');
|
|
}
|
|
|
|
const title = extractHtmlTagText(value, 'title');
|
|
const headline = extractHtmlTagText(value, 'h1');
|
|
return [
|
|
'Received an HTML error response',
|
|
title ? `title: ${title}` : undefined,
|
|
headline ? `headline: ${headline}` : undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join('; ');
|
|
}
|
|
|
|
function appendHttpContext(message: string, error: unknown): string {
|
|
const statusCode = getNumberProperty(error, ['statusCode', 'status', 'statusCodeNumber']);
|
|
const rawUrl = getStringProperty(error, ['url', 'requestUrl', 'endpoint']);
|
|
const url = rawUrl ? sanitizeUrlForLog(rawUrl) : undefined;
|
|
const context = [
|
|
statusCode !== undefined ? `status: ${statusCode}` : undefined,
|
|
url ? `url: ${url}` : undefined,
|
|
].filter(Boolean);
|
|
|
|
const safeMessage = sanitizeForLog(message);
|
|
if (context.length === 0) return safeMessage;
|
|
return `${safeMessage}; ${context.join('; ')}`;
|
|
}
|
|
|
|
export function formatErrorForLog(error: unknown): string {
|
|
const message =
|
|
getStringProperty(error, ['responseBody', 'body', 'data']) ??
|
|
(error instanceof Error ? error.message : String(error));
|
|
const sample = takeSanitizedLogSample(message);
|
|
|
|
if (isHtmlResponse(sample)) {
|
|
return appendHttpContext(truncate(summarizeHtmlResponse(sample)), error);
|
|
}
|
|
|
|
return appendHttpContext(truncate(sample), error);
|
|
}
|