fix: 收紧NapCat WebUI会话脱敏校验

This commit is contained in:
sunlei 2026-06-24 12:43:54 +08:00
parent 99ecb4a35e
commit 2b1c8e9b3e
3 changed files with 147 additions and 10 deletions

View File

@ -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),
)
);
}
/**

View File

@ -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);

View File

@ -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: {