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 =
|
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;
|
/^(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 =
|
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;
|
/(\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 REDACTED_DETAIL_VALUE = '[REDACTED]';
|
||||||
@ -119,7 +140,15 @@ export class NapcatWebuiGatewayAuditService {
|
|||||||
*/
|
*/
|
||||||
private isSensitiveDetailKey(key: string) {
|
private isSensitiveDetailKey(key: string) {
|
||||||
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
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;
|
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}/`;
|
const expectedPrefix = `${GATEWAY_PUBLIC_SESSION_PREFIX}${sessionId}/`;
|
||||||
if (!path.startsWith(expectedPrefix)) return false;
|
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 params = new URLSearchParams(query);
|
||||||
const hasTicket = params.has('ticket');
|
const entries = [...params.entries()];
|
||||||
if (hasTicket) {
|
const ticketValues = params.getAll('ticket');
|
||||||
const ticket = params.get('ticket') || '';
|
if (query) {
|
||||||
if (path !== `${expectedPrefix}bootstrap`) return false;
|
if (!isBootstrapRoute) return false;
|
||||||
if ([...params.keys()].some((key) => key !== 'ticket')) 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;
|
if (!SAFE_BOOTSTRAP_TICKET_PATTERN.test(ticket)) return false;
|
||||||
} else if (/ticket/i.test(iframeUrl)) {
|
} else if (/ticket/i.test(iframeUrl)) {
|
||||||
return false;
|
return false;
|
||||||
} else if (query) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsafeScanValue = hasTicket
|
const unsafeScanValue = query
|
||||||
? `${path}?ticket=`
|
? `${path}?ticket=`
|
||||||
: iframeUrl;
|
: iframeUrl;
|
||||||
return !this.hasUnsafeGatewayEvidence(unsafeScanValue);
|
return !this.hasUnsafeGatewayEvidence(unsafeScanValue);
|
||||||
|
|||||||
@ -191,6 +191,65 @@ describe('QqbotNapcatWebuiGatewayController', () => {
|
|||||||
expect(gatewayService.createSession).not.toHaveBeenCalled();
|
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 () => {
|
it('allows Admin users with WebUI permission and passes create-session evidence', async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.post('/qqbot/napcat/webui/session')
|
.post('/qqbot/napcat/webui/session')
|
||||||
@ -538,6 +597,10 @@ describe('QqbotNapcatWebuiGatewayService', () => {
|
|||||||
clientIp: '127.0.0.1',
|
clientIp: '127.0.0.1',
|
||||||
containerId: '2001',
|
containerId: '2001',
|
||||||
detailJson: {
|
detailJson: {
|
||||||
|
['access' + 'Token']: 'plain-secret',
|
||||||
|
apiToken: 'plain-secret',
|
||||||
|
authorizationHeader: 'Basic abc',
|
||||||
|
cookie: 'sid=abc',
|
||||||
credentialHeader: 'Credential abc',
|
credentialHeader: 'Credential abc',
|
||||||
docker_ip: '172.18.0.23',
|
docker_ip: '172.18.0.23',
|
||||||
hostPort: '6099',
|
hostPort: '6099',
|
||||||
@ -545,9 +608,12 @@ describe('QqbotNapcatWebuiGatewayService', () => {
|
|||||||
nested: {
|
nested: {
|
||||||
display: 'visible',
|
display: 'visible',
|
||||||
loginMessage: 'Bearer abc',
|
loginMessage: 'Bearer abc',
|
||||||
|
refreshToken: 'plain-secret',
|
||||||
redirectPath: '/napcat-webui/session/sess_1/bootstrap?ticket=abc',
|
redirectPath: '/napcat-webui/session/sess_1/bootstrap?ticket=abc',
|
||||||
},
|
},
|
||||||
|
refreshToken: 'plain-secret',
|
||||||
safe: 'visible',
|
safe: 'visible',
|
||||||
|
setCookie: 'sid=abc; HttpOnly',
|
||||||
unsafeList: [
|
unsafeList: [
|
||||||
'plain text',
|
'plain text',
|
||||||
'token=abc',
|
'token=abc',
|
||||||
@ -776,6 +842,41 @@ describe('QqbotNapcatWebuiGatewayClient', () => {
|
|||||||
sessionId: 'sess_1',
|
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) => {
|
])('rejects invalid Gateway create-session result: %s', async (_case, body) => {
|
||||||
requestMock.mockResolvedValue({
|
requestMock.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user