diff --git a/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts b/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts index 56ec959..391e509 100644 --- a/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts +++ b/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts @@ -13,6 +13,7 @@ import type { NapcatWebuiGatewaySession } from '../../domain/napcat-webui-gatewa 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', @@ -42,6 +43,12 @@ type RewriteTextResponseInput = { 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. @@ -133,11 +140,78 @@ export function rewriteNapcatTextResponse(input: RewriteTextResponseInput) { input.sessionId, )}/webui`; - return input.body.replace( + const rewritten = input.body.replace( /(^|[\s"'`(=,:])\/(webui|api|File|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(''); } /** @@ -273,7 +347,11 @@ export class NapcatWebuiProxyService { ); const credential = await this.credentialClient.getCredential(session); this.stripBrowserHeaders(req); - req.url = `${match.proxyPath}${match.search}`; + req.url = `${match.proxyPath}${rewriteNapcatWebSocketSearch({ + credential, + search: match.search, + upstreamPath: match.proxyPath, + })}`; const proxy = this.createProxy(session, credential); proxy.upgrade(req, socket, head); } catch { diff --git a/test/modules/qqbot/napcat-webui-gateway/gateway-proxy.service.spec.ts b/test/modules/qqbot/napcat-webui-gateway/gateway-proxy.service.spec.ts index 3c27128..84b7f08 100644 --- a/test/modules/qqbot/napcat-webui-gateway/gateway-proxy.service.spec.ts +++ b/test/modules/qqbot/napcat-webui-gateway/gateway-proxy.service.spec.ts @@ -42,6 +42,81 @@ describe('NapcatWebuiProxyService response rewriting', () => { expect(rewritten).not.toContain('"/api"'); }); + it('injects a Gateway-only browser token so NapCat WebUI can enter authenticated routes without leaking secrets', () => { + const rewriteNapcatTextResponse = ( + proxyModule as { + rewriteNapcatTextResponse?: (input: { + body: string; + sessionId: string; + }) => string; + } + ).rewriteNapcatTextResponse; + + expect(typeof rewriteNapcatTextResponse).toBe('function'); + + const rewritten = rewriteNapcatTextResponse?.({ + body: [ + '', + '', + '', + ].join(''), + sessionId: 'sess_1', + }); + + expect(rewritten).toContain('data-kt-napcat-webui-gateway-sso'); + expect(rewritten).toContain('localStorage.setItem("token"'); + expect(rewritten).toContain('kt-napcat-webui-gateway:sess_1'); + expect(rewritten).not.toContain('Credential'); + expect(rewritten).not.toContain('webui-token-fixture'); + }); + + it('keeps non-HTML text responses free of Gateway browser-token injection', () => { + const rewriteNapcatTextResponse = ( + proxyModule as { + rewriteNapcatTextResponse?: (input: { + body: string; + sessionId: string; + }) => string; + } + ).rewriteNapcatTextResponse; + + const rewritten = rewriteNapcatTextResponse?.({ + body: 'const baseURL="/api";', + sessionId: 'sess_1', + }); + + expect(rewritten).not.toContain('localStorage.setItem("token"'); + }); + + it('replaces browser terminal WebSocket token query with the server-side Credential', () => { + const rewriteNapcatWebSocketSearch = ( + proxyModule as { + rewriteNapcatWebSocketSearch?: (input: { + credential: string; + search: string; + upstreamPath: string; + }) => string; + } + ).rewriteNapcatWebSocketSearch; + + expect(typeof rewriteNapcatWebSocketSearch).toBe('function'); + + expect( + rewriteNapcatWebSocketSearch?.({ + credential: 'credential-1', + search: '?id=terminal-1&token=browser-dummy', + upstreamPath: '/api/ws/terminal', + }), + ).toBe('?id=terminal-1&token=credential-1'); + expect( + rewriteNapcatWebSocketSearch?.({ + credential: 'credential-1', + search: '?id=other&token=browser-dummy', + upstreamPath: '/api/ws/other', + }), + ).toBe('?id=other&token=browser-dummy'); + }); + it('does not buffer NapCat API or SSE responses for text rewriting', () => { const shouldRewriteNapcatTextResponse = ( proxyModule as {