mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user