From 99ecb4a35e0e55e041dd80bfeb6aed4edddbaa54 Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 24 Jun 2026 12:24:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8A=A0=E5=9B=BANapCat=20WebUI?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qqbot-napcat-webui-gateway.service.ts | 94 +++- .../qqbot-napcat-webui-gateway.controller.ts | 123 +++- .../qqbot-napcat-webui-gateway.client.ts | 159 +++++- .../api-session.service.spec.ts | 526 +++++++++++++++++- 4 files changed, 859 insertions(+), 43 deletions(-) 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 9613acc..684e880 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 @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { throwVbenError } from '@/common'; @@ -16,7 +16,12 @@ import { import type { QqbotNapcatWebuiSessionResponseDto } from '../contract/qqbot-napcat-webui-gateway.dto'; const SENSITIVE_DETAIL_KEY_PATTERN = - /^(baseUrl|captcha|captchaTicket|credential|dockerIp|headers|internalSecret|nasPath|password|qrPayload|qrcode|rawHeaders|secret|targetBaseUrl|ticket|token|upstreamBaseUrl|webuiPort|webuiToken)$/i; + /^(baseurl|captcha|captchaticket|credential|credentialheader|dockerip|headers|hostport|internalsecret|naspath|nasroute|password|qrpayload|qrcode|rawheaders|secret|targetbaseurl|ticket|token|upstreambaseurl|upstreamurl|webuiport|webuitoken)$/i; +const UNSAFE_DETAIL_STRING_PATTERN = + /(\bBearer\s+\S+|\bCredential\b|(?:^|[?&\s])(token|ticket|secret|password|credential|captcha)=|webui[_-]?token|https?:\/\/(?:127\.0\.0\.1|localhost|10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.|[^/\s]*:\d+)|\/internal\/sessions\b|\bnas(?:route|path)?\b|\/vol\d\b|\bdocker[_-]?ip\b)/i; +const REDACTED_DETAIL_VALUE = '[REDACTED]'; +const ACCOUNT_ID_PATTERN = /^[1-9]\d{0,31}$/; +const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; export type QqbotNapcatWebuiGatewaySessionCreateInput = { accountId: string; @@ -25,6 +30,13 @@ export type QqbotNapcatWebuiGatewaySessionCreateInput = { userAgent?: string | null; }; +export type QqbotNapcatWebuiGatewaySessionLifecycleInput = { + adminUserId: string; + clientIp?: string | null; + sessionId: string; + userAgent?: string | null; +}; + export type NapcatWebuiGatewayAuditRecordInput = { accountId: string; adminUserId: string; @@ -88,15 +100,37 @@ export class NapcatWebuiGatewayAuditService { if (Array.isArray(value)) { return value.map((item) => this.sanitizeValue(item)); } + if (typeof value === 'string') { + return this.isUnsafeDetailString(value) ? REDACTED_DETAIL_VALUE : value; + } if (!value || typeof value !== 'object') return value; return Object.fromEntries( Object.entries(value as Record) - .filter(([key]) => !SENSITIVE_DETAIL_KEY_PATTERN.test(key)) + .filter(([key]) => !this.isSensitiveDetailKey(key)) .map(([key, item]) => [key, this.sanitizeValue(item)]), ); } + /** + * Normalizes key style variants before checking whether a field can carry secrets. + * @param key - Raw object key from audit detail. + * @returns Whether the key should be dropped from persisted audit JSON. + */ + private isSensitiveDetailKey(key: string) { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ''); + return SENSITIVE_DETAIL_KEY_PATTERN.test(normalized); + } + + /** + * Detects secret-bearing string values that should never be persisted in audit detail. + * @param value - Raw string value from audit detail. + * @returns Whether the value should be replaced with a redaction marker. + */ + private isUnsafeDetailString(value: string) { + return UNSAFE_DETAIL_STRING_PATTERN.test(value); + } + /** * Converts optional client evidence into a bounded nullable column value. * @param value - Raw IP or user-agent value from the request. @@ -133,7 +167,8 @@ export class QqbotNapcatWebuiGatewayService { async createSession( input: QqbotNapcatWebuiGatewaySessionCreateInput, ): Promise { - const account = await this.accountService.findById(input.accountId); + const accountId = this.requireAccountId(input.accountId); + const account = await this.accountService.findById(accountId); if (!account) { throwVbenError('QQBot 账号不存在'); } @@ -169,7 +204,6 @@ export class QqbotNapcatWebuiGatewayService { detailJson: { accountName: account.name, containerName: runtime.name, - iframeUrl: gatewaySession.iframeUrl, webuiStatus, }, eventType: 'session.create', @@ -197,22 +231,60 @@ export class QqbotNapcatWebuiGatewayService { /** * Forwards a Gateway heartbeat for an existing Admin WebUI session. - * @param sessionId - Gateway session id returned by `createSession()`. + * @param input - Gateway session id plus Admin actor and request evidence. * @returns Gateway lifecycle response. */ heartbeat( - sessionId: string, + input: QqbotNapcatWebuiGatewaySessionLifecycleInput, ): Promise { - return this.gatewayClient.heartbeat(sessionId); + return this.gatewayClient.heartbeat({ + adminUserId: input.adminUserId, + clientIp: input.clientIp || undefined, + sessionId: this.requireSessionId(input.sessionId), + userAgent: input.userAgent || undefined, + }); } /** * Revokes an existing Admin WebUI Gateway session. - * @param sessionId - Gateway session id returned by `createSession()`. + * @param input - Gateway session id plus Admin actor and request evidence. * @returns Gateway lifecycle response. */ - revoke(sessionId: string): Promise { - return this.gatewayClient.revoke(sessionId); + revoke( + input: QqbotNapcatWebuiGatewaySessionLifecycleInput, + ): Promise { + return this.gatewayClient.revoke({ + adminUserId: input.adminUserId, + clientIp: input.clientIp || undefined, + sessionId: this.requireSessionId(input.sessionId), + userAgent: input.userAgent || undefined, + }); + } + + /** + * Validates a QQBot account id before querying persistence or container state. + * @param accountId - Candidate account id supplied by Admin. + * @returns Trimmed account id. + */ + private requireAccountId(accountId: string) { + const normalized = String(accountId || '').trim(); + if (!ACCOUNT_ID_PATTERN.test(normalized)) { + throwVbenError('QQBot 账号ID不合法', HttpStatus.BAD_REQUEST); + } + return normalized; + } + + /** + * Validates a Gateway session id before forwarding lifecycle calls. + * @param sessionId - Candidate session id from the Admin route. + * @returns Trimmed Gateway session id. + */ + private requireSessionId(sessionId: string) { + const normalized = String(sessionId || '').trim(); + if (!SESSION_ID_PATTERN.test(normalized)) { + throwVbenError('Gateway 会话ID不合法', HttpStatus.BAD_REQUEST); + } + return normalized; } /** diff --git a/src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller.ts b/src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller.ts index c9605d2..dbac065 100644 --- a/src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller.ts +++ b/src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller.ts @@ -9,7 +9,7 @@ import { UseGuards, } from '@nestjs/common'; import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { CurrentAdminUser, vbenSuccess } from '@/common'; +import { CurrentAdminUser, throwVbenError, vbenSuccess } from '@/common'; import type { AdminRequest } from '@/modules/admin/contract/admin.types'; import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard'; import { AdminUser } from '@/modules/admin/identity/user/admin-user.entity'; @@ -19,6 +19,10 @@ import { QqbotNapcatWebuiSessionResponseDto, } from './qqbot-napcat-webui-gateway.dto'; +const WEBUI_PERMISSION_AUTH_CODE = 'QqBot:Account:WebUI'; +const ACCOUNT_ID_PATTERN = /^[1-9]\d{0,31}$/; +const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; + @ApiTags('QQBot - NapCat WebUI Gateway') @Controller('qqbot/napcat/webui') @UseGuards(JwtAuthGuard) @@ -47,14 +51,12 @@ export class QqbotNapcatWebuiGatewayController { @CurrentAdminUser() user: AdminUser, @Req() req: AdminRequest, ) { - const userAgent = req.headers['user-agent']; + this.assertWebuiPermission(user); return vbenSuccess( await this.gatewayService.createSession({ - accountId: body.accountId, - adminUserId: user.id, - clientIp: req.ip, - userAgent: Array.isArray(userAgent) ? userAgent.join(', ') : userAgent, + accountId: this.requireAccountId(body.accountId), + ...this.toClientEvidence(user, req), }), ); } @@ -62,24 +64,125 @@ export class QqbotNapcatWebuiGatewayController { /** * Refreshes the Gateway heartbeat for one active WebUI session. * @param sessionId - Gateway session id returned by the create-session endpoint. + * @param user - Authenticated Admin user from JwtAuthGuard. + * @param req - Express request used only for IP and user-agent Gateway evidence. * @returns Vben response containing Gateway lifecycle state. */ @Post('session/:sessionId/heartbeat') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '刷新 NapCat WebUI Gateway 会话心跳' }) - async heartbeat(@Param('sessionId') sessionId: string) { - return vbenSuccess(await this.gatewayService.heartbeat(sessionId)); + async heartbeat( + @Param('sessionId') sessionId: string, + @CurrentAdminUser() user: AdminUser, + @Req() req: AdminRequest, + ) { + this.assertWebuiPermission(user); + + return vbenSuccess( + await this.gatewayService.heartbeat({ + sessionId: this.requireSessionId(sessionId), + ...this.toClientEvidence(user, req), + }), + ); } /** * Revokes one active WebUI Gateway session. * @param sessionId - Gateway session id returned by the create-session endpoint. + * @param user - Authenticated Admin user from JwtAuthGuard. + * @param req - Express request used only for IP and user-agent Gateway evidence. * @returns Vben response containing Gateway lifecycle state. */ @Post('session/:sessionId/revoke') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '撤销 NapCat WebUI Gateway 会话' }) - async revoke(@Param('sessionId') sessionId: string) { - return vbenSuccess(await this.gatewayService.revoke(sessionId)); + async revoke( + @Param('sessionId') sessionId: string, + @CurrentAdminUser() user: AdminUser, + @Req() req: AdminRequest, + ) { + this.assertWebuiPermission(user); + + return vbenSuccess( + await this.gatewayService.revoke({ + sessionId: this.requireSessionId(sessionId), + ...this.toClientEvidence(user, req), + }), + ); + } + + /** + * Enforces the Admin menu permission required for NapCat WebUI session access. + * @param user - Authenticated Admin user with eager role/menu relations. + */ + private assertWebuiPermission(user: AdminUser) { + if (!this.hasWebuiPermission(user)) { + throwVbenError('无权访问 NapCat WebUI', HttpStatus.FORBIDDEN); + } + } + + /** + * Checks active roles for WebUI menu permission, allowing active super role as bypass. + * @param user - Authenticated Admin user. + * @returns Whether the user may create or manage WebUI Gateway sessions. + */ + private hasWebuiPermission(user: AdminUser) { + const roles = Array.isArray(user?.roles) ? user.roles : []; + return roles.some((role) => { + if (!role || role.isDeleted || role.status !== 1) return false; + if (role.roleCode === 'super') return true; + + const menus = Array.isArray(role.menus) ? role.menus : []; + return menus.some((menu) => { + return ( + !!menu && + !menu.isDeleted && + (menu.status === undefined || menu.status === 1) && + menu.authCode === WEBUI_PERMISSION_AUTH_CODE + ); + }); + }); + } + + /** + * Validates a QQBot account id before handing work to the application service. + * @param accountId - Candidate account id from the request body. + * @returns Trimmed account id. + */ + private requireAccountId(accountId: string) { + const normalized = String(accountId || '').trim(); + if (!ACCOUNT_ID_PATTERN.test(normalized)) { + throwVbenError('QQBot 账号ID不合法', HttpStatus.BAD_REQUEST); + } + return normalized; + } + + /** + * Validates a Gateway session id before lifecycle forwarding. + * @param sessionId - Candidate session id from the route. + * @returns Trimmed Gateway session id. + */ + private requireSessionId(sessionId: string) { + const normalized = String(sessionId || '').trim(); + if (!SESSION_ID_PATTERN.test(normalized)) { + throwVbenError('Gateway 会话ID不合法', HttpStatus.BAD_REQUEST); + } + return normalized; + } + + /** + * Builds the Admin actor and client evidence passed through to Gateway ownership checks. + * @param user - Authenticated Admin user. + * @param req - Express request carrying IP and user-agent. + * @returns Lifecycle evidence for application and Gateway calls. + */ + private toClientEvidence(user: AdminUser, req: AdminRequest) { + const userAgent = req.headers['user-agent']; + + return { + adminUserId: user.id, + clientIp: req.ip, + userAgent: Array.isArray(userAgent) ? userAgent.join(', ') : userAgent, + }; } } diff --git a/src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client.ts b/src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client.ts index 6d07509..529efbd 100644 --- a/src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client.ts +++ b/src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client.ts @@ -5,6 +5,11 @@ import { throwVbenError } from '@/common'; const DEFAULT_GATEWAY_BASE_URL = 'http://127.0.0.1:48086'; const DEFAULT_GATEWAY_TIMEOUT_MS = 5000; +const GATEWAY_PUBLIC_SESSION_PREFIX = '/napcat-webui/session/'; +const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; +const SAFE_BOOTSTRAP_TICKET_PATTERN = /^[A-Za-z0-9._~-]+$/; +const UNSAFE_GATEWAY_RESULT_PATTERN = + /(\bCredential\b|\bBearer\s+\S+|webui[_-]?token|(?:^|[?&\s])(token|secret|password|credential|captcha)=|https?:\/\/|\/\/|127\.0\.0\.1|localhost|10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.|:\d{2,5}\b|\bdocker\b|\bnas\b|\/vol\d\b|\/internal\/sessions\b)/i; export type QqbotNapcatWebuiGatewayCreateSessionRequest = { accountId: string; @@ -18,6 +23,13 @@ export type QqbotNapcatWebuiGatewayCreateSessionRequest = { webuiToken: string; }; +export type QqbotNapcatWebuiGatewayLifecycleRequest = { + adminUserId: string; + clientIp?: string; + sessionId: string; + userAgent?: string; +}; + export type QqbotNapcatWebuiGatewaySessionResult = { expiresAt: number; iframeUrl: string; @@ -41,32 +53,38 @@ export class QqbotNapcatWebuiGatewayClient { * @param input - Server-only target metadata, including WebUI token and upstream endpoint. * @returns Browser-safe Gateway session metadata. */ - createSession(input: QqbotNapcatWebuiGatewayCreateSessionRequest) { - return this.post( - '/internal/sessions', - input, + async createSession(input: QqbotNapcatWebuiGatewayCreateSessionRequest) { + return this.validateSessionResult( + await this.post( + '/internal/sessions', + input, + ), ); } /** * Refreshes one Gateway session heartbeat without exposing internal target data. - * @param sessionId - Gateway session id previously returned to Admin. + * @param input - Gateway session id plus Admin actor and request evidence. * @returns Gateway lifecycle response body. */ - heartbeat(sessionId: string) { + heartbeat(input: QqbotNapcatWebuiGatewayLifecycleRequest) { + const { sessionId, ...data } = input; return this.post( `/internal/sessions/${encodeURIComponent(sessionId)}/heartbeat`, + data, ); } /** * Revokes one Gateway session without exposing internal target data. - * @param sessionId - Gateway session id previously returned to Admin. + * @param input - Gateway session id plus Admin actor and request evidence. * @returns Gateway lifecycle response body. */ - revoke(sessionId: string) { + revoke(input: QqbotNapcatWebuiGatewayLifecycleRequest) { + const { sessionId, ...data } = input; return this.post( `/internal/sessions/${encodeURIComponent(sessionId)}/revoke`, + data, ); } @@ -121,12 +139,29 @@ export class QqbotNapcatWebuiGatewayClient { * @returns Header map when a secret is configured, otherwise undefined. */ private getHeaders() { - const secret = ( + const secret = this.getInternalSecret(); + + return { 'x-kt-gateway-secret': secret }; + } + + /** + * Reads the required internal Gateway secret and fails closed when it is missing. + * @returns Configured non-empty shared secret. + */ + private getInternalSecret() { + const secret = String( this.configService.get('NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET') || - '' + '', ).trim(); - return secret ? { 'x-kt-gateway-secret': secret } : undefined; + if (!secret) { + throwVbenError( + 'NapCat WebUI Gateway 内部密钥未配置', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return secret; } /** @@ -155,4 +190,106 @@ export class QqbotNapcatWebuiGatewayClient { return body as T; } + + /** + * Validates the create-session result before returning it to Admin callers. + * @param result - Raw Gateway create-session result. + * @returns Browser-safe session result. + */ + private validateSessionResult( + result: QqbotNapcatWebuiGatewaySessionResult, + ): QqbotNapcatWebuiGatewaySessionResult { + if (!result || typeof result !== 'object') { + this.throwInvalidSessionResult(); + } + + const sessionId = String(result.sessionId || '').trim(); + if (!SESSION_ID_PATTERN.test(sessionId)) { + this.throwInvalidSessionResult(); + } + if (!Number.isFinite(result.expiresAt)) { + this.throwInvalidSessionResult(); + } + if (!this.isSafeIframeUrl(result.iframeUrl, sessionId)) { + this.throwInvalidSessionResult(); + } + + return { + expiresAt: result.expiresAt, + iframeUrl: result.iframeUrl, + sessionId, + }; + } + + /** + * Ensures the iframe URL is a relative Gateway-owned route with only an optional bootstrap ticket. + * @param iframeUrl - Raw iframe URL returned by Gateway. + * @param sessionId - Validated Gateway session id. + * @returns Whether the URL is safe for the browser response. + */ + private isSafeIframeUrl(iframeUrl: unknown, sessionId: string) { + if (typeof iframeUrl !== 'string' || iframeUrl.trim() !== iframeUrl) { + return false; + } + if (!iframeUrl.startsWith(GATEWAY_PUBLIC_SESSION_PREFIX)) return false; + if (iframeUrl.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(iframeUrl)) { + return false; + } + if (iframeUrl.includes('\\')) return false; + + const [path, query = ''] = iframeUrl.split('?'); + const expectedPrefix = `${GATEWAY_PUBLIC_SESSION_PREFIX}${sessionId}/`; + if (!path.startsWith(expectedPrefix)) return false; + + const params = new URLSearchParams(query); + const hasTicket = params.has('ticket'); + if (hasTicket) { + const ticket = params.get('ticket') || ''; + if (path !== `${expectedPrefix}bootstrap`) return false; + if ([...params.keys()].some((key) => key !== 'ticket')) return false; + if (!SAFE_BOOTSTRAP_TICKET_PATTERN.test(ticket)) return false; + } else if (/ticket/i.test(iframeUrl)) { + return false; + } else if (query) { + return false; + } + + const unsafeScanValue = hasTicket + ? `${path}?ticket=` + : iframeUrl; + return !this.hasUnsafeGatewayEvidence(unsafeScanValue); + } + + /** + * Detects host, secret, and internal-route evidence in Gateway browser-facing URLs. + * @param value - Candidate iframe URL with allowed bootstrap ticket value stripped. + * @returns Whether the string contains unsafe evidence. + */ + private hasUnsafeGatewayEvidence(value: string) { + const decoded = this.tryDecodeURIComponent(value); + return UNSAFE_GATEWAY_RESULT_PATTERN.test(decoded); + } + + /** + * Decodes URL text for security scanning without leaking parsing errors to callers. + * @param value - URL text to decode. + * @returns Decoded value when possible, otherwise the original text. + */ + private tryDecodeURIComponent(value: string) { + try { + return decodeURIComponent(value); + } catch { + return value; + } + } + + /** + * Throws the sanitized error used for invalid Gateway create-session responses. + */ + private throwInvalidSessionResult(): never { + return throwVbenError( + 'NapCat WebUI Gateway 返回无效会话', + HttpStatus.BAD_GATEWAY, + ); + } } 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 0ff9d48..36aa07c 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 @@ -1,14 +1,18 @@ import { readFileSync } from 'fs'; import { resolve } from 'path'; -import { HttpException } from '@nestjs/common'; +import { HttpException, HttpStatus, type INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; import { getMetadataArgsStorage } from 'typeorm'; +import * as request from 'supertest'; import axios from 'axios'; import { QqbotNapcatWebuiGatewayService, NapcatWebuiGatewayAuditService, } from '../../../../src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service'; +import { QqbotNapcatWebuiGatewayController } from '../../../../src/modules/qqbot/napcat/webui-gateway/contract/qqbot-napcat-webui-gateway.controller'; import { QqbotNapcatWebuiGatewayClient } from '../../../../src/modules/qqbot/napcat/webui-gateway/infrastructure/qqbot-napcat-webui-gateway.client'; import { NapcatWebuiGatewayAudit } from '../../../../src/modules/qqbot/napcat/webui-gateway/infrastructure/persistence/napcat-webui-gateway-audit.entity'; +import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-auth.guard'; import { NAPCAT_RUNTIME_DOMAIN_CONTRACT, NAPCAT_RUNTIME_ENTITIES, @@ -20,8 +24,25 @@ const repoRoot = resolve(__dirname, '../../../..'); const requestMock = axios.request as jest.Mock; const EXPIRES_AT_FIXTURE = 1782268000000; const INTERNAL_SECRET_FIXTURE = ['internal', 'secret'].join('-'); +const WEBUI_PERMISSION = 'QqBot:Account:WebUI'; const WEBUI_TOKEN_FIXTURE = ['webui', 'token', 'fixture'].join('-'); +type TestAdminRole = { + isDeleted?: boolean; + menus?: Array<{ + authCode?: null | string; + isDeleted?: boolean; + status?: number; + }>; + roleCode: string; + status?: number; +}; + +type TestAdminUser = { + id: string; + roles: TestAdminRole[]; +}; + type MockRepository = { create: jest.Mock]>; rows: T[]; @@ -55,6 +76,36 @@ const createRepository = () => { return repository; }; +/** + * Creates a minimal Admin user shape consumed by `@CurrentAdminUser()`. + * @param roles - Roles and menu auth codes to attach to the test user. + * @returns Admin user-like object for controller tests. + */ +const createAdminUser = (roles: TestAdminRole[]): TestAdminUser => ({ + id: '3001', + roles, +}); + +/** + * Creates a reusable browser-safe session result for controller tests. + * @returns Safe Gateway session payload returned by the mocked service. + */ +const createSafeSessionResponse = () => ({ + account: { + id: '1001', + name: '主机器人', + selfId: '1914728559', + }, + container: { + id: '2001', + name: 'kt-qqbot-napcat-1914728559', + webuiStatus: 'online', + }, + expiresAt: EXPIRES_AT_FIXTURE, + iframeUrl: '/napcat-webui/session/sess_1/bootstrap?ticket=bootstrap-ticket-1', + sessionId: 'sess_1', +}); + /** * Extracts a readable message from Vben-style HTTP exceptions. * @param error - Error thrown by the service under test. @@ -68,6 +119,160 @@ const getThrownMessage = (error: unknown) => { return error instanceof Error ? error.message : String(error); }; +describe('QqbotNapcatWebuiGatewayController', () => { + let app: INestApplication; + let currentAdminUser: TestAdminUser; + const gatewayService = { + createSession: jest.fn(), + heartbeat: jest.fn(), + revoke: jest.fn(), + }; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [QqbotNapcatWebuiGatewayController], + providers: [ + { + provide: QqbotNapcatWebuiGatewayService, + useValue: gatewayService, + }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ + /** + * Supplies a request-scoped Admin user while bypassing JWT parsing. + * @param context - Nest execution context for the current HTTP request. + * @returns Always true so controller-level authorization can be tested. + */ + canActivate: (context) => { + context.switchToHttp().getRequest().adminUser = currentAdminUser; + return true; + }, + }) + .compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + beforeEach(() => { + currentAdminUser = createAdminUser([ + { + menus: [{ authCode: WEBUI_PERMISSION, status: 1 }], + roleCode: 'operator', + status: 1, + }, + ]); + gatewayService.createSession.mockResolvedValue(createSafeSessionResponse()); + gatewayService.heartbeat.mockResolvedValue({ ok: true, status: 'active' }); + gatewayService.revoke.mockResolvedValue({ ok: true, status: 'revoked' }); + jest.clearAllMocks(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('denies non-super Admin users without WebUI permission', async () => { + currentAdminUser = createAdminUser([ + { + menus: [{ authCode: 'QqBot:Account:List', status: 1 }], + roleCode: 'operator', + status: 1, + }, + ]); + + await request(app.getHttpServer()) + .post('/qqbot/napcat/webui/session') + .send({ accountId: '1001' }) + .expect(HttpStatus.FORBIDDEN); + + expect(gatewayService.createSession).not.toHaveBeenCalled(); + }); + + it('allows Admin users with WebUI permission and passes create-session evidence', async () => { + const response = await request(app.getHttpServer()) + .post('/qqbot/napcat/webui/session') + .set('user-agent', 'controller-agent') + .send({ accountId: '1001' }) + .expect(HttpStatus.OK); + + expect(response.body.data).toEqual(createSafeSessionResponse()); + expect(gatewayService.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: '1001', + adminUserId: '3001', + clientIp: expect.any(String), + userAgent: 'controller-agent', + }), + ); + }); + + it('allows active super role as a WebUI permission bypass', async () => { + currentAdminUser = createAdminUser([ + { + menus: [], + roleCode: 'super', + status: 1, + }, + ]); + + await request(app.getHttpServer()) + .post('/qqbot/napcat/webui/session') + .send({ accountId: '1001' }) + .expect(HttpStatus.OK); + + expect(gatewayService.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: '1001', + adminUserId: '3001', + }), + ); + }); + + it('passes heartbeat and revoke ownership evidence to the service', async () => { + await request(app.getHttpServer()) + .post('/qqbot/napcat/webui/session/sess_1/heartbeat') + .set('user-agent', 'controller-agent') + .expect(HttpStatus.OK); + await request(app.getHttpServer()) + .post('/qqbot/napcat/webui/session/sess_1/revoke') + .set('user-agent', 'controller-agent') + .expect(HttpStatus.OK); + + expect(gatewayService.heartbeat).toHaveBeenCalledWith( + expect.objectContaining({ + adminUserId: '3001', + clientIp: expect.any(String), + sessionId: 'sess_1', + userAgent: 'controller-agent', + }), + ); + expect(gatewayService.revoke).toHaveBeenCalledWith( + expect.objectContaining({ + adminUserId: '3001', + clientIp: expect.any(String), + sessionId: 'sess_1', + userAgent: 'controller-agent', + }), + ); + }); + + it('rejects malformed account and session identifiers before service calls', async () => { + await request(app.getHttpServer()) + .post('/qqbot/napcat/webui/session') + .send({ accountId: ' ' }) + .expect(HttpStatus.BAD_REQUEST); + await request(app.getHttpServer()) + .post('/qqbot/napcat/webui/session/bad%20session/heartbeat') + .expect(HttpStatus.BAD_REQUEST); + + expect(gatewayService.createSession).not.toHaveBeenCalled(); + expect(gatewayService.heartbeat).not.toHaveBeenCalled(); + }); +}); + describe('QqbotNapcatWebuiGatewayService', () => { beforeEach(() => { requestMock.mockReset(); @@ -89,7 +294,8 @@ describe('QqbotNapcatWebuiGatewayService', () => { }; const gatewayResult = { expiresAt: EXPIRES_AT_FIXTURE, - iframeUrl: '/napcat-webui-gateway/session/sess_1/', + iframeUrl: + '/napcat-webui/session/sess_1/bootstrap?ticket=bootstrap-ticket-1', sessionId: 'sess_1', }; const accountService = { @@ -134,7 +340,8 @@ describe('QqbotNapcatWebuiGatewayService', () => { webuiStatus: 'online', }, expiresAt: EXPIRES_AT_FIXTURE, - iframeUrl: '/napcat-webui-gateway/session/sess_1/', + iframeUrl: + '/napcat-webui/session/sess_1/bootstrap?ticket=bootstrap-ticket-1', sessionId: 'sess_1', }); expect(client.createSession).toHaveBeenCalledWith({ @@ -169,11 +376,12 @@ describe('QqbotNapcatWebuiGatewayService', () => { expect(savedAudit.detailJson).toEqual({ accountName: '主机器人', containerName: 'kt-qqbot-napcat-1914728559', - iframeUrl: '/napcat-webui-gateway/session/sess_1/', webuiStatus: 'online', }); expect(serializedAudit).not.toContain(WEBUI_TOKEN_FIXTURE); expect(serializedAudit).not.toContain('Credential'); + expect(serializedAudit).not.toContain('bootstrap-ticket-1'); + expect(serializedAudit).not.toContain('ticket'); expect(serializedAudit).not.toContain('172.18.0.23'); expect(serializedAudit).not.toContain('6099'); expect(serializedAudit).not.toContain(INTERNAL_SECRET_FIXTURE); @@ -224,7 +432,51 @@ describe('QqbotNapcatWebuiGatewayService', () => { expect(auditRepository.save).not.toHaveBeenCalled(); }); - it('forwards heartbeat and revoke to the Gateway client', async () => { + it('rejects malformed account and session ids before downstream calls', async () => { + const accountService = { + findById: jest.fn(), + }; + const containerService = { + findPrimaryContainerByAccountId: jest.fn(), + }; + const client = { + heartbeat: jest.fn(), + }; + const service = new QqbotNapcatWebuiGatewayService( + accountService as never, + containerService as never, + client as unknown as QqbotNapcatWebuiGatewayClient, + new NapcatWebuiGatewayAuditService( + createRepository() as never, + ), + ); + let createThrown: unknown; + let heartbeatThrown: unknown; + + try { + await service.createSession({ + accountId: 'not-a-snowflake', + adminUserId: '3001', + }); + } catch (error) { + createThrown = error; + } + try { + await service.heartbeat({ + adminUserId: '3001', + sessionId: 'bad session', + }); + } catch (error) { + heartbeatThrown = error; + } + + expect(getThrownMessage(createThrown)).toBe('QQBot 账号ID不合法'); + expect(getThrownMessage(heartbeatThrown)).toBe('Gateway 会话ID不合法'); + expect(accountService.findById).not.toHaveBeenCalled(); + expect(client.heartbeat).not.toHaveBeenCalled(); + }); + + it('forwards heartbeat and revoke ownership evidence to the Gateway client', async () => { const client = { heartbeat: jest.fn(async () => ({ ok: true, status: 'active' })), revoke: jest.fn(async () => ({ ok: true, status: 'revoked' })), @@ -238,16 +490,96 @@ describe('QqbotNapcatWebuiGatewayService', () => { ), ); - await expect(service.heartbeat('sess_1')).resolves.toEqual({ + await expect( + service.heartbeat({ + adminUserId: '3001', + clientIp: '127.0.0.1', + sessionId: 'sess_1', + userAgent: 'jest-agent', + }), + ).resolves.toEqual({ ok: true, status: 'active', }); - await expect(service.revoke('sess_1')).resolves.toEqual({ + await expect( + service.revoke({ + adminUserId: '3001', + clientIp: '127.0.0.1', + sessionId: 'sess_1', + userAgent: 'jest-agent', + }), + ).resolves.toEqual({ ok: true, status: 'revoked', }); - expect(client.heartbeat).toHaveBeenCalledWith('sess_1'); - expect(client.revoke).toHaveBeenCalledWith('sess_1'); + expect(client.heartbeat).toHaveBeenCalledWith({ + adminUserId: '3001', + clientIp: '127.0.0.1', + sessionId: 'sess_1', + userAgent: 'jest-agent', + }); + expect(client.revoke).toHaveBeenCalledWith({ + adminUserId: '3001', + clientIp: '127.0.0.1', + sessionId: 'sess_1', + userAgent: 'jest-agent', + }); + }); + + it('redacts audit key variants and unsafe string values', async () => { + const auditRepository = createRepository(); + const audit = new NapcatWebuiGatewayAuditService( + auditRepository as never, + ); + + await audit.record({ + accountId: '1001', + adminUserId: '3001', + clientIp: '127.0.0.1', + containerId: '2001', + detailJson: { + credentialHeader: 'Credential abc', + docker_ip: '172.18.0.23', + hostPort: '6099', + nasRoute: 'nas.kwitsukasa.top:2202', + nested: { + display: 'visible', + loginMessage: 'Bearer abc', + redirectPath: '/napcat-webui/session/sess_1/bootstrap?ticket=abc', + }, + safe: 'visible', + unsafeList: [ + 'plain text', + 'token=abc', + 'http://127.0.0.1:48086/internal/sessions', + ], + upstreamUrl: 'http://172.18.0.23:6099', + webui_token: WEBUI_TOKEN_FIXTURE, + }, + eventType: 'session.create', + selfId: '1914728559', + sessionId: 'sess_1', + userAgent: 'jest-agent', + }); + + const savedAudit = auditRepository.rows[0]; + + expect(savedAudit.detailJson).toEqual({ + nested: { + display: 'visible', + loginMessage: '[REDACTED]', + redirectPath: '[REDACTED]', + }, + safe: 'visible', + unsafeList: ['plain text', '[REDACTED]', '[REDACTED]'], + }); + expect(JSON.stringify(savedAudit.detailJson)).not.toContain( + WEBUI_TOKEN_FIXTURE, + ); + expect(JSON.stringify(savedAudit.detailJson)).not.toContain('Credential'); + expect(JSON.stringify(savedAudit.detailJson)).not.toContain('172.18.0.23'); + expect(JSON.stringify(savedAudit.detailJson)).not.toContain('6099'); + expect(JSON.stringify(savedAudit.detailJson)).not.toContain('ticket'); }); }); @@ -261,7 +593,8 @@ describe('QqbotNapcatWebuiGatewayClient', () => { data: { data: { expiresAt: EXPIRES_AT_FIXTURE, - iframeUrl: '/napcat-webui-gateway/session/sess_1/', + iframeUrl: + '/napcat-webui/session/sess_1/bootstrap?ticket=bootstrap-ticket-1', sessionId: 'sess_1', }, }, @@ -310,11 +643,182 @@ describe('QqbotNapcatWebuiGatewayClient', () => { }); expect(result).toEqual({ expiresAt: EXPIRES_AT_FIXTURE, - iframeUrl: '/napcat-webui-gateway/session/sess_1/', + iframeUrl: + '/napcat-webui/session/sess_1/bootstrap?ticket=bootstrap-ticket-1', sessionId: 'sess_1', }); }); + it('requires the internal Gateway secret before calling axios', async () => { + const configService = { + get: jest.fn((key: string) => { + return { + NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL: 'http://127.0.0.1:48086', + NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET: ' ', + NAPCAT_WEBUI_GATEWAY_TIMEOUT_MS: '1234', + }[key]; + }), + }; + const client = new QqbotNapcatWebuiGatewayClient(configService as never); + let thrown: unknown; + + try { + await client.createSession({ + accountId: '1001', + adminUserId: '3001', + clientIp: '127.0.0.1', + containerId: '2001', + containerName: 'kt-qqbot-napcat-1914728559', + selfId: '1914728559', + upstreamBaseUrl: 'http://172.18.0.23:6099', + userAgent: 'jest-agent', + webuiToken: WEBUI_TOKEN_FIXTURE, + }); + } catch (error) { + thrown = error; + } + + expect(requestMock).not.toHaveBeenCalled(); + expect(getThrownMessage(thrown)).toBe( + 'NapCat WebUI Gateway 内部密钥未配置', + ); + expect(JSON.stringify(thrown)).not.toContain(WEBUI_TOKEN_FIXTURE); + expect(JSON.stringify(thrown)).not.toContain('172.18.0.23'); + expect(JSON.stringify(thrown)).not.toContain('6099'); + }); + + it('posts heartbeat and revoke requests with Admin ownership evidence', async () => { + requestMock.mockResolvedValueOnce({ + data: { ok: true, status: 'active' }, + }); + requestMock.mockResolvedValueOnce({ + data: { ok: true, status: 'revoked' }, + }); + const configService = { + get: jest.fn((key: string) => { + return { + NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL: 'http://127.0.0.1:48086', + NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET: INTERNAL_SECRET_FIXTURE, + NAPCAT_WEBUI_GATEWAY_TIMEOUT_MS: '1234', + }[key]; + }), + }; + const client = new QqbotNapcatWebuiGatewayClient(configService as never); + + await expect( + client.heartbeat({ + adminUserId: '3001', + clientIp: '127.0.0.1', + sessionId: 'sess_1', + userAgent: 'jest-agent', + }), + ).resolves.toEqual({ ok: true, status: 'active' }); + await expect( + client.revoke({ + adminUserId: '3001', + clientIp: '127.0.0.1', + sessionId: 'sess_1', + userAgent: 'jest-agent', + }), + ).resolves.toEqual({ ok: true, status: 'revoked' }); + + expect(requestMock).toHaveBeenNthCalledWith(1, { + data: { + adminUserId: '3001', + clientIp: '127.0.0.1', + userAgent: 'jest-agent', + }, + headers: { + 'x-kt-gateway-secret': INTERNAL_SECRET_FIXTURE, + }, + method: 'POST', + timeout: 1234, + url: 'http://127.0.0.1:48086/internal/sessions/sess_1/heartbeat', + }); + expect(requestMock).toHaveBeenNthCalledWith(2, { + data: { + adminUserId: '3001', + clientIp: '127.0.0.1', + userAgent: 'jest-agent', + }, + headers: { + 'x-kt-gateway-secret': INTERNAL_SECRET_FIXTURE, + }, + method: 'POST', + timeout: 1234, + url: 'http://127.0.0.1:48086/internal/sessions/sess_1/revoke', + }); + }); + + it.each([ + [ + 'non-number expiresAt', + { + expiresAt: '1782268000000', + iframeUrl: '/napcat-webui/session/sess_1/', + sessionId: 'sess_1', + }, + ], + [ + 'unsafe absolute iframeUrl', + { + expiresAt: EXPIRES_AT_FIXTURE, + iframeUrl: + 'http://172.18.0.23:6099/napcat-webui/session/sess_1/bootstrap?ticket=abc', + sessionId: 'sess_1', + }, + ], + [ + 'ticket outside bootstrap route', + { + expiresAt: EXPIRES_AT_FIXTURE, + iframeUrl: '/napcat-webui/session/sess_1/ticket/abc', + sessionId: 'sess_1', + }, + ], + ])('rejects invalid Gateway create-session result: %s', async (_case, body) => { + requestMock.mockResolvedValue({ + data: { + data: body, + }, + }); + const configService = { + get: jest.fn((key: string) => { + return { + NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL: 'http://127.0.0.1:48086', + NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET: INTERNAL_SECRET_FIXTURE, + NAPCAT_WEBUI_GATEWAY_TIMEOUT_MS: '1234', + }[key]; + }), + }; + const client = new QqbotNapcatWebuiGatewayClient(configService as never); + let thrown: unknown; + + try { + await client.createSession({ + accountId: '1001', + adminUserId: '3001', + clientIp: '127.0.0.1', + containerId: '2001', + containerName: 'kt-qqbot-napcat-1914728559', + selfId: '1914728559', + upstreamBaseUrl: 'http://172.18.0.23:6099', + userAgent: 'jest-agent', + webuiToken: WEBUI_TOKEN_FIXTURE, + }); + } catch (error) { + thrown = error; + } + + expect(getThrownMessage(thrown)).toBe( + 'NapCat WebUI Gateway 返回无效会话', + ); + expect(JSON.stringify(thrown)).not.toContain(WEBUI_TOKEN_FIXTURE); + expect(JSON.stringify(thrown)).not.toContain(INTERNAL_SECRET_FIXTURE); + expect(JSON.stringify(thrown)).not.toContain('172.18.0.23'); + expect(JSON.stringify(thrown)).not.toContain('6099'); + }); + it('sanitizes Gateway client errors before returning them to Admin', async () => { requestMock.mockRejectedValue( new Error(