fix: 加固NapCat WebUI Gateway会话一致性

This commit is contained in:
sunlei 2026-06-24 13:31:34 +08:00
parent 87b0368b9a
commit d174d9c9c2
4 changed files with 250 additions and 22 deletions

View File

@ -1,5 +1,11 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
GoneException,
Inject,
Injectable,
} from '@nestjs/common';
import { NapcatWebuiGatewayConfigService } from '../config/napcat-webui-gateway-config.service';
import {
NAPCAT_WEBUI_GATEWAY_SESSION_STORE,
@ -30,9 +36,10 @@ export class NapcatWebuiGatewaySessionService {
* @returns Created session persisted in the store.
*/
async create(input: NapcatWebuiGatewayCreateSessionInput) {
const normalizedInput = this.validateCreateInput(input);
const existing = await this.store.findActiveByUserAndAccount(
input.adminUserId,
input.accountId,
normalizedInput.adminUserId,
normalizedInput.accountId,
);
if (existing) {
await this.store.update(existing.sessionId, {
@ -43,19 +50,19 @@ export class NapcatWebuiGatewaySessionService {
const now = this.config.now();
const session: NapcatWebuiGatewaySession = {
accountId: input.accountId,
adminUserId: input.adminUserId,
clientIp: this.toOptionalText(input.clientIp),
containerId: input.containerId,
containerName: input.containerName,
accountId: normalizedInput.accountId,
adminUserId: normalizedInput.adminUserId,
clientIp: this.toOptionalText(normalizedInput.clientIp),
containerId: normalizedInput.containerId,
containerName: normalizedInput.containerName,
createdAt: now,
expiresAt: now + this.config.ttlMs(),
selfId: input.selfId,
selfId: normalizedInput.selfId,
sessionId: randomUUID(),
status: 'created',
upstreamBaseUrl: input.upstreamBaseUrl,
userAgent: this.toOptionalText(input.userAgent),
webuiToken: input.webuiToken,
upstreamBaseUrl: normalizedInput.upstreamBaseUrl,
userAgent: this.toOptionalText(normalizedInput.userAgent),
webuiToken: normalizedInput.webuiToken,
};
return this.store.create(session);
@ -110,8 +117,7 @@ export class NapcatWebuiGatewaySessionService {
* @returns Browser-safe lifecycle result.
*/
async revoke(input: NapcatWebuiGatewayLifecycleInput) {
const session = await this.store.find(input.sessionId);
if (!session) throw new Error('Gateway session is not active');
const session = await this.requireUsableSession(input.sessionId);
this.assertOwner(session, input.adminUserId);
const updated = await this.store.update(input.sessionId, {
@ -145,11 +151,11 @@ export class NapcatWebuiGatewaySessionService {
private async requireUsableSession(sessionId: string) {
const session = await this.store.find(sessionId);
if (!session || TERMINAL_SESSION_STATUSES.includes(session.status)) {
throw new Error('Gateway session is not active');
throw new GoneException('Gateway session is not active');
}
if (session.expiresAt <= this.config.now()) {
await this.store.update(sessionId, { status: 'expired' });
throw new Error('Gateway session is not active');
throw new GoneException('Gateway session is not active');
}
return session;
@ -165,7 +171,62 @@ export class NapcatWebuiGatewaySessionService {
adminUserId: string,
) {
if (session.adminUserId !== adminUserId) {
throw new Error('Gateway session owner mismatch');
throw new ForbiddenException('Gateway session owner mismatch');
}
}
/**
* Validates and normalizes the internal create-session payload before persistence.
* @param input - Internal API payload supplied by the main API process.
* @returns Normalized create payload with required fields trimmed.
*/
private validateCreateInput(input: NapcatWebuiGatewayCreateSessionInput) {
const normalized = {
...input,
accountId: this.requireText(input.accountId, 'accountId'),
adminUserId: this.requireText(input.adminUserId, 'adminUserId'),
containerId: this.requireText(input.containerId, 'containerId'),
containerName: this.requireText(input.containerName, 'containerName'),
selfId: this.requireText(input.selfId, 'selfId'),
upstreamBaseUrl: this.requireUpstreamBaseUrl(input.upstreamBaseUrl),
webuiToken: this.requireText(input.webuiToken, 'webuiToken'),
};
return normalized;
}
/**
* Requires a non-empty text field from the internal create-session payload.
* @param value - Candidate field value.
* @param fieldName - Payload field name used in the error message.
* @returns Trimmed field text.
*/
private requireText(value: string, fieldName: string) {
const text = this.toOptionalText(value);
if (!text) {
throw new BadRequestException(
`Gateway session field ${fieldName} is required`,
);
}
return text;
}
/**
* Validates the upstream WebUI base URL without restricting Docker host shape.
* @param value - Candidate upstream URL.
* @returns Trimmed http or https URL.
*/
private requireUpstreamBaseUrl(value: string) {
const text = this.requireText(value, 'upstreamBaseUrl');
try {
const url = new URL(text);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Unsupported protocol');
}
return text;
} catch {
throw new BadRequestException('Gateway session upstream URL is invalid');
}
}

View File

@ -10,6 +10,12 @@ import type {
const SESSION_KEY_PREFIX = 'napcat:webui:session:';
const USER_ACCOUNT_KEY_PREFIX = 'napcat:webui:user-account:';
const TERMINAL_SESSION_STATUSES = ['expired', 'failed', 'revoked'];
const COMPARE_DELETE_SCRIPT = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
`;
@Injectable()
export class NapcatWebuiGatewayRedisStore
@ -84,12 +90,13 @@ export class NapcatWebuiGatewayRedisStore
...patch,
sessionId,
};
if (this.isTerminal(current) && !this.isTerminal(next)) {
throw new Error('Gateway terminal session cannot become active');
}
await this.writeSession(next);
if (this.isTerminal(next)) {
await this.redis.del(
this.userAccountKey(next.adminUserId, next.accountId),
);
await this.deleteUserAccountIndexIfCurrent(next);
} else {
await this.writeUserAccountIndex(next);
}
@ -122,6 +129,21 @@ export class NapcatWebuiGatewayRedisStore
);
}
/**
* Deletes the user/account index only when it still points at the terminal session.
* @param session - Terminal Gateway session whose index may need cleanup.
*/
private async deleteUserAccountIndexIfCurrent(
session: NapcatWebuiGatewaySession,
) {
await this.redis.eval(
COMPARE_DELETE_SCRIPT,
1,
this.userAccountKey(session.adminUserId, session.accountId),
session.sessionId,
);
}
/**
* Builds the Redis session key.
* @param sessionId - Gateway session id.

View File

@ -12,8 +12,8 @@ async function bootstrap() {
bufferLogs: true,
});
app.useLogger(app.get(Logger));
app.use(json({ limit: '50mb' }));
app.use(urlencoded({ extended: true, limit: '50mb' }));
app.use(json({ limit: '64kb' }));
app.use(urlencoded({ extended: true, limit: '64kb' }));
await app.listen(app.get(NapcatWebuiGatewayConfigService).port());
}

View File

@ -143,6 +143,30 @@ class FakeRedis {
});
return deleted;
}
/**
* Simulates the compare-and-delete Lua script used by Redis index cleanup.
* @param script - Lua script text.
* @param keyCount - Number of Redis keys in the script call.
* @param key - Redis key to conditionally delete.
* @param expectedValue - Value that must match before deletion.
* @returns 1 when the index was deleted, otherwise 0.
*/
async eval(
script: string,
keyCount: number,
key: string,
expectedValue: string,
) {
this.calls.push(`eval:${keyCount}:${key}:${expectedValue}`);
if (!script.includes('redis.call') || keyCount !== 1) {
throw new Error('Unexpected Redis script');
}
if (this.values.get(key) !== expectedValue) return 0;
this.values.delete(key);
this.ttl.delete(key);
return 1;
}
}
/**
@ -246,6 +270,49 @@ describe('NapcatWebuiGatewaySessionService', () => {
).rejects.toThrow('Gateway session is not active');
});
it('does not allow terminal sessions to become active again', async () => {
const store = new MemorySessionStore();
const service = new NapcatWebuiGatewaySessionService(
store,
createConfig({ value: 1000 }) as never,
);
const session = await service.create(createSessionInput());
await service.revoke({
adminUserId: 'admin-1',
sessionId: session.sessionId,
});
await expect(service.markActive(session.sessionId)).rejects.toThrow(
'Gateway session is not active',
);
await expect(
service.heartbeat({
adminUserId: 'admin-1',
sessionId: session.sessionId,
}),
).rejects.toThrow('Gateway session is not active');
expect(await store.find(session.sessionId)).toMatchObject({
status: 'revoked',
});
});
it('rejects blank required create fields and invalid upstream URLs', async () => {
const store = new MemorySessionStore();
const service = new NapcatWebuiGatewaySessionService(
store,
createConfig({ value: 1000 }) as never,
);
await expect(
service.create(createSessionInput({ webuiToken: ' ' })),
).rejects.toThrow('Gateway session field webuiToken is required');
await expect(
service.create(createSessionInput({ upstreamBaseUrl: 'ftp://127.0.0.1' })),
).rejects.toThrow('Gateway session upstream URL is invalid');
expect(store.sessions.size).toBe(0);
});
it('rejects heartbeat and revoke owner mismatches', async () => {
const store = new MemorySessionStore();
const service = new NapcatWebuiGatewaySessionService(
@ -315,6 +382,40 @@ describe('NapcatWebuiGatewayRedisStore', () => {
store.findActiveByUserAndAccount('admin-1', 'account-1'),
).resolves.toBeUndefined();
});
it('keeps the newer user-account index when an older revoked session is revoked again', async () => {
const redis = new FakeRedis();
const config = createConfig({ value: 1000 });
const store = new NapcatWebuiGatewayRedisStore(
redis as never,
config as never,
);
const service = new NapcatWebuiGatewaySessionService(
store,
config as never,
);
const first = await service.create(createSessionInput());
const second = await service.create(createSessionInput());
await expect(
service.revoke({
adminUserId: 'admin-1',
sessionId: first.sessionId,
}),
).rejects.toThrow('Gateway session is not active');
await expect(
store.findActiveByUserAndAccount('admin-1', 'account-1'),
).resolves.toMatchObject({
sessionId: second.sessionId,
status: 'created',
});
expect(
redis.calls.some((call) =>
call.startsWith('eval:1:napcat:webui:user-account:admin-1:account-1:'),
),
).toBe(true);
});
});
describe('NapcatWebuiGatewayTicketService', () => {
@ -435,4 +536,48 @@ describe('InternalSessionController', () => {
.get('/internal/health')
.expect(HttpStatus.OK);
});
it('returns lifecycle HTTP errors for missing, revoked, and owner mismatch sessions', async () => {
await request(app.getHttpServer())
.post('/internal/sessions/missing-session/heartbeat')
.set('x-kt-gateway-secret', INTERNAL_SECRET)
.send({ adminUserId: 'admin-1' })
.expect(HttpStatus.GONE);
const createResponse = await request(app.getHttpServer())
.post('/internal/sessions')
.set('x-kt-gateway-secret', INTERNAL_SECRET)
.send(createSessionInput())
.expect(HttpStatus.CREATED);
const sessionId = createResponse.body.sessionId;
await request(app.getHttpServer())
.post(`/internal/sessions/${sessionId}/heartbeat`)
.set('x-kt-gateway-secret', INTERNAL_SECRET)
.send({ adminUserId: 'admin-2' })
.expect(HttpStatus.FORBIDDEN);
await request(app.getHttpServer())
.post(`/internal/sessions/${sessionId}/revoke`)
.set('x-kt-gateway-secret', INTERNAL_SECRET)
.send({ adminUserId: 'admin-1' })
.expect(HttpStatus.CREATED);
await request(app.getHttpServer())
.post(`/internal/sessions/${sessionId}/heartbeat`)
.set('x-kt-gateway-secret', INTERNAL_SECRET)
.send({ adminUserId: 'admin-1' })
.expect(HttpStatus.GONE);
});
it('rejects invalid create-session payloads with bad request status', async () => {
await request(app.getHttpServer())
.post('/internal/sessions')
.set('x-kt-gateway-secret', INTERNAL_SECRET)
.send(createSessionInput({ adminUserId: ' ' }))
.expect(HttpStatus.BAD_REQUEST);
await request(app.getHttpServer())
.post('/internal/sessions')
.set('x-kt-gateway-secret', INTERNAL_SECRET)
.send(createSessionInput({ upstreamBaseUrl: 'not-a-url' }))
.expect(HttpStatus.BAD_REQUEST);
});
});