fix: 加固NapCat WebUI会话安全
This commit is contained in:
parent
156ba8147e
commit
99ecb4a35e
@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { throwVbenError } from '@/common';
|
import { throwVbenError } from '@/common';
|
||||||
@ -16,7 +16,12 @@ import {
|
|||||||
import type { QqbotNapcatWebuiSessionResponseDto } from '../contract/qqbot-napcat-webui-gateway.dto';
|
import type { QqbotNapcatWebuiSessionResponseDto } from '../contract/qqbot-napcat-webui-gateway.dto';
|
||||||
|
|
||||||
const SENSITIVE_DETAIL_KEY_PATTERN =
|
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 = {
|
export type QqbotNapcatWebuiGatewaySessionCreateInput = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@ -25,6 +30,13 @@ export type QqbotNapcatWebuiGatewaySessionCreateInput = {
|
|||||||
userAgent?: string | null;
|
userAgent?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QqbotNapcatWebuiGatewaySessionLifecycleInput = {
|
||||||
|
adminUserId: string;
|
||||||
|
clientIp?: string | null;
|
||||||
|
sessionId: string;
|
||||||
|
userAgent?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type NapcatWebuiGatewayAuditRecordInput = {
|
export type NapcatWebuiGatewayAuditRecordInput = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
adminUserId: string;
|
adminUserId: string;
|
||||||
@ -88,15 +100,37 @@ export class NapcatWebuiGatewayAuditService {
|
|||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value.map((item) => this.sanitizeValue(item));
|
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;
|
if (!value || typeof value !== 'object') return value;
|
||||||
|
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(value as Record<string, unknown>)
|
Object.entries(value as Record<string, unknown>)
|
||||||
.filter(([key]) => !SENSITIVE_DETAIL_KEY_PATTERN.test(key))
|
.filter(([key]) => !this.isSensitiveDetailKey(key))
|
||||||
.map(([key, item]) => [key, this.sanitizeValue(item)]),
|
.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.
|
* Converts optional client evidence into a bounded nullable column value.
|
||||||
* @param value - Raw IP or user-agent value from the request.
|
* @param value - Raw IP or user-agent value from the request.
|
||||||
@ -133,7 +167,8 @@ export class QqbotNapcatWebuiGatewayService {
|
|||||||
async createSession(
|
async createSession(
|
||||||
input: QqbotNapcatWebuiGatewaySessionCreateInput,
|
input: QqbotNapcatWebuiGatewaySessionCreateInput,
|
||||||
): Promise<QqbotNapcatWebuiSessionResponseDto> {
|
): Promise<QqbotNapcatWebuiSessionResponseDto> {
|
||||||
const account = await this.accountService.findById(input.accountId);
|
const accountId = this.requireAccountId(input.accountId);
|
||||||
|
const account = await this.accountService.findById(accountId);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throwVbenError('QQBot 账号不存在');
|
throwVbenError('QQBot 账号不存在');
|
||||||
}
|
}
|
||||||
@ -169,7 +204,6 @@ export class QqbotNapcatWebuiGatewayService {
|
|||||||
detailJson: {
|
detailJson: {
|
||||||
accountName: account.name,
|
accountName: account.name,
|
||||||
containerName: runtime.name,
|
containerName: runtime.name,
|
||||||
iframeUrl: gatewaySession.iframeUrl,
|
|
||||||
webuiStatus,
|
webuiStatus,
|
||||||
},
|
},
|
||||||
eventType: 'session.create',
|
eventType: 'session.create',
|
||||||
@ -197,22 +231,60 @@ export class QqbotNapcatWebuiGatewayService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Forwards a Gateway heartbeat for an existing Admin WebUI session.
|
* 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.
|
* @returns Gateway lifecycle response.
|
||||||
*/
|
*/
|
||||||
heartbeat(
|
heartbeat(
|
||||||
sessionId: string,
|
input: QqbotNapcatWebuiGatewaySessionLifecycleInput,
|
||||||
): Promise<QqbotNapcatWebuiGatewayLifecycleResult> {
|
): Promise<QqbotNapcatWebuiGatewayLifecycleResult> {
|
||||||
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.
|
* 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.
|
* @returns Gateway lifecycle response.
|
||||||
*/
|
*/
|
||||||
revoke(sessionId: string): Promise<QqbotNapcatWebuiGatewayLifecycleResult> {
|
revoke(
|
||||||
return this.gatewayClient.revoke(sessionId);
|
input: QqbotNapcatWebuiGatewaySessionLifecycleInput,
|
||||||
|
): Promise<QqbotNapcatWebuiGatewayLifecycleResult> {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
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 type { AdminRequest } from '@/modules/admin/contract/admin.types';
|
||||||
import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard';
|
||||||
import { AdminUser } from '@/modules/admin/identity/user/admin-user.entity';
|
import { AdminUser } from '@/modules/admin/identity/user/admin-user.entity';
|
||||||
@ -19,6 +19,10 @@ import {
|
|||||||
QqbotNapcatWebuiSessionResponseDto,
|
QqbotNapcatWebuiSessionResponseDto,
|
||||||
} from './qqbot-napcat-webui-gateway.dto';
|
} 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')
|
@ApiTags('QQBot - NapCat WebUI Gateway')
|
||||||
@Controller('qqbot/napcat/webui')
|
@Controller('qqbot/napcat/webui')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ -47,14 +51,12 @@ export class QqbotNapcatWebuiGatewayController {
|
|||||||
@CurrentAdminUser() user: AdminUser,
|
@CurrentAdminUser() user: AdminUser,
|
||||||
@Req() req: AdminRequest,
|
@Req() req: AdminRequest,
|
||||||
) {
|
) {
|
||||||
const userAgent = req.headers['user-agent'];
|
this.assertWebuiPermission(user);
|
||||||
|
|
||||||
return vbenSuccess(
|
return vbenSuccess(
|
||||||
await this.gatewayService.createSession({
|
await this.gatewayService.createSession({
|
||||||
accountId: body.accountId,
|
accountId: this.requireAccountId(body.accountId),
|
||||||
adminUserId: user.id,
|
...this.toClientEvidence(user, req),
|
||||||
clientIp: req.ip,
|
|
||||||
userAgent: Array.isArray(userAgent) ? userAgent.join(', ') : userAgent,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -62,24 +64,125 @@ export class QqbotNapcatWebuiGatewayController {
|
|||||||
/**
|
/**
|
||||||
* Refreshes the Gateway heartbeat for one active WebUI session.
|
* Refreshes the Gateway heartbeat for one active WebUI session.
|
||||||
* @param sessionId - Gateway session id returned by the create-session endpoint.
|
* @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.
|
* @returns Vben response containing Gateway lifecycle state.
|
||||||
*/
|
*/
|
||||||
@Post('session/:sessionId/heartbeat')
|
@Post('session/:sessionId/heartbeat')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '刷新 NapCat WebUI Gateway 会话心跳' })
|
@ApiOperation({ summary: '刷新 NapCat WebUI Gateway 会话心跳' })
|
||||||
async heartbeat(@Param('sessionId') sessionId: string) {
|
async heartbeat(
|
||||||
return vbenSuccess(await this.gatewayService.heartbeat(sessionId));
|
@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.
|
* Revokes one active WebUI Gateway session.
|
||||||
* @param sessionId - Gateway session id returned by the create-session endpoint.
|
* @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.
|
* @returns Vben response containing Gateway lifecycle state.
|
||||||
*/
|
*/
|
||||||
@Post('session/:sessionId/revoke')
|
@Post('session/:sessionId/revoke')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '撤销 NapCat WebUI Gateway 会话' })
|
@ApiOperation({ summary: '撤销 NapCat WebUI Gateway 会话' })
|
||||||
async revoke(@Param('sessionId') sessionId: string) {
|
async revoke(
|
||||||
return vbenSuccess(await this.gatewayService.revoke(sessionId));
|
@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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,11 @@ import { throwVbenError } from '@/common';
|
|||||||
|
|
||||||
const DEFAULT_GATEWAY_BASE_URL = 'http://127.0.0.1:48086';
|
const DEFAULT_GATEWAY_BASE_URL = 'http://127.0.0.1:48086';
|
||||||
const DEFAULT_GATEWAY_TIMEOUT_MS = 5000;
|
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 = {
|
export type QqbotNapcatWebuiGatewayCreateSessionRequest = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@ -18,6 +23,13 @@ export type QqbotNapcatWebuiGatewayCreateSessionRequest = {
|
|||||||
webuiToken: string;
|
webuiToken: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QqbotNapcatWebuiGatewayLifecycleRequest = {
|
||||||
|
adminUserId: string;
|
||||||
|
clientIp?: string;
|
||||||
|
sessionId: string;
|
||||||
|
userAgent?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type QqbotNapcatWebuiGatewaySessionResult = {
|
export type QqbotNapcatWebuiGatewaySessionResult = {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
iframeUrl: string;
|
iframeUrl: string;
|
||||||
@ -41,32 +53,38 @@ export class QqbotNapcatWebuiGatewayClient {
|
|||||||
* @param input - Server-only target metadata, including WebUI token and upstream endpoint.
|
* @param input - Server-only target metadata, including WebUI token and upstream endpoint.
|
||||||
* @returns Browser-safe Gateway session metadata.
|
* @returns Browser-safe Gateway session metadata.
|
||||||
*/
|
*/
|
||||||
createSession(input: QqbotNapcatWebuiGatewayCreateSessionRequest) {
|
async createSession(input: QqbotNapcatWebuiGatewayCreateSessionRequest) {
|
||||||
return this.post<QqbotNapcatWebuiGatewaySessionResult>(
|
return this.validateSessionResult(
|
||||||
'/internal/sessions',
|
await this.post<QqbotNapcatWebuiGatewaySessionResult>(
|
||||||
input,
|
'/internal/sessions',
|
||||||
|
input,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes one Gateway session heartbeat without exposing internal target data.
|
* 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.
|
* @returns Gateway lifecycle response body.
|
||||||
*/
|
*/
|
||||||
heartbeat(sessionId: string) {
|
heartbeat(input: QqbotNapcatWebuiGatewayLifecycleRequest) {
|
||||||
|
const { sessionId, ...data } = input;
|
||||||
return this.post<QqbotNapcatWebuiGatewayLifecycleResult>(
|
return this.post<QqbotNapcatWebuiGatewayLifecycleResult>(
|
||||||
`/internal/sessions/${encodeURIComponent(sessionId)}/heartbeat`,
|
`/internal/sessions/${encodeURIComponent(sessionId)}/heartbeat`,
|
||||||
|
data,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revokes one Gateway session without exposing internal target 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.
|
* @returns Gateway lifecycle response body.
|
||||||
*/
|
*/
|
||||||
revoke(sessionId: string) {
|
revoke(input: QqbotNapcatWebuiGatewayLifecycleRequest) {
|
||||||
|
const { sessionId, ...data } = input;
|
||||||
return this.post<QqbotNapcatWebuiGatewayLifecycleResult>(
|
return this.post<QqbotNapcatWebuiGatewayLifecycleResult>(
|
||||||
`/internal/sessions/${encodeURIComponent(sessionId)}/revoke`,
|
`/internal/sessions/${encodeURIComponent(sessionId)}/revoke`,
|
||||||
|
data,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,12 +139,29 @@ export class QqbotNapcatWebuiGatewayClient {
|
|||||||
* @returns Header map when a secret is configured, otherwise undefined.
|
* @returns Header map when a secret is configured, otherwise undefined.
|
||||||
*/
|
*/
|
||||||
private getHeaders() {
|
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<string>('NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET') ||
|
this.configService.get<string>('NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET') ||
|
||||||
''
|
'',
|
||||||
).trim();
|
).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;
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { resolve } from 'path';
|
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 { getMetadataArgsStorage } from 'typeorm';
|
||||||
|
import * as request from 'supertest';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
QqbotNapcatWebuiGatewayService,
|
QqbotNapcatWebuiGatewayService,
|
||||||
NapcatWebuiGatewayAuditService,
|
NapcatWebuiGatewayAuditService,
|
||||||
} from '../../../../src/modules/qqbot/napcat/webui-gateway/application/qqbot-napcat-webui-gateway.service';
|
} 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 { 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 { 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 {
|
import {
|
||||||
NAPCAT_RUNTIME_DOMAIN_CONTRACT,
|
NAPCAT_RUNTIME_DOMAIN_CONTRACT,
|
||||||
NAPCAT_RUNTIME_ENTITIES,
|
NAPCAT_RUNTIME_ENTITIES,
|
||||||
@ -20,8 +24,25 @@ const repoRoot = resolve(__dirname, '../../../..');
|
|||||||
const requestMock = axios.request as jest.Mock;
|
const requestMock = axios.request as jest.Mock;
|
||||||
const EXPIRES_AT_FIXTURE = 1782268000000;
|
const EXPIRES_AT_FIXTURE = 1782268000000;
|
||||||
const INTERNAL_SECRET_FIXTURE = ['internal', 'secret'].join('-');
|
const INTERNAL_SECRET_FIXTURE = ['internal', 'secret'].join('-');
|
||||||
|
const WEBUI_PERMISSION = 'QqBot:Account:WebUI';
|
||||||
const WEBUI_TOKEN_FIXTURE = ['webui', 'token', 'fixture'].join('-');
|
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<T extends object> = {
|
type MockRepository<T extends object> = {
|
||||||
create: jest.Mock<T, [Partial<T>]>;
|
create: jest.Mock<T, [Partial<T>]>;
|
||||||
rows: T[];
|
rows: T[];
|
||||||
@ -55,6 +76,36 @@ const createRepository = <T extends object>() => {
|
|||||||
return repository;
|
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.
|
* Extracts a readable message from Vben-style HTTP exceptions.
|
||||||
* @param error - Error thrown by the service under test.
|
* @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);
|
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', () => {
|
describe('QqbotNapcatWebuiGatewayService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
requestMock.mockReset();
|
requestMock.mockReset();
|
||||||
@ -89,7 +294,8 @@ describe('QqbotNapcatWebuiGatewayService', () => {
|
|||||||
};
|
};
|
||||||
const gatewayResult = {
|
const gatewayResult = {
|
||||||
expiresAt: EXPIRES_AT_FIXTURE,
|
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',
|
sessionId: 'sess_1',
|
||||||
};
|
};
|
||||||
const accountService = {
|
const accountService = {
|
||||||
@ -134,7 +340,8 @@ describe('QqbotNapcatWebuiGatewayService', () => {
|
|||||||
webuiStatus: 'online',
|
webuiStatus: 'online',
|
||||||
},
|
},
|
||||||
expiresAt: EXPIRES_AT_FIXTURE,
|
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',
|
sessionId: 'sess_1',
|
||||||
});
|
});
|
||||||
expect(client.createSession).toHaveBeenCalledWith({
|
expect(client.createSession).toHaveBeenCalledWith({
|
||||||
@ -169,11 +376,12 @@ describe('QqbotNapcatWebuiGatewayService', () => {
|
|||||||
expect(savedAudit.detailJson).toEqual({
|
expect(savedAudit.detailJson).toEqual({
|
||||||
accountName: '主机器人',
|
accountName: '主机器人',
|
||||||
containerName: 'kt-qqbot-napcat-1914728559',
|
containerName: 'kt-qqbot-napcat-1914728559',
|
||||||
iframeUrl: '/napcat-webui-gateway/session/sess_1/',
|
|
||||||
webuiStatus: 'online',
|
webuiStatus: 'online',
|
||||||
});
|
});
|
||||||
expect(serializedAudit).not.toContain(WEBUI_TOKEN_FIXTURE);
|
expect(serializedAudit).not.toContain(WEBUI_TOKEN_FIXTURE);
|
||||||
expect(serializedAudit).not.toContain('Credential');
|
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('172.18.0.23');
|
||||||
expect(serializedAudit).not.toContain('6099');
|
expect(serializedAudit).not.toContain('6099');
|
||||||
expect(serializedAudit).not.toContain(INTERNAL_SECRET_FIXTURE);
|
expect(serializedAudit).not.toContain(INTERNAL_SECRET_FIXTURE);
|
||||||
@ -224,7 +432,51 @@ describe('QqbotNapcatWebuiGatewayService', () => {
|
|||||||
expect(auditRepository.save).not.toHaveBeenCalled();
|
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<NapcatWebuiGatewayAudit>() 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 = {
|
const client = {
|
||||||
heartbeat: jest.fn(async () => ({ ok: true, status: 'active' })),
|
heartbeat: jest.fn(async () => ({ ok: true, status: 'active' })),
|
||||||
revoke: jest.fn(async () => ({ ok: true, status: 'revoked' })),
|
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,
|
ok: true,
|
||||||
status: 'active',
|
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,
|
ok: true,
|
||||||
status: 'revoked',
|
status: 'revoked',
|
||||||
});
|
});
|
||||||
expect(client.heartbeat).toHaveBeenCalledWith('sess_1');
|
expect(client.heartbeat).toHaveBeenCalledWith({
|
||||||
expect(client.revoke).toHaveBeenCalledWith('sess_1');
|
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<NapcatWebuiGatewayAudit>();
|
||||||
|
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: {
|
||||||
data: {
|
data: {
|
||||||
expiresAt: EXPIRES_AT_FIXTURE,
|
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',
|
sessionId: 'sess_1',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -310,11 +643,182 @@ describe('QqbotNapcatWebuiGatewayClient', () => {
|
|||||||
});
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
expiresAt: EXPIRES_AT_FIXTURE,
|
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',
|
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 () => {
|
it('sanitizes Gateway client errors before returning them to Admin', async () => {
|
||||||
requestMock.mockRejectedValue(
|
requestMock.mockRejectedValue(
|
||||||
new Error(
|
new Error(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user