diff --git a/apps/electron-backend/src/app/server/http-server.spec.ts b/apps/electron-backend/src/app/server/http-server.spec.ts new file mode 100644 index 000000000..88f3d8d38 --- /dev/null +++ b/apps/electron-backend/src/app/server/http-server.spec.ts @@ -0,0 +1,448 @@ +import { once } from 'node:events'; +import { + mkdirSync, + mkdtempSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, posix, win32 } from 'node:path'; + +import { HttpServer, resolveStaticFilePath } from './http-server'; + +interface CapturedServer { + listening: Promise; + listeningEvents: number; + server: http.Server; +} + +interface TestResponse { + body: Buffer; + contentType: string | undefined; + statusCode: number | undefined; +} + +const INDEX_BODY = Buffer.from('
IPTVnator remote control
'); +const ASSETS = { + '/app.js': { + body: Buffer.from('globalThis.remoteControlLoaded = true;'), + contentType: 'application/javascript', + }, + '/styles.css': { + body: Buffer.from('main { color: rebeccapurple; }'), + contentType: 'text/css', + }, + '/data.json': { + body: Buffer.from('{"ready":true}'), + contentType: 'application/json', + }, + '/asset.unknown': { + body: Buffer.from([0x00, 0xff, 0x49, 0x50, 0x54, 0x56]), + contentType: 'application/octet-stream', + }, +} as const; + +jest.setTimeout(15_000); + +function request(port: number, path: string): Promise { + return new Promise((resolve, reject) => { + const clientRequest = http.request( + { + host: '127.0.0.1', + method: 'GET', + path, + port, + }, + (response) => { + const chunks: Buffer[] = []; + + response.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + response.on('end', () => { + const contentType = response.headers['content-type']; + resolve({ + body: Buffer.concat(chunks), + contentType: + typeof contentType === 'string' + ? contentType + : undefined, + statusCode: response.statusCode, + }); + }); + response.on('error', reject); + } + ); + + clientRequest.on('error', reject); + clientRequest.end(); + }); +} + +function getAddress(server: http.Server): AddressInfo { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected the HTTP server to have an IP address'); + } + return address; +} + +async function closeServer(server: http.Server): Promise { + if (!server.listening) { + return; + } + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +async function getAvailablePort(): Promise { + const probe = http.createServer(); + + await new Promise((resolve, reject) => { + probe.once('error', reject); + probe.listen(0, '127.0.0.1', resolve); + }); + + const port = getAddress(probe).port; + await closeServer(probe); + return port; +} + +describe('resolveStaticFilePath', () => { + const POSIX_STATIC_ROOT = '/opt/iptvnator/remote-control'; + const WINDOWS_STATIC_ROOT = 'C:\\iptvnator\\remote-control'; + + it('rejects a Windows double-leading-slash traversal', () => { + expect( + resolveStaticFilePath( + WINDOWS_STATIC_ROOT, + '//../../outside-secret.txt', + win32 + ) + ).toBeNull(); + }); + + it('rejects encoded traversal segments after one decode', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/%2e%2e/outside-secret.txt', + posix + ) + ).toBeNull(); + }); + + it('fails closed for malformed percent encoding without throwing', () => { + expect( + resolveStaticFilePath(POSIX_STATIC_ROOT, '/%E0%A4%A', posix) + ).toBeNull(); + }); + + it('rejects decoded NUL bytes', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/assets/%00secret.js', + posix + ) + ).toBeNull(); + }); + + it('resolves a valid Windows-style asset inside the static root', () => { + expect( + resolveStaticFilePath( + WINDOWS_STATIC_ROOT, + '/assets\\app.js?version=1', + win32 + ) + ).toBe('C:\\iptvnator\\remote-control\\assets\\app.js'); + }); + + it('ignores query and fragment data for filesystem resolution', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/data.json?cache=1#ignored', + posix + ) + ).toBe('/opt/iptvnator/remote-control/data.json'); + }); + + it('decodes the URL pathname exactly once', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/%252e%252e/asset.js', + posix + ) + ).toBe('/opt/iptvnator/remote-control/%2e%2e/asset.js'); + }); +}); + +describe('HttpServer', () => { + let capturedServers: CapturedServer[]; + let distPath: string; + let server: HttpServer; + let tempRoot: string; + + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => undefined); + + capturedServers = []; + tempRoot = mkdtempSync(join(tmpdir(), 'iptvnator-http-server-')); + distPath = join(tempRoot, 'static', 'app'); + mkdirSync(distPath, { recursive: true }); + writeFileSync(join(distPath, 'index.html'), INDEX_BODY); + + for (const [assetPath, asset] of Object.entries(ASSETS)) { + writeFileSync(join(distPath, assetPath), asset.body); + } + + writeFileSync( + join(tempRoot, 'outside-secret.txt'), + 'must-not-leave-the-static-directory' + ); + + server = new HttpServer({ + createServer: (requestListener) => { + const nodeServer = http.createServer(requestListener); + const captured: CapturedServer = { + listening: once(nodeServer, 'listening').then( + () => undefined + ), + listeningEvents: 0, + server: nodeServer, + }; + nodeServer.on('listening', () => { + captured.listeningEvents += 1; + }); + capturedServers.push(captured); + return nodeServer; + }, + distPath, + }); + }); + + afterEach(async () => { + let closeError: unknown; + + try { + await Promise.all( + capturedServers.map(({ server: nodeServer }) => + closeServer(nodeServer) + ) + ); + } catch (error) { + closeError = error; + } finally { + rmSync(tempRoot, { force: true, recursive: true }); + jest.restoreAllMocks(); + } + + if (closeError) { + throw closeError; + } + }); + + async function startServer(): Promise { + const serverIndex = capturedServers.length; + server.start(0); + const captured = capturedServers[serverIndex]; + if (!captured) { + throw new Error('Expected HttpServer.start() to create a server'); + } + + await captured.listening; + return captured; + } + + it('serves the exact index document at the root', async () => { + const captured = await startServer(); + + const response = await request(getAddress(captured.server).port, '/'); + + expect(response).toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); + + it('asks the operating system for an ephemeral port when started with zero', async () => { + const captured = await startServer(); + + expect(getAddress(captured.server).port).not.toBe(8765); + }); + + it.each(Object.entries(ASSETS))( + 'serves %s with its expected MIME type', + async (assetPath, asset) => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + assetPath + ); + + expect(response).toEqual({ + body: asset.body, + contentType: asset.contentType, + statusCode: 200, + }); + } + ); + + it('falls back to the exact index document for a client route', async () => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/channels/favorites' + ); + + expect(response).toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); + + it('returns a plain-text 404 when the index document is missing', async () => { + unlinkSync(join(distPath, 'index.html')); + const captured = await startServer(); + + const response = await request(getAddress(captured.server).port, '/'); + + expect(response).toEqual({ + body: Buffer.from('404 Not Found'), + contentType: 'text/plain', + statusCode: 404, + }); + }); + + it('dispatches a registered API request without serving a static file', async () => { + let receivedRequest: http.IncomingMessage | undefined; + server.registerRemoteControlHandler( + '/api/remote-control/status', + (incomingRequest, response) => { + receivedRequest = incomingRequest; + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end('{"source":"api-handler"}'); + } + ); + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/api/remote-control/status' + ); + + expect(receivedRequest?.url).toBe('/api/remote-control/status'); + expect(response).toEqual({ + body: Buffer.from('{"source":"api-handler"}'), + contentType: 'application/json', + statusCode: 200, + }); + expect(response.body).not.toEqual(INDEX_BODY); + }); + + it('returns a JSON 404 for an unknown remote-control API endpoint', async () => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/api/remote-control/missing' + ); + + expect(response).toEqual({ + body: Buffer.from('{"error":"Endpoint not found"}'), + contentType: 'application/json', + statusCode: 404, + }); + }); + + it.each([ + '/../../outside-secret.txt', + '//../../outside-secret.txt', + '/%2e%2e/%2e%2e/outside-secret.txt', + '/%E0%A4%A', + ])( + 'rejects the invalid static request target %s without serving an outside file', + async (requestTarget) => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + requestTarget + ); + + expect(response).toEqual({ + body: Buffer.from('404 Not Found'), + contentType: 'text/plain', + statusCode: 404, + }); + expect(response.body.toString()).not.toContain( + 'must-not-leave-the-static-directory' + ); + } + ); + + it('creates and listens on only one server when started twice', async () => { + server.start(0); + server.start(0); + + expect(capturedServers).toHaveLength(1); + await capturedServers[0].listening; + expect(capturedServers[0].listeningEvents).toBe(1); + }); + + it('stops the running server when disabled', async () => { + const captured = await startServer(); + const closed = once(captured.server, 'close'); + + server.updateSettings(false, 0); + await closed; + + expect(captured.server.listening).toBe(false); + expect(capturedServers).toHaveLength(1); + }); + + it('stops once and starts once on the new port when the port changes', async () => { + const first = await startServer(); + const newPort = await getAvailablePort(); + let closeEvents = 0; + first.server.on('close', () => { + closeEvents += 1; + }); + const firstClosed = once(first.server, 'close'); + + server.updateSettings(true, newPort); + const second = capturedServers[1]; + if (!second) { + throw new Error('Expected a replacement HTTP server'); + } + await Promise.all([firstClosed, second.listening]); + + expect(capturedServers).toHaveLength(2); + expect(closeEvents).toBe(1); + expect(first.server.listening).toBe(false); + expect(second.listeningEvents).toBe(1); + expect(getAddress(second.server).port).toBe(newPort); + await expect(request(newPort, '/')).resolves.toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); +}); diff --git a/apps/electron-backend/src/app/server/http-server.ts b/apps/electron-backend/src/app/server/http-server.ts index 23a235133..a6b24ce37 100644 --- a/apps/electron-backend/src/app/server/http-server.ts +++ b/apps/electron-backend/src/app/server/http-server.ts @@ -3,10 +3,61 @@ import * as fs from 'fs'; import * as http from 'http'; import * as path from 'path'; +type HttpServerFactory = (requestListener: http.RequestListener) => http.Server; + +interface HttpServerOptions { + createServer?: HttpServerFactory; + distPath?: string; +} + +/** + * Resolve an HTTP request target within the configured static root. + * The path implementation is injectable so platform-specific semantics remain testable. + */ +export function resolveStaticFilePath( + staticRoot: string, + requestTarget: string, + pathImplementation: path.PlatformPath = path +): string | null { + const separatorIndex = requestTarget.search(/[?#]/); + const encodedPathname = + separatorIndex === -1 + ? requestTarget + : requestTarget.slice(0, separatorIndex); + + let pathname: string; + try { + pathname = decodeURIComponent(encodedPathname); + } catch { + return null; + } + + if (pathname.includes('\0')) { + return null; + } + + const relativeCandidate = pathname.replace(/^[/\\]+/, '') || 'index.html'; + const resolvedRoot = pathImplementation.resolve(staticRoot); + const candidate = pathImplementation.resolve( + resolvedRoot, + relativeCandidate + ); + + if ( + candidate === resolvedRoot || + candidate.startsWith(`${resolvedRoot}${pathImplementation.sep}`) + ) { + return candidate; + } + + return null; +} + /** * HTTP server for serving the remote control web app and providing REST API endpoints */ export class HttpServer { + private readonly createServer: HttpServerFactory; private server: http.Server | null = null; private port = 8765; private isEnabled = false; @@ -16,6 +67,11 @@ export class HttpServer { (req: http.IncomingMessage, res: http.ServerResponse) => void > = new Map(); + constructor(options: HttpServerOptions = {}) { + this.createServer = options.createServer ?? http.createServer; + this.distPath = options.distPath ?? null; + } + /** * Get the path to the remote-control-web static files. * Lazily computed to avoid calling Electron APIs before app is ready. @@ -43,11 +99,7 @@ export class HttpServer { } else { // Production mode - files are bundled with the app // electron-builder copies remote-control-web/**/* directly to app root - this.distPath = path.join( - appPath, - 'remote-control-web', - 'browser' - ); + this.distPath = path.join(appPath, 'remote-control-web', 'browser'); } console.log('[HTTP Server] Serving from:', this.distPath); @@ -58,7 +110,7 @@ export class HttpServer { * Start the HTTP server */ start(port?: number): void { - if (port) { + if (port !== undefined) { this.port = port; } @@ -67,7 +119,7 @@ export class HttpServer { return; } - this.server = http.createServer((req, res) => { + this.server = this.createServer((req, res) => { this.handleRequest(req, res); }); @@ -153,18 +205,21 @@ export class HttpServer { * Serve static files */ private serveStaticFile(url: string, res: http.ServerResponse): void { - // Default to index.html for root path - let filePath = url === '/' ? '/index.html' : url; - - // Security: prevent directory traversal - filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, ''); - - const fullPath = path.join(this.getDistPath(), filePath); + const distPath = this.getDistPath(); + const fullPath = resolveStaticFilePath(distPath, url); + if (!fullPath) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('404 Not Found'); + return; + } fs.readFile(fullPath, (err, data) => { if (err) { // If file not found, try serving index.html (for Angular routing) - if (err.code === 'ENOENT' && filePath !== '/index.html') { + if ( + err.code === 'ENOENT' && + fullPath !== path.resolve(distPath, 'index.html') + ) { this.serveStaticFile('/', res); return; }