refactor(portals): compact credential-free logging for failed Stalker requests (#1418)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-12 17:54:23 +02:00
1 parent 36c2867d36
commit 5f4d219d3a
5 files changed
+215 -54

No files matched your search

@@ -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.
@@ -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');
});
});
@@ -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/<user>/<pass>/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<string, unknown>;
return {
...base,
type: 'ErrorObject',
status: errObj.status,
message: errObj.message,
};
}
return {
...base,
type: 'UnknownError',
message: String(error),
};
}
@@ -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<string, unknown> | 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
@@ -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<string, unknown>;
return {
...base,
type: 'ErrorObject',
status: errObj.status,
message: errObj.message,
};
}
return {
...base,
type: 'UnknownError',
message: String(error),
};
}
function buildXtreamApiUrl(url: string, params: Record<string, string>): 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