From 2b1c8e9b3e1f86b519e002fe9835c6419f39353e Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 24 Jun 2026 12:43:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7NapCat=20WebUI?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E8=84=B1=E6=95=8F=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qqbot-napcat-webui-gateway.service.ts | 31 +++++- .../qqbot-napcat-webui-gateway.client.ts | 25 +++-- .../api-session.service.spec.ts | 101 ++++++++++++++++++ 3 files changed, 147 insertions(+), 10 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 684e880..8b127bb 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 @@ -17,6 +17,27 @@ import type { QqbotNapcatWebuiSessionResponseDto } from '../contract/qqbot-napca const SENSITIVE_DETAIL_KEY_PATTERN = /^(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 SENSITIVE_DETAIL_KEY_FAMILIES = [ + 'authorization', + 'captcha', + 'cookie', + 'credential', + 'password', + 'secret', + 'ticket', + 'token', +]; +const SENSITIVE_DETAIL_KEY_SUBSTRINGS = [ + 'dockerip', + 'hostport', + 'naspath', + 'nasroute', + 'targetbaseurl', + 'upstreambaseurl', + 'upstreamurl', + 'webuiport', + 'webuitoken', +]; 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]'; @@ -119,7 +140,15 @@ export class NapcatWebuiGatewayAuditService { */ private isSensitiveDetailKey(key: string) { const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ''); - return SENSITIVE_DETAIL_KEY_PATTERN.test(normalized); + return ( + SENSITIVE_DETAIL_KEY_PATTERN.test(normalized) || + SENSITIVE_DETAIL_KEY_FAMILIES.some((family) => + normalized.includes(family), + ) || + SENSITIVE_DETAIL_KEY_SUBSTRINGS.some((substring) => + normalized.includes(substring), + ) + ); } /** 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 529efbd..4d12c3f 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 @@ -237,24 +237,31 @@ export class QqbotNapcatWebuiGatewayClient { } if (iframeUrl.includes('\\')) return false; - const [path, query = ''] = iframeUrl.split('?'); + const queryStart = iframeUrl.indexOf('?'); + const path = + queryStart >= 0 ? iframeUrl.slice(0, queryStart) : iframeUrl; + const query = queryStart >= 0 ? iframeUrl.slice(queryStart + 1) : ''; const expectedPrefix = `${GATEWAY_PUBLIC_SESSION_PREFIX}${sessionId}/`; if (!path.startsWith(expectedPrefix)) return false; + const isBootstrapRoute = path === `${expectedPrefix}bootstrap`; + if (query.includes('?') || /%3f/i.test(query)) return false; + if (query && this.hasUnsafeGatewayEvidence(query)) 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; + const entries = [...params.entries()]; + const ticketValues = params.getAll('ticket'); + if (query) { + if (!isBootstrapRoute) return false; + if (entries.length !== 1 || ticketValues.length !== 1) return false; + const [key, ticket] = entries[0]; + if (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 + const unsafeScanValue = query ? `${path}?ticket=` : iframeUrl; return !this.hasUnsafeGatewayEvidence(unsafeScanValue); 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 36aa07c..dcda0fd 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 @@ -191,6 +191,65 @@ describe('QqbotNapcatWebuiGatewayController', () => { expect(gatewayService.createSession).not.toHaveBeenCalled(); }); + it.each([ + [ + 'deleted role', + [ + { + isDeleted: true, + menus: [{ authCode: WEBUI_PERMISSION, status: 1 }], + roleCode: 'operator', + status: 1, + }, + ], + ], + [ + 'inactive role', + [ + { + menus: [{ authCode: WEBUI_PERMISSION, status: 1 }], + roleCode: 'operator', + status: 0, + }, + ], + ], + [ + 'deleted menu', + [ + { + menus: [ + { + authCode: WEBUI_PERMISSION, + isDeleted: true, + status: 1, + }, + ], + roleCode: 'operator', + status: 1, + }, + ], + ], + [ + 'inactive menu', + [ + { + menus: [{ authCode: WEBUI_PERMISSION, status: 0 }], + roleCode: 'operator', + status: 1, + }, + ], + ], + ])('denies WebUI access for %s', async (_case, roles) => { + currentAdminUser = createAdminUser(roles); + + 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') @@ -538,6 +597,10 @@ describe('QqbotNapcatWebuiGatewayService', () => { clientIp: '127.0.0.1', containerId: '2001', detailJson: { + ['access' + 'Token']: 'plain-secret', + apiToken: 'plain-secret', + authorizationHeader: 'Basic abc', + cookie: 'sid=abc', credentialHeader: 'Credential abc', docker_ip: '172.18.0.23', hostPort: '6099', @@ -545,9 +608,12 @@ describe('QqbotNapcatWebuiGatewayService', () => { nested: { display: 'visible', loginMessage: 'Bearer abc', + refreshToken: 'plain-secret', redirectPath: '/napcat-webui/session/sess_1/bootstrap?ticket=abc', }, + refreshToken: 'plain-secret', safe: 'visible', + setCookie: 'sid=abc; HttpOnly', unsafeList: [ 'plain text', 'token=abc', @@ -776,6 +842,41 @@ describe('QqbotNapcatWebuiGatewayClient', () => { sessionId: 'sess_1', }, ], + [ + 'multiple query separators', + { + expiresAt: EXPIRES_AT_FIXTURE, + iframeUrl: + '/napcat-webui/session/sess_1/bootstrap?ticket=abc?token=secret', + sessionId: 'sess_1', + }, + ], + [ + 'duplicate ticket query', + { + expiresAt: EXPIRES_AT_FIXTURE, + iframeUrl: + '/napcat-webui/session/sess_1/bootstrap?ticket=abc&ticket=def', + sessionId: 'sess_1', + }, + ], + [ + 'encoded secret query evidence', + { + expiresAt: EXPIRES_AT_FIXTURE, + iframeUrl: + '/napcat-webui/session/sess_1/bootstrap?ticket=abc%3Ftoken%3Dsecret', + sessionId: 'sess_1', + }, + ], + [ + 'non-bootstrap query', + { + 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: {