fix: 加固NapCat WebUI Gateway会话一致性
This commit is contained in:
parent
87b0368b9a
commit
d174d9c9c2
@ -1,5 +1,11 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
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 { NapcatWebuiGatewayConfigService } from '../config/napcat-webui-gateway-config.service';
|
||||||
import {
|
import {
|
||||||
NAPCAT_WEBUI_GATEWAY_SESSION_STORE,
|
NAPCAT_WEBUI_GATEWAY_SESSION_STORE,
|
||||||
@ -30,9 +36,10 @@ export class NapcatWebuiGatewaySessionService {
|
|||||||
* @returns Created session persisted in the store.
|
* @returns Created session persisted in the store.
|
||||||
*/
|
*/
|
||||||
async create(input: NapcatWebuiGatewayCreateSessionInput) {
|
async create(input: NapcatWebuiGatewayCreateSessionInput) {
|
||||||
|
const normalizedInput = this.validateCreateInput(input);
|
||||||
const existing = await this.store.findActiveByUserAndAccount(
|
const existing = await this.store.findActiveByUserAndAccount(
|
||||||
input.adminUserId,
|
normalizedInput.adminUserId,
|
||||||
input.accountId,
|
normalizedInput.accountId,
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await this.store.update(existing.sessionId, {
|
await this.store.update(existing.sessionId, {
|
||||||
@ -43,19 +50,19 @@ export class NapcatWebuiGatewaySessionService {
|
|||||||
|
|
||||||
const now = this.config.now();
|
const now = this.config.now();
|
||||||
const session: NapcatWebuiGatewaySession = {
|
const session: NapcatWebuiGatewaySession = {
|
||||||
accountId: input.accountId,
|
accountId: normalizedInput.accountId,
|
||||||
adminUserId: input.adminUserId,
|
adminUserId: normalizedInput.adminUserId,
|
||||||
clientIp: this.toOptionalText(input.clientIp),
|
clientIp: this.toOptionalText(normalizedInput.clientIp),
|
||||||
containerId: input.containerId,
|
containerId: normalizedInput.containerId,
|
||||||
containerName: input.containerName,
|
containerName: normalizedInput.containerName,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
expiresAt: now + this.config.ttlMs(),
|
expiresAt: now + this.config.ttlMs(),
|
||||||
selfId: input.selfId,
|
selfId: normalizedInput.selfId,
|
||||||
sessionId: randomUUID(),
|
sessionId: randomUUID(),
|
||||||
status: 'created',
|
status: 'created',
|
||||||
upstreamBaseUrl: input.upstreamBaseUrl,
|
upstreamBaseUrl: normalizedInput.upstreamBaseUrl,
|
||||||
userAgent: this.toOptionalText(input.userAgent),
|
userAgent: this.toOptionalText(normalizedInput.userAgent),
|
||||||
webuiToken: input.webuiToken,
|
webuiToken: normalizedInput.webuiToken,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.store.create(session);
|
return this.store.create(session);
|
||||||
@ -110,8 +117,7 @@ export class NapcatWebuiGatewaySessionService {
|
|||||||
* @returns Browser-safe lifecycle result.
|
* @returns Browser-safe lifecycle result.
|
||||||
*/
|
*/
|
||||||
async revoke(input: NapcatWebuiGatewayLifecycleInput) {
|
async revoke(input: NapcatWebuiGatewayLifecycleInput) {
|
||||||
const session = await this.store.find(input.sessionId);
|
const session = await this.requireUsableSession(input.sessionId);
|
||||||
if (!session) throw new Error('Gateway session is not active');
|
|
||||||
this.assertOwner(session, input.adminUserId);
|
this.assertOwner(session, input.adminUserId);
|
||||||
|
|
||||||
const updated = await this.store.update(input.sessionId, {
|
const updated = await this.store.update(input.sessionId, {
|
||||||
@ -145,11 +151,11 @@ export class NapcatWebuiGatewaySessionService {
|
|||||||
private async requireUsableSession(sessionId: string) {
|
private async requireUsableSession(sessionId: string) {
|
||||||
const session = await this.store.find(sessionId);
|
const session = await this.store.find(sessionId);
|
||||||
if (!session || TERMINAL_SESSION_STATUSES.includes(session.status)) {
|
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()) {
|
if (session.expiresAt <= this.config.now()) {
|
||||||
await this.store.update(sessionId, { status: 'expired' });
|
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;
|
return session;
|
||||||
@ -165,7 +171,62 @@ export class NapcatWebuiGatewaySessionService {
|
|||||||
adminUserId: string,
|
adminUserId: string,
|
||||||
) {
|
) {
|
||||||
if (session.adminUserId !== adminUserId) {
|
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 SESSION_KEY_PREFIX = 'napcat:webui:session:';
|
||||||
const USER_ACCOUNT_KEY_PREFIX = 'napcat:webui:user-account:';
|
const USER_ACCOUNT_KEY_PREFIX = 'napcat:webui:user-account:';
|
||||||
const TERMINAL_SESSION_STATUSES = ['expired', 'failed', 'revoked'];
|
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()
|
@Injectable()
|
||||||
export class NapcatWebuiGatewayRedisStore
|
export class NapcatWebuiGatewayRedisStore
|
||||||
@ -84,12 +90,13 @@ export class NapcatWebuiGatewayRedisStore
|
|||||||
...patch,
|
...patch,
|
||||||
sessionId,
|
sessionId,
|
||||||
};
|
};
|
||||||
|
if (this.isTerminal(current) && !this.isTerminal(next)) {
|
||||||
|
throw new Error('Gateway terminal session cannot become active');
|
||||||
|
}
|
||||||
|
|
||||||
await this.writeSession(next);
|
await this.writeSession(next);
|
||||||
if (this.isTerminal(next)) {
|
if (this.isTerminal(next)) {
|
||||||
await this.redis.del(
|
await this.deleteUserAccountIndexIfCurrent(next);
|
||||||
this.userAccountKey(next.adminUserId, next.accountId),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
await this.writeUserAccountIndex(next);
|
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.
|
* Builds the Redis session key.
|
||||||
* @param sessionId - Gateway session id.
|
* @param sessionId - Gateway session id.
|
||||||
|
|||||||
@ -12,8 +12,8 @@ async function bootstrap() {
|
|||||||
bufferLogs: true,
|
bufferLogs: true,
|
||||||
});
|
});
|
||||||
app.useLogger(app.get(Logger));
|
app.useLogger(app.get(Logger));
|
||||||
app.use(json({ limit: '50mb' }));
|
app.use(json({ limit: '64kb' }));
|
||||||
app.use(urlencoded({ extended: true, limit: '50mb' }));
|
app.use(urlencoded({ extended: true, limit: '64kb' }));
|
||||||
await app.listen(app.get(NapcatWebuiGatewayConfigService).port());
|
await app.listen(app.get(NapcatWebuiGatewayConfigService).port());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -143,6 +143,30 @@ class FakeRedis {
|
|||||||
});
|
});
|
||||||
return deleted;
|
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');
|
).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 () => {
|
it('rejects heartbeat and revoke owner mismatches', async () => {
|
||||||
const store = new MemorySessionStore();
|
const store = new MemorySessionStore();
|
||||||
const service = new NapcatWebuiGatewaySessionService(
|
const service = new NapcatWebuiGatewaySessionService(
|
||||||
@ -315,6 +382,40 @@ describe('NapcatWebuiGatewayRedisStore', () => {
|
|||||||
store.findActiveByUserAndAccount('admin-1', 'account-1'),
|
store.findActiveByUserAndAccount('admin-1', 'account-1'),
|
||||||
).resolves.toBeUndefined();
|
).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', () => {
|
describe('NapcatWebuiGatewayTicketService', () => {
|
||||||
@ -435,4 +536,48 @@ describe('InternalSessionController', () => {
|
|||||||
.get('/internal/health')
|
.get('/internal/health')
|
||||||
.expect(HttpStatus.OK);
|
.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