diff --git a/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts b/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts index 8a465e5..e0f69eb 100644 --- a/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts +++ b/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts @@ -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'); } } diff --git a/src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts b/src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts index 2f218a2..1cca361 100644 --- a/src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts +++ b/src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store.ts @@ -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. diff --git a/src/apps/napcat-webui-gateway/main.ts b/src/apps/napcat-webui-gateway/main.ts index 3051ee1..fb9a07f 100644 --- a/src/apps/napcat-webui-gateway/main.ts +++ b/src/apps/napcat-webui-gateway/main.ts @@ -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()); } diff --git a/test/apps/napcat-webui-gateway/session-store.spec.ts b/test/apps/napcat-webui-gateway/session-store.spec.ts index 0774bd2..e266528 100644 --- a/test/apps/napcat-webui-gateway/session-store.spec.ts +++ b/test/apps/napcat-webui-gateway/session-store.spec.ts @@ -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); + }); });