merge: bring web-backend proxy hardening into self-hosted image

This commit is contained in:
4gray committed 2026-05-15 18:30:49 +02:00
commit 9d291faa10
6 files changed
+371 -42

No files matched your search

+1
View File
@@ -21,6 +21,7 @@ This file provides guidance to coding agents working in this repository.
## PWA / Self-hosted Web
- The browser self-hosted backend lives in `apps/web-backend`. Do not rely on the historical external `4gray/iptvnator-backend` container for the default Docker flow unless the task explicitly asks to re-sync missing behavior from that repository.
- `apps/web-backend` proxy routes must validate provider URLs before outbound requests. Keep `http`/`https` only, block loopback/private/reserved targets by default, and use `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` only for trusted local/LAN mock or self-hosted deployments.
- The Angular PWA resolves its backend through `window.__IPTVNATOR_CONFIG__.BACKEND_URL`, read by `apps/web/src/app/services/runtime-config.ts`. The placeholder file is `apps/web/src/assets/app-config.js`; Docker rewrites the built copy at container startup.
- Keep `assets/app-config.js` out of Angular service worker hashing in `ngsw-config.json`. Runtime rewrites after `web:pwa` must not invalidate `ngsw.json`.
- For PWA output checks, run `pnpm nx build web --configuration=pwa --skip-nx-cache` and verify `dist/apps/web/ngsw-worker.js` plus `dist/apps/web/ngsw.json` exist. If Nx serves stale build metadata after project config changes, run `pnpm nx reset`.
+2 -1
View File
@@ -30,7 +30,8 @@
"env": {
"PORT": "3333",
"CLIENT_URL": "http://localhost:4200",
"BACKEND_URL": "/api"
"BACKEND_URL": "/api",
"IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS": "1"
}
}
},
@@ -61,6 +61,8 @@ class StubHttpClient implements WebBackendHttpClient {
}
}
const resolvePublicHost = async () => ['93.184.216.34'];
async function withServer<T>(
app: ReturnType<typeof createWebBackendApp>,
callback: (baseUrl: string) => Promise<T>
@@ -121,6 +123,7 @@ https://stream.example/news.m3u8`);
guid: () => 'fixed-id',
httpClient,
now: () => new Date('2026-05-15T08:00:00.000Z'),
resolveHostname: resolvePublicHost,
}),
async (baseUrl) => {
const response = await fetch(
@@ -169,7 +172,10 @@ https://stream.example/news.m3u8`);
httpClient.queueResponse({ user_info: { username: 'demo' } });
await withServer(
createWebBackendApp({ httpClient }),
createWebBackendApp({
httpClient,
resolveHostname: resolvePublicHost,
}),
async (baseUrl) => {
const response = await fetch(
`${baseUrl}/xtream?url=${encodeURIComponent('http://xtream.example')}&username=demo&password=secret&action=get_account_info`
@@ -199,7 +205,10 @@ https://stream.example/news.m3u8`);
httpClient.queueResponse({ js: [{ id: '2001', title: 'Action' }] });
await withServer(
createWebBackendApp({ httpClient }),
createWebBackendApp({
httpClient,
resolveHostname: resolvePublicHost,
}),
async (baseUrl) => {
const response = await fetch(
`${baseUrl}/stalker?url=${encodeURIComponent('http://stalker.example/portal.php')}&macAddress=00:1A:79:00:00:01&token=abc123&action=get_categories&type=vod`
@@ -233,7 +242,10 @@ https://stream.example/news.m3u8`);
httpClient.queueFailure(403, 'Forbidden');
await withServer(
createWebBackendApp({ httpClient }),
createWebBackendApp({
httpClient,
resolveHostname: resolvePublicHost,
}),
async (baseUrl) => {
const response = await fetch(
`${baseUrl}/xtream?url=${encodeURIComponent('http://xtream.example')}&action=get_account_info`
@@ -246,4 +258,96 @@ https://stream.example/news.m3u8`);
}
);
});
it('returns provider parse errors as JSON instead of executable text', async () => {
const httpClient = new StubHttpClient();
httpClient.queueFailure(502, '<script>alert(1)</script>');
await withServer(
createWebBackendApp({
httpClient,
resolveHostname: resolvePublicHost,
}),
async (baseUrl) => {
const response = await fetch(
`${baseUrl}/parse?url=${encodeURIComponent('https://provider.example/list.m3u')}`
);
expect(response.status).toBe(502);
expect(response.headers.get('content-type')).toContain(
'application/json'
);
await expect(response.json()).resolves.toEqual({
message: '<script>alert(1)</script>',
status: 502,
});
}
);
});
it('rejects unsupported target URL schemes before proxying', async () => {
const httpClient = new StubHttpClient();
await withServer(
createWebBackendApp({ httpClient }),
async (baseUrl) => {
const response = await fetch(
`${baseUrl}/parse?url=${encodeURIComponent('file:///etc/passwd')}`
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
message: 'Only http and https provider URLs are supported',
status: 400,
});
expect(httpClient.requests).toEqual([]);
}
);
});
it('rejects loopback target URLs by default', async () => {
const httpClient = new StubHttpClient();
await withServer(
createWebBackendApp({ httpClient }),
async (baseUrl) => {
const response = await fetch(
`${baseUrl}/xtream?url=${encodeURIComponent('http://127.0.0.1:3211')}&action=get_account_info`
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
message:
'Provider URL points to a private or local network address',
status: 400,
});
expect(httpClient.requests).toEqual([]);
}
);
});
it('allows private target URLs when explicitly enabled for local self-hosted testing', async () => {
const httpClient = new StubHttpClient();
httpClient.queueResponse({ user_info: { username: 'demo' } });
await withServer(
createWebBackendApp({
allowPrivateNetworkTargets: true,
httpClient,
}),
async (baseUrl) => {
const response = await fetch(
`${baseUrl}/xtream?url=${encodeURIComponent('http://127.0.0.1:3211')}&username=demo&password=secret&action=get_account_info`
);
await expect(response.json()).resolves.toEqual({
action: 'get_account_info',
payload: { user_info: { username: 'demo' } },
});
expect(httpClient.requests[0]?.url).toBe(
'http://127.0.0.1:3211/player_api.php'
);
}
);
});
});
+230 -33
View File
@@ -1,6 +1,7 @@
import cors from 'cors';
import express, { Express, Request } from 'express';
import https from 'node:https';
import express, { Express, Request, Response } from 'express';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import zlib from 'node:zlib';
import axios from 'axios';
import epgParser from 'epg-parser';
@@ -8,7 +9,6 @@ import parser from 'iptv-playlist-parser';
export interface WebBackendHttpGetOptions {
readonly headers?: Record<string, string>;
readonly httpsAgent?: unknown;
readonly params?: Record<string, string>;
readonly responseType?: 'arraybuffer';
}
@@ -33,16 +33,24 @@ interface PlaylistParseError {
}
export interface WebBackendAppOptions {
readonly allowPrivateNetworkTargets?: boolean;
readonly clientOrigins?: string[];
readonly guid?: () => string;
readonly httpClient?: WebBackendHttpClient;
readonly now?: () => Date;
readonly resolveHostname?: (hostname: string) => Promise<readonly string[]>;
readonly runtimeBackendUrl?: string;
}
const defaultHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
interface ProviderUrlPolicy {
readonly allowPrivateNetworkTargets: boolean;
readonly resolveHostname: (hostname: string) => Promise<readonly string[]>;
}
interface ProviderUrlError {
readonly message: string;
readonly status: number;
}
export function createWebBackendApp(
options: WebBackendAppOptions = {}
@@ -54,6 +62,12 @@ export function createWebBackendApp(
const clientOrigins = options.clientOrigins ?? getClientOrigins();
const runtimeBackendUrl =
options.runtimeBackendUrl ?? process.env['BACKEND_URL'] ?? '/api';
const providerUrlPolicy: ProviderUrlPolicy = {
allowPrivateNetworkTargets:
options.allowPrivateNetworkTargets ??
isPrivateNetworkProxyAllowed(),
resolveHostname: options.resolveHostname ?? resolveHostname,
};
const corsMiddleware = cors({
origin(origin, callback) {
@@ -83,9 +97,12 @@ export function createWebBackendApp(
});
app.get('/parse', corsMiddleware, async (req, res) => {
const url = getQueryString(req, 'url');
const url = await getValidatedProviderUrl(
req,
res,
providerUrlPolicy
);
if (!url) {
res.status(400).send('Missing url');
return;
}
@@ -93,11 +110,11 @@ export function createWebBackendApp(
guid,
httpClient,
now,
url,
url: url.href,
});
if (isPlaylistParseError(result)) {
res.status(result.status).send(result.message);
res.status(result.status).json(result);
return;
}
@@ -105,31 +122,45 @@ export function createWebBackendApp(
});
app.get('/parse-xml', corsMiddleware, async (req, res) => {
const url = getQueryString(req, 'url');
const url = await getValidatedProviderUrl(
req,
res,
providerUrlPolicy
);
if (!url) {
res.status(400).send('Missing url');
return;
}
const result = await fetchEpgDataFromUrl(httpClient, url);
if (!result) {
res.status(500).send('Error, something went wrong');
return;
}
try {
const result = await fetchEpgDataFromUrl(httpClient, url);
if (!result) {
res.status(500).json({
message: 'Error, something went wrong',
status: 500,
});
return;
}
res.json(result);
res.json(result);
} catch (error) {
const providerError = normalizeProviderError(error);
res.status(providerError.status).json(providerError);
}
});
app.get('/xtream', corsMiddleware, async (req, res) => {
const url = getQueryString(req, 'url');
const url = await getValidatedProviderUrl(
req,
res,
providerUrlPolicy
);
if (!url) {
res.status(400).json({ message: 'Missing url', status: 400 });
return;
}
try {
const response = await httpClient.get(
`${trimTrailingSlash(url)}/player_api.php`,
appendPathSegment(url, 'player_api.php'),
{
params: getProxyParams(req, ['url']),
}
@@ -145,16 +176,19 @@ export function createWebBackendApp(
});
app.get('/stalker', corsMiddleware, async (req, res) => {
const url = getQueryString(req, 'url');
const url = await getValidatedProviderUrl(
req,
res,
providerUrlPolicy
);
const macAddress = getQueryString(req, 'macAddress');
const token = getQueryString(req, 'token');
if (!url) {
res.status(400).json({ message: 'Missing url', status: 400 });
return;
}
try {
const response = await httpClient.get(url, {
const response = await httpClient.get(url.href, {
params: getProxyParams(req, ['url']),
headers: {
...(macAddress ? { Cookie: `mac=${macAddress}` } : {}),
@@ -174,6 +208,102 @@ export function createWebBackendApp(
return app;
}
async function getValidatedProviderUrl(
req: Request,
res: Response,
policy: ProviderUrlPolicy
): Promise<URL | null> {
const rawUrl = getQueryString(req, 'url');
if (!rawUrl) {
res.status(400).json({ message: 'Missing url', status: 400 });
return null;
}
const result = await validateProviderUrl(rawUrl, policy);
if ('message' in result) {
res.status(result.status).json(result);
return null;
}
return result;
}
async function validateProviderUrl(
rawUrl: string,
policy: ProviderUrlPolicy
): Promise<URL | ProviderUrlError> {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return { message: 'Provider URL is not a valid URL', status: 400 };
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return {
message: 'Only http and https provider URLs are supported',
status: 400,
};
}
if (url.username || url.password) {
return {
message: 'Provider URL credentials are not supported',
status: 400,
};
}
if (policy.allowPrivateNetworkTargets) {
return url;
}
const hostname = normalizeHostname(url.hostname);
if (isLocalHostname(hostname) || isPrivateOrReservedIp(hostname)) {
return {
message:
'Provider URL points to a private or local network address',
status: 400,
};
}
if (isIP(hostname) === 0) {
let addresses: readonly string[];
try {
addresses = await policy.resolveHostname(hostname);
} catch {
return {
message: 'Provider URL host could not be resolved',
status: 400,
};
}
if (
addresses.length === 0 ||
addresses.some((address) =>
isPrivateOrReservedIp(normalizeHostname(address))
)
) {
return {
message:
'Provider URL points to a private or local network address',
status: 400,
};
}
}
return url;
}
async function resolveHostname(hostname: string): Promise<readonly string[]> {
const records = await lookup(hostname, { all: true, verbatim: true });
return records.map((record) => record.address);
}
function isPrivateNetworkProxyAllowed(): boolean {
const value = process.env['IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS'];
return value === '1' || value === 'true';
}
function getClientOrigins(): string[] {
const configured = process.env['CLIENT_URL']
?.split(',')
@@ -226,8 +356,12 @@ function getProxyParams(
return params;
}
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, '');
function appendPathSegment(url: URL, segment: string): string {
const nextUrl = new URL(url.href);
nextUrl.pathname = `${nextUrl.pathname.replace(/\/+$/, '')}/${segment}`;
nextUrl.search = '';
nextUrl.hash = '';
return nextUrl.href;
}
async function handlePlaylistParse(options: {
@@ -237,9 +371,7 @@ async function handlePlaylistParse(options: {
readonly url: string;
}): Promise<Record<string, unknown> | PlaylistParseError> {
try {
const response = await options.httpClient.get<string>(options.url, {
httpsAgent: defaultHttpsAgent,
});
const response = await options.httpClient.get<string>(options.url);
const parsedPlaylist = parsePlaylist(response.data);
const title = getLastUrlSegment(options.url);
return createPlaylistObject({
@@ -262,12 +394,13 @@ async function handlePlaylistParse(options: {
async function fetchEpgDataFromUrl(
httpClient: WebBackendHttpClient,
url: string
url: URL
): Promise<unknown> {
const response = await httpClient.get<ArrayBuffer | string>(url.trim(), {
...(url.endsWith('.gz') ? { responseType: 'arraybuffer' } : {}),
const href = url.href;
const response = await httpClient.get<ArrayBuffer | string>(href, {
...(url.pathname.endsWith('.gz') ? { responseType: 'arraybuffer' } : {}),
});
const xml = url.endsWith('.gz')
const xml = url.pathname.endsWith('.gz')
? zlib.gunzipSync(Buffer.from(response.data as ArrayBuffer)).toString()
: response.data.toString();
return epgParser.parse(xml);
@@ -338,3 +471,67 @@ function normalizeProviderError(error: unknown): {
function createGuid(): string {
return Math.random().toString(36).slice(2);
}
function normalizeHostname(hostname: string): string {
return hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
}
function isLocalHostname(hostname: string): boolean {
return hostname === 'localhost' || hostname.endsWith('.localhost');
}
function isPrivateOrReservedIp(address: string): boolean {
const version = isIP(address);
if (version === 4) {
return isPrivateOrReservedIpv4(address);
}
if (version === 6) {
return isPrivateOrReservedIpv6(address);
}
return false;
}
function isPrivateOrReservedIpv4(address: string): boolean {
const parts = address.split('.').map((part) => Number(part));
if (
parts.length !== 4 ||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
) {
return true;
}
const [first, second, third] = parts;
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168) ||
(first === 192 && second === 0) ||
(first === 192 && second === 0 && third === 2) ||
(first === 198 && (second === 18 || second === 19)) ||
(first === 198 && second === 51 && third === 100) ||
(first === 203 && second === 0 && third === 113) ||
first >= 224
);
}
function isPrivateOrReservedIpv6(address: string): boolean {
const normalized = address.toLowerCase();
if (
normalized === '::' ||
normalized === '::1' ||
normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
normalized.startsWith('fe80:')
) {
return true;
}
const mappedIpv4 = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
return mappedIpv4 ? isPrivateOrReservedIpv4(mappedIpv4) : false;
}
+15 -5
View File
@@ -35,11 +35,21 @@ The container writes `/usr/share/nginx/html/assets/app-config.js` on startup.
That file sets `window.__IPTVNATOR_CONFIG__.BACKEND_URL`, which the PWA reads
before it creates `PwaService`.
| Variable | Default | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------ |
| `BACKEND_URL` | `/api` | Browser-facing backend URL used by the PWA. Keep `/api` for the bundled nginx proxy. |
| `CLIENT_URL` | `http://localhost:4333` | Allowed browser origin for backend CORS. Use the public URL when hosting behind a reverse proxy. |
| `PORT` | `3000` | Internal Express backend port. nginx proxy config is patched to match it at container startup. |
| Variable | Default | Purpose |
| ----------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `BACKEND_URL` | `/api` | Browser-facing backend URL used by the PWA. Keep `/api` for the bundled nginx proxy. |
| `CLIENT_URL` | `http://localhost:4333` | Allowed browser origin for backend CORS. Use the public URL when hosting behind a reverse proxy. |
| `PORT` | `3000` | Internal Express backend port. nginx proxy config is patched to match it at container startup. |
| `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS` | unset | Set to `1` only for trusted local/LAN deployments that intentionally proxy private network IPTV or mock endpoints. |
The web backend proxy accepts only `http` and `https` provider URLs. It blocks
loopback, private, link-local, and reserved network targets by default so a
publicly exposed instance cannot be used as a generic internal-network fetcher.
If you enable `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1`, keep the instance
restricted to trusted users.
For providers that use private certificate authorities, keep TLS validation
enabled and pass the CA bundle to Node with `NODE_EXTRA_CA_CERTS=/path/to/ca.pem`.
The nginx config serves the PWA with SPA fallback, avoids caching
`assets/app-config.js`, and proxies `/api/*` to the internal backend.
+16
View File
@@ -64,6 +64,18 @@ The PWA continues to use `PwaService`; only the backend base URL is resolved at
runtime. Electron routes remain owned by the Electron backend and preload
bridge.
Provider proxy routes validate the target URL before any outbound request:
- only `http:` and `https:` provider URLs are accepted
- URL credentials are rejected
- loopback, private, link-local, and reserved network targets are blocked by
default
- `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` explicitly enables trusted
local/LAN targets for development, mock servers, or private deployments
Do not disable TLS certificate validation in the backend proxy. For private
certificate authorities, configure Node with `NODE_EXTRA_CA_CERTS`.
## PWA Portal User Data
Xtream favorites and recently viewed items use the browser-side
@@ -97,9 +109,13 @@ Default runtime values:
- `BACKEND_URL=/api`
- `CLIENT_URL=http://localhost:4333`
- `PORT=3000`
- `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS` unset
When hosting behind another domain, set `CLIENT_URL` to the browser origin and
keep `BACKEND_URL=/api` unless the reverse proxy exposes the backend elsewhere.
Only set `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` when the self-hosted
instance is restricted to trusted users and intentionally needs private
network IPTV targets.
## Validation