fix: 收紧NapCat WebUI会话脱敏校验
This commit is contained in:
parent
99ecb4a35e
commit
2b1c8e9b3e
@ -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),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user