fix: 禁止NapCat WebUI心跳激活未引导会话

This commit is contained in:
sunlei 2026-06-24 14:10:40 +08:00
parent c9cc0d5e3a
commit 0375fd4661
4 changed files with 48 additions and 4 deletions

View File

@ -773,7 +773,7 @@ if (older) {
} }
``` ```
The `heartbeat()` implementation must reject `revoked`, `expired`, `failed`, missing sessions, and sessions whose user/account index no longer points at the requested `sessionId`. `requireBootstrapSession()` accepts only non-terminal, non-expired, currently indexed sessions for one-time ticket bootstrap. `requireProxySession()` accepts only `active`, non-expired, currently indexed sessions. The `heartbeat()` implementation must extend active sessions only. It must reject `created`, `revoked`, `expired`, `failed`, missing sessions, and sessions whose user/account index no longer points at the requested `sessionId`. `requireBootstrapSession()` accepts only non-terminal, non-expired, currently indexed sessions for one-time ticket bootstrap. `markActive()` is the only method that may promote a `created` session to `active`. `requireProxySession()` accepts only `active`, non-expired, currently indexed sessions.
- [ ] **Step 7: Implement Redis store and ticket service** - [ ] **Step 7: Implement Redis store and ticket service**

View File

@ -302,7 +302,7 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/
- [ ] **Step 6实现 session service** - [ ] **Step 6实现 session service**
创建 `NapcatWebuiGatewaySessionService`,包含 `create`、`markActive`、`heartbeat`、`revoke`、`requireBootstrapSession`、`requireProxySession`。每个方法必须有 JSDoc。`create()` 必须撤销同用户同账号旧 session。`requireBootstrapSession()` 只接受非终态、未过期且 user/account index 仍指向当前 `sessionId` 的 session`requireProxySession()` 只接受 `active`、未过期且 index 仍指向当前 `sessionId` 的 session。 创建 `NapcatWebuiGatewaySessionService`,包含 `create`、`markActive`、`heartbeat`、`revoke`、`requireBootstrapSession`、`requireProxySession`。每个方法必须有 JSDoc。`create()` 必须撤销同用户同账号旧 session。`heartbeat()` 只延长 active session必须拒绝 `created`、终态、过期、缺失或 user/account index 不再指向当前 `sessionId` 的 session。`requireBootstrapSession()` 只接受非终态、未过期且 user/account index 仍指向当前 `sessionId` 的 session;只有 `markActive()` 可以把 `created` session 提升为 `active``requireProxySession()` 只接受 `active`、未过期且 index 仍指向当前 `sessionId` 的 session。
- [ ] **Step 7实现 Redis store 和 ticket service** - [ ] **Step 7实现 Redis store 和 ticket service**

View File

@ -86,13 +86,13 @@ export class NapcatWebuiGatewaySessionService {
} }
/** /**
* Extends an Admin-owned active Gateway session. * Extends an Admin-owned Gateway session that bootstrap already activated.
* @param input - Session id plus Admin ownership and request evidence. * @param input - Session id plus Admin ownership and request evidence.
* @returns Browser-safe lifecycle result. * @returns Browser-safe lifecycle result.
*/ */
async heartbeat(input: NapcatWebuiGatewayLifecycleInput) { async heartbeat(input: NapcatWebuiGatewayLifecycleInput) {
const adminUserId = this.requireLifecycleAdminUserId(input.adminUserId); const adminUserId = this.requireLifecycleAdminUserId(input.adminUserId);
const session = await this.requireUsableSession(input.sessionId); const session = await this.requireProxySession(input.sessionId);
this.assertOwner(session, adminUserId); this.assertOwner(session, adminUserId);
const now = this.config.now(); const now = this.config.now();
const expiresAt = now + this.config.ttlMs(); const expiresAt = now + this.config.ttlMs();

View File

@ -381,6 +381,8 @@ describe('NapcatWebuiGatewaySessionService', () => {
); );
const session = await service.create(createSessionInput()); const session = await service.create(createSessionInput());
await service.markActive(session.sessionId);
await expect( await expect(
service.heartbeat({ service.heartbeat({
adminUserId: 'admin-2', adminUserId: 'admin-2',
@ -395,6 +397,25 @@ describe('NapcatWebuiGatewaySessionService', () => {
).rejects.toThrow('Gateway session owner mismatch'); ).rejects.toThrow('Gateway session owner mismatch');
}); });
it('rejects heartbeat for created sessions without activating them', async () => {
const store = new MemorySessionStore();
const service = new NapcatWebuiGatewaySessionService(
store,
createConfig({ value: 1000 }) as never,
);
const session = await service.create(createSessionInput());
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: 'created',
});
});
it('keeps created sessions out of proxy access until bootstrap marks them active', async () => { it('keeps created sessions out of proxy access until bootstrap marks them active', async () => {
const store = new MemorySessionStore(); const store = new MemorySessionStore();
const currentTime = { value: 1000 }; const currentTime = { value: 1000 };
@ -813,6 +834,11 @@ describe('InternalSessionController', () => {
.send(createSessionInput()) .send(createSessionInput())
.expect(HttpStatus.CREATED); .expect(HttpStatus.CREATED);
const sessionId = createResponse.body.sessionId; const sessionId = createResponse.body.sessionId;
await store.update(sessionId, {
activeAt: 1000,
lastSeenAt: 1000,
status: 'active',
});
await request(app.getHttpServer()) await request(app.getHttpServer())
.post(`/internal/sessions/${sessionId}/heartbeat`) .post(`/internal/sessions/${sessionId}/heartbeat`)
@ -851,6 +877,24 @@ describe('InternalSessionController', () => {
.expect(HttpStatus.BAD_REQUEST); .expect(HttpStatus.BAD_REQUEST);
}); });
it('rejects heartbeat for created sessions with gone status', async () => {
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-1' })
.expect(HttpStatus.GONE);
expect(await store.find(sessionId)).toMatchObject({
status: 'created',
});
});
it('rejects invalid create-session payloads with bad request status', async () => { it('rejects invalid create-session payloads with bad request status', async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/internal/sessions') .post('/internal/sessions')