diff --git a/.changes/portals-compact-request-error-logs.md b/.changes/portals-compact-request-error-logs.md new file mode 100644 index 000000000..b4d5a2294 --- /dev/null +++ b/.changes/portals-compact-request-error-logs.md @@ -0,0 +1,9 @@ +--- +type: internal +area: portals +--- + +Failed Stalker portal requests now log a compact, credential-free summary +(action, host, error code, HTTP status) in the desktop backend instead of +dumping the full multi-page network error object, matching the existing +Xtream request logging. diff --git a/apps/electron-backend/src/app/events/portal-request-error.util.spec.ts b/apps/electron-backend/src/app/events/portal-request-error.util.spec.ts new file mode 100644 index 000000000..73eb46fc8 --- /dev/null +++ b/apps/electron-backend/src/app/events/portal-request-error.util.spec.ts @@ -0,0 +1,115 @@ +import { AxiosError } from 'axios'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; +import { formatPortalRequestError } from './portal-request-error.util'; + +describe('formatPortalRequestError', () => { + const xtreamUrl = + 'http://provider.example:8080/player_api.php?username=user&password=secret&action=get_vod_streams'; + const stalkerUrl = + 'http://portal.example/stalker_portal/server/load.php?action=get_ordered_list&type=vod&p=1&JsHttpRequest=1-xml'; + + it('keeps only host and pathname of the request URL, never the query', () => { + const formatted = formatPortalRequestError( + new Error('timeout of 30000ms exceeded'), + xtreamUrl, + 'get_vod_streams' + ); + + expect(formatted.host).toBe('provider.example:8080'); + expect(formatted.pathname).toBe('/player_api.php'); + const serialized = JSON.stringify(formatted); + expect(serialized).not.toContain('secret'); + expect(serialized).not.toContain('username'); + }); + + it('formats axios errors with code, status and syscall details', () => { + const error = new AxiosError('timeout of 15000ms exceeded', 'ECONNABORTED'); + (error as AxiosError & { syscall?: string }).syscall = 'connect'; + (error as AxiosError & { hostname?: string }).hostname = + 'portal.example'; + + const formatted = formatPortalRequestError( + error, + stalkerUrl, + 'get_ordered_list' + ); + + expect(formatted).toEqual({ + action: 'get_ordered_list', + host: 'portal.example', + pathname: '/stalker_portal/server/load.php', + type: 'AxiosError', + code: 'ECONNABORTED', + status: undefined, + message: 'timeout of 15000ms exceeded', + syscall: 'connect', + hostname: 'portal.example', + }); + }); + + it('reads the HTTP status from an axios error response', () => { + const error = new AxiosError( + 'Request failed with status code 521', + 'ERR_BAD_RESPONSE', + undefined, + undefined, + { status: 521 } as AxiosError['response'] + ); + + const formatted = formatPortalRequestError(error, stalkerUrl); + + expect(formatted.type).toBe('AxiosError'); + expect(formatted.status).toBe(521); + }); + + it('formats plain error objects and Error instances as ErrorObject', () => { + const httpError = new Error('HTTP Error 404: Not Found') as Error & { + status: number; + }; + httpError.status = 404; + + const formatted = formatPortalRequestError(httpError, stalkerUrl); + + expect(formatted).toEqual({ + action: undefined, + host: 'portal.example', + pathname: '/stalker_portal/server/load.php', + type: 'ErrorObject', + status: 404, + message: 'HTTP Error 404: Not Found', + }); + }); + + it('stringifies non-object errors as UnknownError', () => { + const formatted = formatPortalRequestError('boom', stalkerUrl); + + expect(formatted.type).toBe('UnknownError'); + expect(formatted.message).toBe('boom'); + }); + + it('withholds the URL entirely when it cannot be parsed', () => { + const formatted = formatPortalRequestError( + new Error('fail'), + 'not-a-url' + ); + + expect(formatted.host).toBe('unknown'); + expect(formatted.pathname).toBe('[unparseable-url]'); + }); + + // A malformed URL is exactly where redaction cannot help: the guard in + // stalker.events.ts rejects credentialed portal URLs before the request + // URL is built, so the raw playlist value reaches this formatter, and + // redactSensitiveData passes an unparseable URL through verbatim. + it('does not leak credentials of an unparseable credentialed URL', () => { + const formatted = formatPortalRequestError( + new Error('blocked by url guard'), + 'http://user:sup3rsecret@', + 'get_genres' + ); + + expect( + JSON.stringify(redactSensitiveData(formatted)) + ).not.toContain('sup3rsecret'); + }); +}); diff --git a/apps/electron-backend/src/app/events/portal-request-error.util.ts b/apps/electron-backend/src/app/events/portal-request-error.util.ts new file mode 100644 index 000000000..de0db9fe2 --- /dev/null +++ b/apps/electron-backend/src/app/events/portal-request-error.util.ts @@ -0,0 +1,78 @@ +import axios from 'axios'; + +/** + * Stand-in for a request URL that could not be parsed. The raw string must + * never be retained: a caller reaches this branch precisely when the URL is + * malformed (`http://user:secret@`, missing host), and `redactSensitiveData` + * only sanitizes userinfo of URLs it can parse — a malformed one is passed + * through verbatim, password included. + */ +const UNPARSEABLE_URL_VALUE = '[unparseable-url]'; + +/** + * Compact, credential-free shape for logging a failed portal request. + * Only host + pathname of the request URL are retained: Xtream URLs carry + * username/password in the query string and Stalker URLs carry the session + * command, so the query must never reach a log line. + * + * Callers must pass a portal API endpoint (`/player_api.php`, + * `/stalker_portal/server/load.php`). Xtream STREAM URLs embed credentials in + * the path itself (`/live///id.ts`), which this shape retains. + */ +export interface PortalRequestErrorLog { + action?: string; + host: string; + pathname: string; + type: 'AxiosError' | 'ErrorObject' | 'UnknownError'; + code?: string; + status?: unknown; + message?: unknown; + syscall?: string; + hostname?: string; +} + +export function formatPortalRequestError( + error: unknown, + requestUrl: string, + action?: string +): PortalRequestErrorLog { + let parsedUrl: URL | null = null; + try { + parsedUrl = new URL(requestUrl); + } catch { + parsedUrl = null; + } + const base = { + action, + host: parsedUrl?.host ?? 'unknown', + pathname: parsedUrl?.pathname ?? UNPARSEABLE_URL_VALUE, + }; + + if (axios.isAxiosError(error)) { + return { + ...base, + type: 'AxiosError', + code: error.code, + status: error.response?.status, + message: error.message, + syscall: (error as NodeJS.ErrnoException).syscall, + hostname: (error as { hostname?: string }).hostname, + }; + } + + if (error && typeof error === 'object') { + const errObj = error as Record; + return { + ...base, + type: 'ErrorObject', + status: errObj.status, + message: errObj.message, + }; + } + + return { + ...base, + type: 'UnknownError', + message: String(error), + }; +} diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index 056cc73a1..535cdbb8b 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -16,6 +16,7 @@ import { import { redactSensitiveData } from '@iptvnator/shared/logging'; import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service'; import { emitPortalDebugEvent } from './portal-debug.events'; +import { formatPortalRequestError } from './portal-request-error.util'; import { assertRemoteUrlAllowed } from './url-safety'; import { requestWithValidatedRedirects } from '../util/validated-axios'; @@ -50,6 +51,7 @@ ipcMain.handle( ) => { const startedAt = Date.now(); let debugRequest: Record | undefined; + let requestUrlForLog = payload.url; try { const { url, macAddress, params, token, serialNumber, requestId } = payload; @@ -66,6 +68,7 @@ ipcMain.handle( await assertRemoteUrlAllowed(url, { allowPrivateNetworks: true }); const fullUrl = buildStalkerRequestUrl(url, requestParams); + requestUrlForLog = fullUrl; // Determine timeout based on action type // create_link requests can take longer as server generates stream URL @@ -97,11 +100,6 @@ ipcMain.handle( // Check if response is successful if (response.status >= 400) { - console.error( - '[StalkerEvents] HTTP Error:', - response.status, - response.statusText - ); // The numeric code must live in the MESSAGE: ipcRenderer // strips custom properties from rejected values, and the // renderer's endpoint discovery needs to tell a 404 (probe @@ -177,8 +175,14 @@ ipcMain.handle( } console.error( - '[StalkerEvents] Request error:', - redactSensitiveData(error) + '[STALKER_REQUEST] Failed', + redactSensitiveData( + formatPortalRequestError( + error, + requestUrlForLog, + String(payload.params?.action ?? 'unknown') + ) + ) ); // Format error response diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 30f7349f4..59072d371 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -14,6 +14,7 @@ import { } from '@iptvnator/shared/interfaces'; import { redactSensitiveData } from '@iptvnator/shared/logging'; import { emitPortalDebugEvent } from './portal-debug.events'; +import { formatPortalRequestError } from './portal-request-error.util'; import { requestWithValidatedRedirects } from '../util/validated-axios'; import { createXtreamMainPerformanceCaptureForRequest, @@ -27,52 +28,6 @@ export default class XtreamEvents { } } -function formatXtreamError( - error: unknown, - requestUrl: string, - action?: string -) { - let parsedUrl: URL | null = null; - try { - parsedUrl = new URL(requestUrl); - } catch { - parsedUrl = null; - } - const base = { - action, - host: parsedUrl?.host ?? 'unknown', - pathname: parsedUrl?.pathname ?? requestUrl, - }; - - if (axios.isAxiosError(error)) { - return { - ...base, - type: 'AxiosError', - code: error.code, - status: error.response?.status, - message: error.message, - syscall: (error as NodeJS.ErrnoException).syscall, - hostname: (error as any).hostname, - }; - } - - if (error && typeof error === 'object') { - const errObj = error as Record; - return { - ...base, - type: 'ErrorObject', - status: errObj.status, - message: errObj.message, - }; - } - - return { - ...base, - type: 'UnknownError', - message: String(error), - }; -} - function buildXtreamApiUrl(url: string, params: Record): URL { const baseUrl = normalizeXtreamServerUrl(url); const apiUrl = new URL(`${baseUrl}/player_api.php`); @@ -240,7 +195,7 @@ ipcMain.handle( console.error( '[XTREAM_REQUEST] Failed', redactSensitiveData( - formatXtreamError( + formatPortalRequestError( error, requestUrlForLog, payload.params?.action