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 d785c4c..ab81f78 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 @@ -4,6 +4,7 @@ import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common'; import type { NextFunction, Request, Response } from 'express'; import { createProxyMiddleware, + fixRequestBody, type RequestHandler, } from 'http-proxy-middleware'; import { NapcatWebuiGatewaySessionService } from '../../application/napcat-webui-gateway-session.service'; @@ -74,30 +75,33 @@ export function rewriteNapcatLocationHeader(input: RewriteLocationInput) { 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 `${gatewayPrefix}${target.pathname}${target.search}${target.hash}`; + return toGatewayRedirectLocation(gatewayPrefix, target.pathname); } catch { - return `${gatewayPrefix}/webui`; + return fallback; } } - if (location.startsWith('/')) { - return `${gatewayPrefix}${location}`; - } if (!/^[a-z][a-z0-9+.-]*:/i.test(location)) { - return `${gatewayPrefix}/${location.replace(/^\/+/, '')}`; + 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 `${gatewayPrefix}${target.pathname}${target.search}${target.hash}`; + return toGatewayRedirectLocation(gatewayPrefix, target.pathname); } - return `${gatewayPrefix}/webui`; + return fallback; } catch { - return `${gatewayPrefix}/webui`; + return fallback; } } @@ -134,6 +138,25 @@ function decodeProxyPath(value: string) { 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`; + } +} + @Injectable() export class NapcatWebuiProxyService { /** @@ -227,9 +250,10 @@ export class NapcatWebuiProxyService { error: (_error, _req, res) => { this.writeProxyError(res); }, - proxyReq: (proxyReq) => { + proxyReq: (proxyReq, req) => { proxyReq.removeHeader('cookie'); proxyReq.setHeader('Authorization', `Bearer ${credential}`); + fixRequestBody(proxyReq, req); }, proxyReqWs: (proxyReq) => { proxyReq.removeHeader('cookie'); diff --git a/src/apps/napcat-webui-gateway/main.ts b/src/apps/napcat-webui-gateway/main.ts index 62a1311..4cfebf6 100644 --- a/src/apps/napcat-webui-gateway/main.ts +++ b/src/apps/napcat-webui-gateway/main.ts @@ -10,11 +10,12 @@ import { NapcatWebuiGatewayModule } from './napcat-webui-gateway.module'; */ async function bootstrap() { const app = await NestFactory.create(NapcatWebuiGatewayModule, { + bodyParser: false, bufferLogs: true, }); app.useLogger(app.get(Logger)); - app.use(json({ limit: '64kb' })); - app.use(urlencoded({ extended: true, limit: '64kb' })); + app.use('/internal', json({ limit: '64kb' })); + app.use('/internal', urlencoded({ extended: true, limit: '64kb' })); await app.listen(app.get(NapcatWebuiGatewayConfigService).port()); app.get(NapcatWebuiProxyService).bindWebSocketUpgrade(app.getHttpServer()); } diff --git a/src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service.ts b/src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service.ts index 8b127bb..865443c 100644 --- a/src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service.ts +++ b/src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service.ts @@ -248,8 +248,6 @@ export class QqbotNapcatWebuiGatewayService { selfId: account.selfId, }, container: { - id: runtime.id, - name: runtime.name, webuiStatus, }, expiresAt: gatewaySession.expiresAt, diff --git a/src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.dto.ts b/src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.dto.ts index deb5f0f..85979ed 100644 --- a/src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.dto.ts +++ b/src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.dto.ts @@ -18,12 +18,6 @@ export class QqbotNapcatWebuiSessionAccountDto { } export class QqbotNapcatWebuiSessionContainerDto { - @ApiProperty({ description: 'NapCat container id.' }) - id: string; - - @ApiProperty({ description: 'NapCat container name.' }) - name: string; - @ApiProperty({ description: 'Browser-safe WebUI availability status.', enum: ['offline', 'online', 'unknown'], diff --git a/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts b/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts index e4ab67b..0ed33ec 100644 --- a/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts +++ b/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts @@ -1,4 +1,6 @@ import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { HttpStatus, type INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import axios from 'axios'; @@ -19,6 +21,7 @@ jest.mock('axios'); const SESSION_ID = 'session-1'; const UPSTREAM_BASE_URL = 'http://127.0.0.1:6099'; +const repoRoot = resolve(__dirname, '../../..'); const mockedAxiosPost = axios.post as jest.Mock; /** @@ -57,6 +60,15 @@ function createCredentialConfig(currentTime: { value: number }) { }; } +/** + * Reads source files from the API repository for proxy bootstrap boundary checks. + * @param relativePath - Repository-relative TypeScript source path. + * @returns File text used by structural regression assertions. + */ +function readApiSource(relativePath: string) { + return readFileSync(resolve(repoRoot, relativePath), 'utf8'); +} + describe('Napcat WebUI proxy rewrite helpers', () => { it('rejects absolute URL proxy paths', () => { expect(() => sanitizeGatewayProxyPath('https://evil.test/api')).toThrow( @@ -111,9 +123,7 @@ describe('Napcat WebUI proxy rewrite helpers', () => { sessionId: SESSION_ID, upstreamBaseUrl: UPSTREAM_BASE_URL, }), - ).toBe( - `/napcat-webui/session/${SESSION_ID}/webui/webui/login?next=/`, - ); + ).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`); expect( rewriteNapcatLocationHeader({ location: 'http://container.internal:6099/webui/login', @@ -130,9 +140,24 @@ describe('Napcat WebUI proxy rewrite helpers', () => { sessionId: SESSION_ID, upstreamBaseUrl: UPSTREAM_BASE_URL, }), - ).toBe( - `/napcat-webui/session/${SESSION_ID}/webui/webui/login?next=/`, - ); + ).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`); + }); + + it('drops upstream redirect query and hash fragments before returning browser locations', () => { + expect( + rewriteNapcatLocationHeader({ + location: '/webui/login?Credential=secret#token', + sessionId: SESSION_ID, + upstreamBaseUrl: UPSTREAM_BASE_URL, + }), + ).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`); + expect( + rewriteNapcatLocationHeader({ + location: 'webui/login?ticket=secret#hash', + sessionId: SESSION_ID, + upstreamBaseUrl: UPSTREAM_BASE_URL, + }), + ).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`); }); it('fails closed for malformed absolute redirects', () => { @@ -157,6 +182,20 @@ describe('Napcat WebUI proxy rewrite helpers', () => { '*': `/napcat-webui/session/${SESSION_ID}/webui`, }); }); + + it('keeps request bodies available for the public proxy route', () => { + const mainSource = readApiSource('src/apps/napcat-webui-gateway/main.ts'); + const proxySource = readApiSource( + 'src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts', + ); + + expect(mainSource).toContain("app.use('/internal', json"); + expect(mainSource).toContain("app.use('/internal', urlencoded"); + expect(mainSource).toContain('bodyParser: false'); + expect(mainSource).not.toContain('app.use(json({ limit'); + expect(mainSource).not.toContain('app.use(urlencoded({ extended'); + expect(proxySource).toContain('fixRequestBody'); + }); }); describe('NapcatWebuiCredentialClient', () => { diff --git a/test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts b/test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts index dcda0fd..ee26abd 100644 --- a/test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts +++ b/test/modules/qqbot/napcat-webui-gateway/api-session.service.spec.ts @@ -97,8 +97,6 @@ const createSafeSessionResponse = () => ({ selfId: '1914728559', }, container: { - id: '2001', - name: 'kt-qqbot-napcat-1914728559', webuiStatus: 'online', }, expiresAt: EXPIRES_AT_FIXTURE, @@ -394,8 +392,6 @@ describe('QqbotNapcatWebuiGatewayService', () => { selfId: '1914728559', }, container: { - id: '2001', - name: 'kt-qqbot-napcat-1914728559', webuiStatus: 'online', }, expiresAt: EXPIRES_AT_FIXTURE, @@ -416,6 +412,8 @@ describe('QqbotNapcatWebuiGatewayService', () => { }); expect(serialized).not.toContain(WEBUI_TOKEN_FIXTURE); expect(serialized).not.toContain('Credential'); + expect(serialized).not.toContain('2001'); + expect(serialized).not.toContain('kt-qqbot-napcat-1914728559'); expect(serialized).not.toContain('172.18.0.23'); expect(serialized).not.toContain('6099'); expect(serialized).not.toContain(INTERNAL_SECRET_FIXTURE);