fix: 加固NapCat WebUI Gateway会话一致性
This commit is contained in:
parent
87b0368b9a
commit
d174d9c9c2
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user