mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
merge: bring web-backend proxy hardening into self-hosted image
This commit is contained in:
commit
9d291faa10
6 files changed
+371
-42
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user