From 5f4d219d3a7c46fb306a3600b71b4072d5a637d5 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:54:23 +0200 Subject: [PATCH] refactor(portals): compact credential-free logging for failed Stalker requests (#1418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(portals): log failed Stalker requests as compact credential-free summaries Failed STALKER_REQUEST handlers dumped the entire axios error object (config, request internals, agent state, stack) into the main-process console — ~100 lines per dead-portal request. Extract the existing compact Xtream error formatter into a shared portal-request-error util (action, host, pathname, code, status, message — query string never included) and use it from both handlers. The redundant pre-throw "[StalkerEvents] HTTP Error" line is dropped; HTTP >=400 already reaches the catch block and is now logged once, compactly. Co-Authored-By: Claude Fable 5 * fix(portals): never retain an unparseable request URL in error logs The URL guard in stalker.events.ts rejects credentialed portal URLs before the request URL is built, so a malformed row value such as "http://user:secret@" reaches the error formatter as-is. Its fallback copied that raw string into `pathname`, and redactSensitiveData only sanitizes userinfo of URLs it can PARSE — an unparseable one passes through verbatim, password included. Withhold the URL entirely in that branch. Verified: the added regression test fails on the old fallback and passes with the marker. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../portals-compact-request-error-logs.md | 9 ++ .../events/portal-request-error.util.spec.ts | 115 ++++++++++++++++++ .../app/events/portal-request-error.util.ts | 78 ++++++++++++ .../src/app/events/stalker.events.ts | 18 +-- .../src/app/events/xtream.events.ts | 49 +------- 5 files changed, 215 insertions(+), 54 deletions(-) create mode 100644 .changes/portals-compact-request-error-logs.md create mode 100644 apps/electron-backend/src/app/events/portal-request-error.util.spec.ts create mode 100644 apps/electron-backend/src/app/events/portal-request-error.util.ts 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