import type { IncomingMessage, Server } from 'node:http'; import type { Socket } from 'node:net'; import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common'; import type { NextFunction, Request, Response } from 'express'; import { createProxyMiddleware, fixRequestBody, responseInterceptor, type RequestHandler, } from 'http-proxy-middleware'; import { NapcatWebuiGatewaySessionService } from '../../application/napcat-webui-gateway-session.service'; import type { NapcatWebuiGatewaySession } from '../../domain/napcat-webui-gateway.types'; import { NapcatWebuiCredentialClient } from '../napcat-webui-credential.client'; const GATEWAY_WEBUI_PREFIX = '/napcat-webui/session'; const GATEWAY_BROWSER_TOKEN_PREFIX = 'kt-napcat-webui-gateway:'; const TEXT_REWRITE_EXTENSIONS = ['.css', '.html', '.js', '.mjs'] as const; const STRIPPED_UPSTREAM_HEADERS = [ 'authorization', 'cookie', 'x-admin-token', 'x-api-token', 'x-access-token', 'x-kt-access-token', 'x-kt-gateway-secret', 'x-wordpress-cookie', ] as const; type ProxyPathInput = string | string[] | undefined; type RewriteLocationInput = { location: string; sessionId: string; upstreamBaseUrl: string; }; type CookiePathRewriteInput = { sessionId: string; }; type RewriteTextResponseInput = { body: string; sessionId: string; }; type RewriteWebSocketSearchInput = { credential: string; search: string; upstreamPath: string; }; /** * Normalizes a Gateway route tail into a safe upstream pathname. * @param input - Route parameter from Nest/path-to-regexp. * @returns Absolute upstream pathname beginning with `/`. */ export function sanitizeGatewayProxyPath(input: ProxyPathInput) { const raw = Array.isArray(input) ? input.join('/') : String(input || ''); const trimmed = raw.trim(); const decoded = decodeProxyPath(trimmed); if ( !decoded || decoded.includes('\\') || decoded.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(decoded) ) { throw new BadRequestException('Gateway proxy path is invalid'); } const path = decoded.startsWith('/') ? decoded : `/${decoded}`; const segments = path.split('/').filter(Boolean); if (segments.some((segment) => segment === '..')) { throw new BadRequestException('Gateway proxy path is invalid'); } return path; } /** * Rewrites NapCat redirects so browsers stay under the Gateway session route. * @param input - Upstream Location header plus Gateway session context. * @returns Rewritten safe Location header. */ export function rewriteNapcatLocationHeader(input: RewriteLocationInput) { const gatewayPrefix = `${GATEWAY_WEBUI_PREFIX}/${encodeURIComponent( input.sessionId, )}/webui`; const location = input.location.trim(); if (!location) return location; const fallback = `${gatewayPrefix}/webui`; if (location.startsWith('//')) { try { const upstream = new URL(input.upstreamBaseUrl); const target = new URL(`${upstream.protocol}${location}`); return toGatewayRedirectLocation(gatewayPrefix, target.pathname); } catch { return fallback; } } if (!/^[a-z][a-z0-9+.-]*:/i.test(location)) { try { const target = new URL(location, 'http://gateway.local'); return toGatewayRedirectLocation(gatewayPrefix, target.pathname); } catch { return fallback; } } try { const target = new URL(location); if (target.protocol === 'http:' || target.protocol === 'https:') { return toGatewayRedirectLocation(gatewayPrefix, target.pathname); } return fallback; } catch { return fallback; } } /** * Builds HPM cookie path rewrite config for Gateway-scoped upstream cookies. * @param input - Gateway session id used in the public route prefix. * @returns HPM cookiePathRewrite object. */ export function buildGatewayCookiePathRewrite(input: CookiePathRewriteInput) { return { '*': `${GATEWAY_WEBUI_PREFIX}/${encodeURIComponent(input.sessionId)}/webui`, }; } /** * Rewrites absolute NapCat WebUI browser paths so assets, APIs, and plugin resources stay under the active Gateway session. * @param input - Text response body from NapCat plus the Gateway session id that owns the browser lifecycle. * @returns Body text whose absolute NapCat root paths point back through the Gateway session prefix. */ export function rewriteNapcatTextResponse(input: RewriteTextResponseInput) { const gatewayWebuiPrefix = `${GATEWAY_WEBUI_PREFIX}/${encodeURIComponent( input.sessionId, )}/webui`; const rewritten = input.body.replace( /(^|[\s"'`(=,:])\/(webui|api|files|plugin)(?=\/|[?#"'`)]|$)/g, (_match, leader: string, root: string) => `${leader}${gatewayWebuiPrefix}/${root}`, ); return injectGatewayBrowserToken({ body: rewritten, sessionId: input.sessionId, }); } /** * Rewrites WebSocket query strings that NapCat authenticates with query tokens instead of headers. * @param input - Upstream path, browser search string, and server-side Credential for the active session. * @returns Search string safe to send upstream without preserving browser-supplied terminal tokens. */ export function rewriteNapcatWebSocketSearch( input: RewriteWebSocketSearchInput, ) { if (input.upstreamPath !== '/api/ws/terminal') { return input.search; } const params = new URLSearchParams(input.search); params.set('token', input.credential); const serialized = params.toString(); return serialized ? `?${serialized}` : ''; } /** * Injects a non-secret local token into NapCat WebUI HTML so React enters authenticated routes while Gateway handles real auth server-side. * @param input - HTML body candidate and Gateway session id used to build the browser-only token. * @returns HTML with a one-time SSO bootstrap script, or unchanged non-HTML text. */ function injectGatewayBrowserToken(input: RewriteTextResponseInput) { if ( input.body.includes('data-kt-napcat-webui-gateway-sso') || !/]/i.test(input.body) ) { return input.body; } const script = buildGatewayBrowserTokenScript(input.sessionId); if (/
]/i.test(input.body)) { return input.body.replace(/]*)?>/i, (headTag) => { return `${headTag}${script}`; }); } return input.body.replace( /', ].join(''); } /** * Decodes path text until stable so nested encoded traversal cannot pass through. * @param value - Raw route path text. * @returns Decoded path text. */ function decodeProxyPath(value: string) { try { let decoded = value; for (let index = 0; index < 6; index += 1) { const next = decodeURIComponent(decoded); if (next === decoded) { return next; } decoded = next; } } catch { throw new BadRequestException('Gateway proxy path is invalid'); } throw new BadRequestException('Gateway proxy path is invalid'); } /** * Builds a browser redirect that keeps only the upstream pathname. * @param gatewayPrefix - Public Gateway session route prefix for one Admin session. * @param upstreamPathname - Upstream redirect pathname after URL parsing removed search/hash. * @returns Gateway-scoped Location header, falling back to WebUI root for unsafe paths. */ function toGatewayRedirectLocation( gatewayPrefix: string, upstreamPathname: string, ) { try { return `${gatewayPrefix}${sanitizeGatewayProxyPath( upstreamPathname || '/webui', )}`; } catch { return `${gatewayPrefix}/webui`; } } /** * Checks whether a proxied upstream path may contain browser-executable absolute paths that need Gateway prefix rewriting. * @param upstreamPath - Sanitized upstream pathname with an optional query string. * @returns Whether the response should be buffered for text rewriting. */ export function shouldRewriteNapcatTextResponse(upstreamPath: string) { const pathname = new URL(upstreamPath, 'http://gateway.local').pathname; const filename = pathname.split('/').pop() || ''; const extensionIndex = filename.lastIndexOf('.'); const extension = extensionIndex >= 0 ? filename.slice(extensionIndex).toLowerCase() : ''; if (TEXT_REWRITE_EXTENSIONS.includes(extension as never)) { return true; } return ( pathname === '/webui' || (pathname.startsWith('/webui/') && extensionIndex < 0) ); } @Injectable() export class NapcatWebuiProxyService { /** * Creates the Gateway proxy service. * @param sessionService - Session lifecycle guard for bootstrap/proxy eligibility. * @param credentialClient - NapCat WebUI credential exchange/cache client. */ constructor( private readonly sessionService: NapcatWebuiGatewaySessionService, private readonly credentialClient: NapcatWebuiCredentialClient, ) {} /** * Proxies one HTTP request to the active session's NapCat WebUI. * @param sessionId - Gateway session id from the public route. * @param proxyPath - Route tail mapped to the upstream pathname. * @param req - Express request delegated from the public controller. * @param res - Express response owned by HPM after delegation. * @param next - Express next callback used by HPM. */ async handleHttpProxy( sessionId: string, proxyPath: ProxyPathInput, req: Request, res: Response, next: NextFunction, ) { const session = await this.sessionService.requireProxySession(sessionId); const upstreamPath = this.buildUpstreamPath(proxyPath, req.originalUrl); const credential = await this.credentialClient.getCredential(session); this.stripBrowserHeaders(req); req.url = upstreamPath; const proxy = this.createProxy( session, credential, shouldRewriteNapcatTextResponse(upstreamPath), ); return proxy(req, res, next); } /** * Subscribes the Gateway HTTP server to NapCat WebUI WebSocket upgrades. * @param server - HTTP server returned by the Nest application. */ bindWebSocketUpgrade(server: Server) { server.on('upgrade', (req, socket, head) => { void this.handleWebSocketUpgrade(req, socket as Socket, head); }); } /** * Handles one matching WebSocket upgrade and ignores unrelated upgrade URLs. * @param req - Raw Node upgrade request. * @param socket - TCP socket for the upgrade. * @param head - First packet of the upgraded stream. */ private async handleWebSocketUpgrade( req: IncomingMessage, socket: Socket, head: Buffer, ) { try { const match = this.matchGatewayUpgrade(req.url || ''); if (!match) return; const session = await this.sessionService.requireProxySession( match.sessionId, ); const credential = await this.credentialClient.getCredential(session); this.stripBrowserHeaders(req); req.url = `${match.proxyPath}${rewriteNapcatWebSocketSearch({ credential, search: match.search, upstreamPath: match.proxyPath, })}`; const proxy = this.createProxy(session, credential); proxy.upgrade(req, socket, head); } catch { this.rejectUpgrade(socket); } } /** * Creates one HPM proxy bound to a validated session and server-side credential. * @param session - Active Gateway session metadata. * @param credential - NapCat WebUI Credential for upstream Authorization. * @returns HPM request handler with HTTP and WebSocket support. */ private createProxy( session: NapcatWebuiGatewaySession, credential: string, rewriteTextResponse = false, ): RequestHandler