fix: 收紧NapCat WebUI代理会话边界

This commit is contained in:
sunlei 2026-06-24 14:01:00 +08:00
parent 43b2c4c32e
commit c9cc0d5e3a
5 changed files with 261 additions and 62 deletions

View File

@ -756,7 +756,7 @@ export interface NapcatWebuiGatewaySessionStore {
- [ ] **Step 6: Implement session service** - [ ] **Step 6: Implement session service**
Create `src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts` with `create`, `markActive`, `heartbeat`, `revoke`, and `requireProxySession`. Every method needs JSDoc. Use `crypto.randomUUID()` for `sessionId`. Create `src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts` with `create`, `markActive`, `heartbeat`, `revoke`, `requireBootstrapSession`, and `requireProxySession`. Every method needs JSDoc. Use `crypto.randomUUID()` for `sessionId`.
The `create()` implementation must: The `create()` implementation must:
@ -773,7 +773,7 @@ if (older) {
} }
``` ```
The `heartbeat()` implementation must reject `revoked`, `expired`, `failed`, and missing sessions. 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.
- [ ] **Step 7: Implement Redis store and ticket service** - [ ] **Step 7: Implement Redis store and ticket service**
@ -1006,14 +1006,14 @@ Use `createProxyMiddleware` from `http-proxy-middleware` with:
Before proxying: Before proxying:
- resolve session by `sessionId`; - resolve an active session by `sessionId` through `sessionService.requireProxySession(sessionId)`;
- reject non-active/non-created sessions with 410; - reject non-active, stale, terminal, expired, or missing sessions with 410;
- redeem Credential through `NapcatWebuiCredentialClient`; - redeem Credential through `NapcatWebuiCredentialClient`;
- add `Authorization: Bearer ${credential}` upstream; - add `Authorization: Bearer ${credential}` upstream;
- never forward API/Admin cookies upstream; - never forward API/Admin cookies upstream;
- never allow the browser to override `target`. - never allow the browser to override `target`.
On first successful upstream response, call `sessionService.markActive(sessionId)`. Do not mark sessions active from the proxy path; bootstrap activates the session before redirecting into the proxied WebUI.
For WebSocket upgrade, do not tunnel frames through MQTT and do not hand-roll a WebSocket bridge. Expose a method on `NapcatWebuiProxyService` and bind HPM's upgrade handler from `main.ts`: For WebSocket upgrade, do not tunnel frames through MQTT and do not hand-roll a WebSocket bridge. Expose a method on `NapcatWebuiProxyService` and bind HPM's upgrade handler from `main.ts`:
@ -1046,9 +1046,9 @@ GET /napcat-webui/session/:sessionId/bootstrap
ALL /napcat-webui/session/:sessionId/webui/* ALL /napcat-webui/session/:sessionId/webui/*
``` ```
Bootstrap must redeem `ticket`, set an HttpOnly gateway cookie scoped to `/napcat-webui/session/:sessionId`, and redirect to `/napcat-webui/session/:sessionId/webui/webui`. Bootstrap must redeem `ticket`, validate the redeemed session through `sessionService.requireBootstrapSession(sessionId)`, call `sessionService.markActive(sessionId)`, set an HttpOnly gateway cookie scoped to `/napcat-webui/session/:sessionId`, and redirect to `/napcat-webui/session/:sessionId/webui/webui`.
Proxy route delegates to `NapcatWebuiProxyService`. The proxy service remains responsible for path sanitization, session validation, Credential injection, HPM `cookiePathRewrite`, HPM HTTP proxying, and HPM WebSocket upgrade handling. Proxy route delegates to `NapcatWebuiProxyService`. The proxy service remains responsible for path sanitization, active-only session validation, Credential injection, HPM `cookiePathRewrite`, HPM HTTP proxying, and HPM WebSocket upgrade handling.
- [ ] **Step 7: Run tests and typecheck** - [ ] **Step 7: Run tests and typecheck**

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`、`requireProxySession`。每个方法必须有 JSDoc。`create()` 必须撤销同用户同账号旧 session。 创建 `NapcatWebuiGatewaySessionService`,包含 `create`、`markActive`、`heartbeat`、`revoke`、`requireBootstrapSession`、`requireProxySession`。每个方法必须有 JSDoc。`create()` 必须撤销同用户同账号旧 session。`requireBootstrapSession()` 只接受非终态、未过期且 user/account index 仍指向当前 `sessionId` 的 session`requireProxySession()` 只接受 `active`、未过期且 index 仍指向当前 `sessionId` 的 session。
- [ ] **Step 7实现 Redis store 和 ticket service** - [ ] **Step 7实现 Redis store 和 ticket service**
@ -431,7 +431,7 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api jest --runTestsByPath test/
} }
``` ```
代理前必须解析 session拒绝非 active/created session换取 Credential注入 `Authorization: Bearer <credential>`,删除浏览器传入的 API/Admin cookies不允许浏览器改变 target。 代理前必须通过 `sessionService.requireProxySession(sessionId)` 解析 active session拒绝非 active、stale、终态、过期或缺失 session换取 Credential注入 `Authorization: Bearer <credential>`,删除浏览器传入的 API/Admin cookies不允许浏览器改变 target。Proxy 路径不要再调用 `markActive()`session 必须在 bootstrap redirect 前完成激活。
WebSocket upgrade 必须仍走 `http-proxy-middleware`,不能通过 MQTT 搬运 WebUI 数据帧,也不要手写 WebSocket tunnel。`NapcatWebuiProxyService` 暴露 `bindWebSocketUpgrade(server)`,内部用 HPM 的 `proxy.upgrade(req, socket, head)``main.ts` 在 `app.listen()` 后调用: WebSocket upgrade 必须仍走 `http-proxy-middleware`,不能通过 MQTT 搬运 WebUI 数据帧,也不要手写 WebSocket tunnel。`NapcatWebuiProxyService` 暴露 `bindWebSocketUpgrade(server)`,内部用 HPM 的 `proxy.upgrade(req, socket, head)``main.ts` 在 `app.listen()` 后调用:
@ -449,7 +449,7 @@ GET /napcat-webui/session/:sessionId/bootstrap
ALL /napcat-webui/session/:sessionId/webui/* ALL /napcat-webui/session/:sessionId/webui/*
``` ```
bootstrap 兑换一次性 ticket设置 HttpOnly session cookie跳转到 `/napcat-webui/session/:sessionId/webui/webui`。Proxy route 委托给 `NapcatWebuiProxyService`,由该服务统一负责 path sanitize、session 校验、Credential 注入、HPM `cookiePathRewrite`、HTTP proxy 和 WebSocket upgrade。 bootstrap 兑换一次性 ticket通过 `sessionService.requireBootstrapSession(sessionId)` 校验 bootstrap session调用 `sessionService.markActive(sessionId)`设置 HttpOnly session cookie跳转到 `/napcat-webui/session/:sessionId/webui/webui`。Proxy route 委托给 `NapcatWebuiProxyService`,由该服务统一负责 path sanitize、active-only session 校验、Credential 注入、HPM `cookiePathRewrite`、HTTP proxy 和 WebSocket upgrade。
- [ ] **Step 7运行测试和类型检查** - [ ] **Step 7运行测试和类型检查**

View File

@ -74,7 +74,7 @@ export class NapcatWebuiGatewaySessionService {
* @returns Updated active session. * @returns Updated active session.
*/ */
async markActive(sessionId: string) { async markActive(sessionId: string) {
const session = await this.requireUsableSession(sessionId); const session = await this.requireBootstrapSession(sessionId);
const now = this.config.now(); const now = this.config.now();
return this.updateSession(sessionId, { return this.updateSession(sessionId, {
@ -97,7 +97,7 @@ export class NapcatWebuiGatewaySessionService {
const now = this.config.now(); const now = this.config.now();
const expiresAt = now + this.config.ttlMs(); const expiresAt = now + this.config.ttlMs();
await this.updateSession(input.sessionId, { const updated = await this.updateSession(input.sessionId, {
clientIp: this.toOptionalText(input.clientIp) || session.clientIp, clientIp: this.toOptionalText(input.clientIp) || session.clientIp,
expiresAt, expiresAt,
lastSeenAt: now, lastSeenAt: now,
@ -106,7 +106,7 @@ export class NapcatWebuiGatewaySessionService {
}); });
return { return {
expiresAt, expiresAt: updated.expiresAt,
sessionId: input.sessionId, sessionId: input.sessionId,
status: 'active' as const, status: 'active' as const,
}; };
@ -137,16 +137,30 @@ export class NapcatWebuiGatewaySessionService {
} }
/** /**
* Loads a session only when it is usable for later proxy handling. * Loads a session only when it is eligible for one-time bootstrap.
* @param sessionId - Gateway session id from a public Gateway route. * @param sessionId - Gateway session id redeemed from a bootstrap ticket.
* @returns Stored server-only session metadata for proxy setup. * @returns Indexed non-terminal session that may be marked active.
*/ */
async requireProxySession(sessionId: string) { async requireBootstrapSession(sessionId: string) {
return this.requireUsableSession(sessionId); return this.requireUsableSession(sessionId);
} }
/** /**
* Loads and validates a non-terminal, non-expired session. * Loads a session only when it is active and usable for proxy handling.
* @param sessionId - Gateway session id from a public Gateway route.
* @returns Stored server-only session metadata for proxy setup.
*/
async requireProxySession(sessionId: string) {
const session = await this.requireUsableSession(sessionId);
if (session.status !== 'active') {
throw new GoneException('Gateway session is not active');
}
return session;
}
/**
* Loads and validates a non-terminal, non-expired, currently indexed session.
* @param sessionId - Gateway session id. * @param sessionId - Gateway session id.
* @returns Usable Gateway session. * @returns Usable Gateway session.
*/ */
@ -160,6 +174,14 @@ export class NapcatWebuiGatewaySessionService {
throw new GoneException('Gateway session is not active'); throw new GoneException('Gateway session is not active');
} }
const indexed = await this.store.findActiveByUserAndAccount(
session.adminUserId,
session.accountId,
);
if (!indexed || indexed.sessionId !== session.sessionId) {
throw new GoneException('Gateway session is not active');
}
return session; return session;
} }

View File

@ -17,29 +17,59 @@ if not currentJson then
end end
local current = cjson.decode(currentJson) local current = cjson.decode(currentJson)
local next = cjson.decode(ARGV[2]) local patch = cjson.decode(ARGV[2])
local terminal = { expired = true, failed = true, revoked = true } local terminal = { expired = true, failed = true, revoked = true }
local indexValue = redis.call("GET", KEYS[2]) local next = {}
for key, value in pairs(current) do
next[key] = value
end
for key, value in pairs(patch) do
next[key] = value
end
next["sessionId"] = ARGV[1]
next["adminUserId"] = current["adminUserId"]
next["accountId"] = current["accountId"]
if current["expiresAt"] and patch["expiresAt"] and tonumber(current["expiresAt"]) > tonumber(patch["expiresAt"]) then
next["expiresAt"] = current["expiresAt"]
end
if current["lastSeenAt"] and patch["lastSeenAt"] and tonumber(current["lastSeenAt"]) > tonumber(patch["lastSeenAt"]) then
next["lastSeenAt"] = current["lastSeenAt"]
end
if current["activeAt"] then
next["activeAt"] = current["activeAt"]
end
if current["revokedAt"] then
next["revokedAt"] = current["revokedAt"]
end
local indexKey = ARGV[3] .. current["adminUserId"] .. ":" .. current["accountId"]
local indexValue = redis.call("GET", indexKey)
local now = tonumber(ARGV[4])
local ttl = math.max(1, tonumber(next["expiresAt"]) - now)
local nextJson = cjson.encode(next)
if terminal[current["status"]] and not terminal[next["status"]] then if terminal[current["status"]] and not terminal[next["status"]] then
return {0, "Gateway session is not active"} return {0, "Gateway session is not active"}
end end
if terminal[next["status"]] then if terminal[next["status"]] then
redis.call("PSETEX", KEYS[1], ARGV[3], ARGV[2]) redis.call("PSETEX", KEYS[1], ttl, nextJson)
if indexValue == ARGV[1] then if indexValue == ARGV[1] then
redis.call("DEL", KEYS[2]) redis.call("DEL", indexKey)
end end
return {1, ARGV[2]} return {1, nextJson}
end end
if indexValue and indexValue ~= ARGV[1] then if indexValue and indexValue ~= ARGV[1] then
return {0, "Gateway session is not active"} return {0, "Gateway session is not active"}
end end
redis.call("PSETEX", KEYS[1], ARGV[3], ARGV[2]) redis.call("PSETEX", KEYS[1], ttl, nextJson)
redis.call("SET", KEYS[2], ARGV[1], "PX", ARGV[4]) redis.call("SET", indexKey, ARGV[1], "PX", ttl)
return {1, ARGV[2]} return {1, nextJson}
`; `;
@Injectable() @Injectable()
@ -107,21 +137,7 @@ export class NapcatWebuiGatewayRedisStore
sessionId: string, sessionId: string,
patch: Partial<NapcatWebuiGatewaySession>, patch: Partial<NapcatWebuiGatewaySession>,
) { ) {
const current = await this.find(sessionId); return this.mergeSessionPatchAtomically(sessionId, patch);
if (!current) throw new Error('Gateway session is not active');
const next = {
...current,
...patch,
sessionId,
};
if (this.isTerminal(current) && !this.isTerminal(next)) {
throw new Error('Gateway terminal session cannot become active');
}
await this.writeSessionAndIndexAtomically(next);
return next;
} }
/** /**
@ -178,26 +194,29 @@ export class NapcatWebuiGatewayRedisStore
} }
/** /**
* Atomically writes session JSON and maintains the user/account index. * Atomically merges a patch into current Redis JSON and maintains the index.
* @param session - Already-merged Gateway session to commit. * @param sessionId - Gateway session id to update.
* @param patch - Partial session fields to merge inside Redis.
* @returns Final merged session returned by the Lua script.
*/ */
private async writeSessionAndIndexAtomically( private async mergeSessionPatchAtomically(
session: NapcatWebuiGatewaySession, sessionId: string,
patch: Partial<NapcatWebuiGatewaySession>,
) { ) {
const sessionJson = JSON.stringify(session);
const result = (await this.redis.eval( const result = (await this.redis.eval(
UPDATE_SESSION_SCRIPT, UPDATE_SESSION_SCRIPT,
2, 1,
this.sessionKey(session.sessionId), this.sessionKey(sessionId),
this.userAccountKey(session.adminUserId, session.accountId), sessionId,
session.sessionId, JSON.stringify(patch),
sessionJson, USER_ACCOUNT_KEY_PREFIX,
this.remainingTtlMs(session), this.config.now(),
this.remainingTtlMs(session),
)) as [number, string]; )) as [number, string];
if (!Array.isArray(result) || Number(result[0]) !== 1) { if (!Array.isArray(result) || Number(result[0]) !== 1) {
throw new Error(String(result?.[1] || 'Gateway session is not active')); throw new Error(String(result?.[1] || 'Gateway session is not active'));
} }
return JSON.parse(result[1]) as NapcatWebuiGatewaySession;
} }
/** /**

View File

@ -156,27 +156,52 @@ class FakeRedis {
if (!script.includes('redis.call')) { if (!script.includes('redis.call')) {
throw new Error('Unexpected Redis script'); throw new Error('Unexpected Redis script');
} }
if (keyCount !== 2) { if (keyCount !== 1) {
throw new Error('Unexpected Redis key count'); throw new Error('Unexpected Redis key count');
} }
const [ const [
sessionKey, sessionKey,
indexKey,
sessionId, sessionId,
nextSessionJson, patchJson,
sessionTtlMs, userAccountKeyPrefix,
indexTtlMs, now,
] = args; ] = args;
const currentJson = this.values.get(sessionKey); const currentJson = this.values.get(sessionKey);
if (!currentJson) return [0, 'Gateway session is not active']; if (!currentJson) return [0, 'Gateway session is not active'];
const current = JSON.parse(currentJson) as NapcatWebuiGatewaySession; const current = JSON.parse(currentJson) as NapcatWebuiGatewaySession;
const next = JSON.parse(nextSessionJson) as NapcatWebuiGatewaySession; const patch = JSON.parse(
patchJson,
) as Partial<NapcatWebuiGatewaySession>;
const next = {
...current,
...patch,
accountId: current.accountId,
adminUserId: current.adminUserId,
sessionId,
} as NapcatWebuiGatewaySession;
next.expiresAt = Math.max(current.expiresAt, patch.expiresAt || 0);
if (current.lastSeenAt || patch.lastSeenAt) {
next.lastSeenAt = Math.max(
current.lastSeenAt || 0,
patch.lastSeenAt || 0,
);
}
if (current.activeAt) {
next.activeAt = current.activeAt;
}
if (current.revokedAt) {
next.revokedAt = current.revokedAt;
}
const terminalStatuses = ['expired', 'failed', 'revoked']; const terminalStatuses = ['expired', 'failed', 'revoked'];
const currentTerminal = terminalStatuses.includes(current.status); const currentTerminal = terminalStatuses.includes(current.status);
const nextTerminal = terminalStatuses.includes(next.status); const nextTerminal = terminalStatuses.includes(next.status);
const indexKey = `${userAccountKeyPrefix}${current.adminUserId}:${current.accountId}`;
const indexValue = this.values.get(indexKey); const indexValue = this.values.get(indexKey);
const ttlMs = Math.max(1, next.expiresAt - Number(now));
const nextSessionJson = JSON.stringify(next);
if (currentTerminal && !nextTerminal) { if (currentTerminal && !nextTerminal) {
return [0, 'Gateway session is not active']; return [0, 'Gateway session is not active'];
@ -184,7 +209,7 @@ class FakeRedis {
if (nextTerminal) { if (nextTerminal) {
this.values.set(sessionKey, nextSessionJson); this.values.set(sessionKey, nextSessionJson);
this.ttl.set(sessionKey, Number(sessionTtlMs)); this.ttl.set(sessionKey, ttlMs);
if (indexValue === sessionId) { if (indexValue === sessionId) {
this.values.delete(indexKey); this.values.delete(indexKey);
this.ttl.delete(indexKey); this.ttl.delete(indexKey);
@ -197,9 +222,9 @@ class FakeRedis {
} }
this.values.set(sessionKey, nextSessionJson); this.values.set(sessionKey, nextSessionJson);
this.ttl.set(sessionKey, Number(sessionTtlMs)); this.ttl.set(sessionKey, ttlMs);
this.values.set(indexKey, sessionId); this.values.set(indexKey, sessionId);
this.ttl.set(indexKey, Number(indexTtlMs)); this.ttl.set(indexKey, ttlMs);
return [1, nextSessionJson]; return [1, nextSessionJson];
} }
} }
@ -370,6 +395,37 @@ describe('NapcatWebuiGatewaySessionService', () => {
).rejects.toThrow('Gateway session owner mismatch'); ).rejects.toThrow('Gateway session owner mismatch');
}); });
it('keeps created sessions out of proxy access until bootstrap marks them active', async () => {
const store = new MemorySessionStore();
const currentTime = { value: 1000 };
const service = new NapcatWebuiGatewaySessionService(
store,
createConfig(currentTime) as never,
);
const session = await service.create(createSessionInput());
await expect(service.requireProxySession(session.sessionId)).rejects.toThrow(
'Gateway session is not active',
);
await expect(
service.requireBootstrapSession(session.sessionId),
).resolves.toMatchObject({
sessionId: session.sessionId,
status: 'created',
});
currentTime.value = 5000;
await service.markActive(session.sessionId);
await expect(
service.requireProxySession(session.sessionId),
).resolves.toMatchObject({
activeAt: 5000,
sessionId: session.sessionId,
status: 'active',
});
});
it('rejects expired proxy sessions and marks them expired', async () => { it('rejects expired proxy sessions and marks them expired', async () => {
const store = new MemorySessionStore(); const store = new MemorySessionStore();
const currentTime = { value: 1000 }; const currentTime = { value: 1000 };
@ -388,6 +444,24 @@ describe('NapcatWebuiGatewaySessionService', () => {
status: 'expired', status: 'expired',
}); });
}); });
it('rejects revoked sessions for proxy access', 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.requireProxySession(session.sessionId)).rejects.toThrow(
'Gateway session is not active',
);
});
}); });
describe('NapcatWebuiGatewayRedisStore', () => { describe('NapcatWebuiGatewayRedisStore', () => {
@ -521,6 +595,90 @@ describe('NapcatWebuiGatewayRedisStore', () => {
sessionId: second.sessionId, sessionId: second.sessionId,
}); });
}); });
it('rejects stale replaced sessions from bootstrap and proxy loaders', 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());
const firstSessionKey = `napcat:webui:session:${first.sessionId}`;
const indexKey = 'napcat:webui:user-account:admin-1:account-1';
redis.values.set(
firstSessionKey,
JSON.stringify({
...first,
status: 'created',
}),
);
redis.values.set(indexKey, second.sessionId);
await expect(
service.requireBootstrapSession(first.sessionId),
).rejects.toThrow('Gateway session is not active');
redis.values.set(
firstSessionKey,
JSON.stringify({
...first,
activeAt: 2000,
lastSeenAt: 2000,
status: 'active',
}),
);
await expect(service.requireProxySession(first.sessionId)).rejects.toThrow(
'Gateway session is not active',
);
});
it('merges delayed lifecycle patches without reducing monotonic timestamps', async () => {
const redis = new FakeRedis();
const currentTime = { value: 1000 };
const config = createConfig(currentTime);
const store = new NapcatWebuiGatewayRedisStore(
redis as never,
config as never,
);
const service = new NapcatWebuiGatewaySessionService(
store,
config as never,
);
const session = await service.create(createSessionInput());
currentTime.value = 5000;
await service.markActive(session.sessionId);
currentTime.value = 2000;
const heartbeat = await service.heartbeat({
adminUserId: 'admin-1',
sessionId: session.sessionId,
});
const markActive = await service.markActive(session.sessionId);
const stored = await store.find(session.sessionId);
expect(heartbeat.expiresAt).toBe(65_000);
expect(markActive).toMatchObject({
activeAt: 5000,
expiresAt: 65_000,
lastSeenAt: 5000,
status: 'active',
});
expect(stored).toMatchObject({
activeAt: 5000,
expiresAt: 65_000,
lastSeenAt: 5000,
status: 'active',
});
});
}); });
describe('NapcatWebuiGatewayTicketService', () => { describe('NapcatWebuiGatewayTicketService', () => {