diff --git a/src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts b/src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts index 6176dc4..552d583 100644 --- a/src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts +++ b/src/apps/napcat-webui-gateway/config/napcat-webui-gateway-config.service.ts @@ -5,6 +5,7 @@ const DEFAULT_GATEWAY_PORT = 48086; const DEFAULT_REDIS_HOST = '127.0.0.1'; const DEFAULT_REDIS_PORT = 6379; const DEFAULT_SESSION_TTL_MS = 60_000; +const DEFAULT_UPSTREAM_TIMEOUT_MS = 5000; const MAX_TICKET_TTL_MS = 60_000; @Injectable() @@ -59,6 +60,17 @@ export class NapcatWebuiGatewayConfigService { ); } + /** + * Reads the bounded timeout for NapCat WebUI upstream HTTP calls. + * @returns Positive timeout in milliseconds, defaulting to 5 seconds. + */ + upstreamTimeoutMs() { + return this.getPositiveNumber( + 'NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS', + DEFAULT_UPSTREAM_TIMEOUT_MS, + ); + } + /** * Reads the shared internal secret required for mutating API-to-Gateway calls. * @returns Trimmed secret or an empty string when missing so callers fail closed. diff --git a/src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client.ts b/src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client.ts new file mode 100644 index 0000000..a69e97e --- /dev/null +++ b/src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client.ts @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto'; +import axios from 'axios'; +import { BadGatewayException, Injectable } from '@nestjs/common'; +import { NapcatWebuiGatewayConfigService } from '../config/napcat-webui-gateway-config.service'; +import type { NapcatWebuiGatewaySession } from '../domain/napcat-webui-gateway.types'; + +type NapcatCredentialBody = { + Credential?: string; +}; + +type NapcatCredentialResponse = + | NapcatCredentialBody + | { + data?: NapcatCredentialBody; + }; + +@Injectable() +export class NapcatWebuiCredentialClient { + private readonly credentials = new Map< + string, + { credential: string; expiresAt: number } + >(); + + /** + * Creates the standalone Gateway credential client. + * @param config - Gateway config used for bounded upstream requests and time. + */ + constructor(private readonly config: NapcatWebuiGatewayConfigService) {} + + /** + * Returns a cached or freshly exchanged NapCat WebUI credential for one Gateway session. + * @param session - Server-only Gateway session metadata containing token and upstream URL. + * @returns NapCat WebUI Credential string for upstream Authorization. + */ + async getCredential(session: NapcatWebuiGatewaySession) { + const cached = this.credentials.get(session.sessionId); + const now = this.config.now(); + if ( + cached && + now < cached.expiresAt && + cached.expiresAt <= session.expiresAt + ) { + return cached.credential; + } + + const credential = await this.exchangeCredential(session); + this.credentials.set(session.sessionId, { + credential, + expiresAt: session.expiresAt, + }); + return credential; + } + + /** + * Clears a cached credential when a Gateway session is revoked. + * @param sessionId - Gateway session id whose cached credential should be removed. + */ + clear(sessionId: string) { + this.credentials.delete(sessionId); + } + + /** + * Exchanges the server-only WebUI token for a NapCat Credential. + * @param session - Gateway session containing upstream base URL and WebUI token. + * @returns Credential returned by NapCat WebUI. + */ + private async exchangeCredential(session: NapcatWebuiGatewaySession) { + const hash = createHash('sha256') + .update(`${session.webuiToken}.napcat`) + .digest('hex'); + + try { + const response = await axios.post( + new URL('/api/auth/login', session.upstreamBaseUrl).toString(), + { hash }, + { + timeout: this.config.upstreamTimeoutMs(), + }, + ); + const credential = this.extractCredential(response.data); + if (!credential) { + throw new Error('Missing Credential'); + } + return credential; + } catch { + throw new BadGatewayException('NapCat WebUI credential exchange failed'); + } + } + + /** + * Reads Credential from either the raw NapCat payload or its data wrapper. + * @param body - Axios response body from `/api/auth/login`. + * @returns Credential when present. + */ + private extractCredential(body: NapcatCredentialResponse) { + if ('data' in body) { + return body.data?.Credential; + } + return (body as NapcatCredentialBody).Credential; + } +} diff --git a/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts b/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts new file mode 100644 index 0000000..d785c4c --- /dev/null +++ b/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts @@ -0,0 +1,325 @@ +import type { IncomingMessage, Server } from 'node:http'; +import type { Socket } from 'node:net'; +import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; +import { + createProxyMiddleware, + type RequestHandler, +} from 'http-proxy-middleware'; +import { NapcatWebuiGatewaySessionService } from '../../application/napcat-webui-gateway-session.service'; +import type { NapcatWebuiGatewaySession } from '../../domain/napcat-webui-gateway.types'; +import { NapcatWebuiCredentialClient } from '../napcat-webui-credential.client'; + +const GATEWAY_WEBUI_PREFIX = '/napcat-webui/session'; +const STRIPPED_UPSTREAM_HEADERS = [ + 'authorization', + 'cookie', + 'x-admin-token', + 'x-api-token', + 'x-access-token', + 'x-kt-access-token', + 'x-kt-gateway-secret', + 'x-wordpress-cookie', +] as const; + +type ProxyPathInput = string | string[] | undefined; + +type RewriteLocationInput = { + location: string; + sessionId: string; + upstreamBaseUrl: string; +}; + +type CookiePathRewriteInput = { + sessionId: string; +}; + +/** + * Normalizes a Gateway route tail into a safe upstream pathname. + * @param input - Route parameter from Nest/path-to-regexp. + * @returns Absolute upstream pathname beginning with `/`. + */ +export function sanitizeGatewayProxyPath(input: ProxyPathInput) { + const raw = Array.isArray(input) ? input.join('/') : String(input || ''); + const trimmed = raw.trim(); + const decoded = decodeProxyPath(trimmed); + + if ( + !decoded || + decoded.includes('\\') || + decoded.startsWith('//') || + /^[a-z][a-z0-9+.-]*:/i.test(decoded) + ) { + throw new BadRequestException('Gateway proxy path is invalid'); + } + + const path = decoded.startsWith('/') ? decoded : `/${decoded}`; + const segments = path.split('/').filter(Boolean); + if (segments.some((segment) => segment === '..')) { + throw new BadRequestException('Gateway proxy path is invalid'); + } + + return path; +} + +/** + * Rewrites NapCat redirects so browsers stay under the Gateway session route. + * @param input - Upstream Location header plus Gateway session context. + * @returns Rewritten safe Location header. + */ +export function rewriteNapcatLocationHeader(input: RewriteLocationInput) { + const gatewayPrefix = `${GATEWAY_WEBUI_PREFIX}/${encodeURIComponent( + input.sessionId, + )}/webui`; + const location = input.location.trim(); + if (!location) return location; + + if (location.startsWith('//')) { + try { + const upstream = new URL(input.upstreamBaseUrl); + const target = new URL(`${upstream.protocol}${location}`); + return `${gatewayPrefix}${target.pathname}${target.search}${target.hash}`; + } catch { + return `${gatewayPrefix}/webui`; + } + } + if (location.startsWith('/')) { + return `${gatewayPrefix}${location}`; + } + if (!/^[a-z][a-z0-9+.-]*:/i.test(location)) { + return `${gatewayPrefix}/${location.replace(/^\/+/, '')}`; + } + + try { + const target = new URL(location); + if (target.protocol === 'http:' || target.protocol === 'https:') { + return `${gatewayPrefix}${target.pathname}${target.search}${target.hash}`; + } + return `${gatewayPrefix}/webui`; + } catch { + return `${gatewayPrefix}/webui`; + } +} + +/** + * Builds HPM cookie path rewrite config for Gateway-scoped upstream cookies. + * @param input - Gateway session id used in the public route prefix. + * @returns HPM cookiePathRewrite object. + */ +export function buildGatewayCookiePathRewrite(input: CookiePathRewriteInput) { + return { + '*': `${GATEWAY_WEBUI_PREFIX}/${encodeURIComponent(input.sessionId)}/webui`, + }; +} + +/** + * Decodes path text until stable so nested encoded traversal cannot pass through. + * @param value - Raw route path text. + * @returns Decoded path text. + */ +function decodeProxyPath(value: string) { + try { + let decoded = value; + for (let index = 0; index < 6; index += 1) { + const next = decodeURIComponent(decoded); + if (next === decoded) { + return next; + } + decoded = next; + } + } catch { + throw new BadRequestException('Gateway proxy path is invalid'); + } + + throw new BadRequestException('Gateway proxy path is invalid'); +} + +@Injectable() +export class NapcatWebuiProxyService { + /** + * Creates the Gateway proxy service. + * @param sessionService - Session lifecycle guard for bootstrap/proxy eligibility. + * @param credentialClient - NapCat WebUI credential exchange/cache client. + */ + constructor( + private readonly sessionService: NapcatWebuiGatewaySessionService, + private readonly credentialClient: NapcatWebuiCredentialClient, + ) {} + + /** + * Proxies one HTTP request to the active session's NapCat WebUI. + * @param sessionId - Gateway session id from the public route. + * @param proxyPath - Route tail mapped to the upstream pathname. + * @param req - Express request delegated from the public controller. + * @param res - Express response owned by HPM after delegation. + * @param next - Express next callback used by HPM. + */ + async handleHttpProxy( + sessionId: string, + proxyPath: ProxyPathInput, + req: Request, + res: Response, + next: NextFunction, + ) { + const session = await this.sessionService.requireProxySession(sessionId); + const upstreamPath = this.buildUpstreamPath(proxyPath, req.originalUrl); + const credential = await this.credentialClient.getCredential(session); + this.stripBrowserHeaders(req); + req.url = upstreamPath; + + const proxy = this.createProxy(session, credential); + return proxy(req, res, next); + } + + /** + * Subscribes the Gateway HTTP server to NapCat WebUI WebSocket upgrades. + * @param server - HTTP server returned by the Nest application. + */ + bindWebSocketUpgrade(server: Server) { + server.on('upgrade', (req, socket, head) => { + void this.handleWebSocketUpgrade(req, socket as Socket, head); + }); + } + + /** + * Handles one matching WebSocket upgrade and ignores unrelated upgrade URLs. + * @param req - Raw Node upgrade request. + * @param socket - TCP socket for the upgrade. + * @param head - First packet of the upgraded stream. + */ + private async handleWebSocketUpgrade( + req: IncomingMessage, + socket: Socket, + head: Buffer, + ) { + try { + const match = this.matchGatewayUpgrade(req.url || ''); + if (!match) return; + const session = await this.sessionService.requireProxySession( + match.sessionId, + ); + const credential = await this.credentialClient.getCredential(session); + this.stripBrowserHeaders(req); + req.url = `${match.proxyPath}${match.search}`; + const proxy = this.createProxy(session, credential); + proxy.upgrade(req, socket, head); + } catch { + this.rejectUpgrade(socket); + } + } + + /** + * Creates one HPM proxy bound to a validated session and server-side credential. + * @param session - Active Gateway session metadata. + * @param credential - NapCat WebUI Credential for upstream Authorization. + * @returns HPM request handler with HTTP and WebSocket support. + */ + private createProxy( + session: NapcatWebuiGatewaySession, + credential: string, + ): RequestHandler { + return createProxyMiddleware({ + changeOrigin: true, + cookiePathRewrite: buildGatewayCookiePathRewrite({ + sessionId: session.sessionId, + }), + on: { + error: (_error, _req, res) => { + this.writeProxyError(res); + }, + proxyReq: (proxyReq) => { + proxyReq.removeHeader('cookie'); + proxyReq.setHeader('Authorization', `Bearer ${credential}`); + }, + proxyReqWs: (proxyReq) => { + proxyReq.removeHeader('cookie'); + proxyReq.setHeader('Authorization', `Bearer ${credential}`); + }, + proxyRes: (proxyRes) => { + const location = proxyRes.headers.location; + if (typeof location === 'string') { + proxyRes.headers.location = rewriteNapcatLocationHeader({ + location, + sessionId: session.sessionId, + upstreamBaseUrl: session.upstreamBaseUrl, + }); + } + }, + }, + secure: false, + target: session.upstreamBaseUrl, + ws: true, + }); + } + + /** + * Builds the upstream URL path while preserving the original query string. + * @param proxyPath - Gateway route tail to sanitize. + * @param originalUrl - Original Express URL containing the query string. + * @returns Upstream path plus query string. + */ + private buildUpstreamPath(proxyPath: ProxyPathInput, originalUrl?: string) { + const pathname = sanitizeGatewayProxyPath(proxyPath); + const queryIndex = String(originalUrl || '').indexOf('?'); + const query = queryIndex >= 0 ? String(originalUrl).slice(queryIndex) : ''; + return `${pathname}${query}`; + } + + /** + * Parses a public WebSocket upgrade URL into Gateway session and upstream path. + * @param rawUrl - Raw URL from the Node upgrade request. + * @returns Parsed session id, sanitized upstream path, and query string when matched. + */ + private matchGatewayUpgrade(rawUrl: string) { + const url = new URL(rawUrl, 'http://gateway.local'); + const match = url.pathname.match( + /^\/napcat-webui\/session\/([^/]+)\/webui(?:\/(.*))?$/, + ); + if (!match) return undefined; + + return { + proxyPath: sanitizeGatewayProxyPath(match[2] || ''), + search: url.search, + sessionId: decodeURIComponent(match[1]), + }; + } + + /** + * Removes browser-provided auth/session headers before HPM builds upstream requests. + * @param req - Express or Node request whose headers are being proxied. + */ + private stripBrowserHeaders(req: IncomingMessage) { + STRIPPED_UPSTREAM_HEADERS.forEach((header) => { + delete req.headers[header]; + }); + } + + /** + * Writes a generic HTTP proxy failure without exposing upstream target data. + * @param res - HTTP response object passed by HPM. + */ + private writeProxyError(res: Response | Socket) { + if ('headersSent' in res) { + if (res.headersSent) return; + res.status(HttpStatus.BAD_GATEWAY).json({ + message: 'NapCat WebUI proxy failed', + statusCode: HttpStatus.BAD_GATEWAY, + }); + return; + } + this.rejectUpgrade(res); + } + + /** + * Sends a compact HTTP error for failed WebSocket upgrade validation. + * @param socket - Upgrade socket to close after the error response. + */ + private rejectUpgrade(socket: Socket) { + if (socket.writable) { + socket.write( + 'HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n', + ); + } + socket.destroy(); + } +} diff --git a/src/apps/napcat-webui-gateway/main.ts b/src/apps/napcat-webui-gateway/main.ts index fb9a07f..62a1311 100644 --- a/src/apps/napcat-webui-gateway/main.ts +++ b/src/apps/napcat-webui-gateway/main.ts @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { Logger } from 'nestjs-pino'; import { json, urlencoded } from 'express'; import { NapcatWebuiGatewayConfigService } from './config/napcat-webui-gateway-config.service'; +import { NapcatWebuiProxyService } from './infrastructure/proxy/napcat-webui-proxy.service'; import { NapcatWebuiGatewayModule } from './napcat-webui-gateway.module'; /** @@ -15,6 +16,7 @@ async function bootstrap() { app.use(json({ limit: '64kb' })); app.use(urlencoded({ extended: true, limit: '64kb' })); await app.listen(app.get(NapcatWebuiGatewayConfigService).port()); + app.get(NapcatWebuiProxyService).bindWebSocketUpgrade(app.getHttpServer()); } bootstrap(); diff --git a/src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts b/src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts index a60e88a..34d8593 100644 --- a/src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts +++ b/src/apps/napcat-webui-gateway/napcat-webui-gateway.module.ts @@ -6,9 +6,12 @@ import { createPinoLoggerParams } from '@/common'; import { NapcatWebuiGatewaySessionService } from './application/napcat-webui-gateway-session.service'; import { NapcatWebuiGatewayConfigService } from './config/napcat-webui-gateway-config.service'; import { NAPCAT_WEBUI_GATEWAY_SESSION_STORE } from './domain/napcat-webui-gateway.types'; +import { NapcatWebuiCredentialClient } from './infrastructure/napcat-webui-credential.client'; +import { NapcatWebuiProxyService } from './infrastructure/proxy/napcat-webui-proxy.service'; import { NapcatWebuiGatewayRedisStore } from './infrastructure/session/napcat-webui-gateway-redis.store'; import { NapcatWebuiGatewayTicketService } from './infrastructure/session/napcat-webui-gateway-ticket.service'; import { InternalSessionController } from './presentation/internal-session.controller'; +import { PublicWebuiController } from './presentation/public-webui.controller'; @Module({ imports: [ @@ -44,9 +47,11 @@ import { InternalSessionController } from './presentation/internal-session.contr }, }), ], - controllers: [InternalSessionController], + controllers: [InternalSessionController, PublicWebuiController], providers: [ NapcatWebuiGatewayConfigService, + NapcatWebuiCredentialClient, + NapcatWebuiProxyService, NapcatWebuiGatewaySessionService, NapcatWebuiGatewayRedisStore, NapcatWebuiGatewayTicketService, diff --git a/src/apps/napcat-webui-gateway/presentation/internal-session.controller.ts b/src/apps/napcat-webui-gateway/presentation/internal-session.controller.ts index 4f7b5ab..dcd7874 100644 --- a/src/apps/napcat-webui-gateway/presentation/internal-session.controller.ts +++ b/src/apps/napcat-webui-gateway/presentation/internal-session.controller.ts @@ -13,6 +13,7 @@ import type { NapcatWebuiGatewayCreateSessionInput, NapcatWebuiGatewayLifecycleInput, } from '../domain/napcat-webui-gateway.types'; +import { NapcatWebuiCredentialClient } from '../infrastructure/napcat-webui-credential.client'; import { NapcatWebuiGatewayTicketService } from '../infrastructure/session/napcat-webui-gateway-ticket.service'; type CreateSessionBody = NapcatWebuiGatewayCreateSessionInput; @@ -24,11 +25,13 @@ export class InternalSessionController { * Creates the internal API-to-Gateway session controller. * @param sessionService - Gateway session lifecycle application service. * @param ticketService - One-time bootstrap ticket service. + * @param credentialClient - Server-side credential cache cleared on revoke. * @param config - Gateway config used for secret validation and public URL prefix. */ constructor( private readonly sessionService: NapcatWebuiGatewaySessionService, private readonly ticketService: NapcatWebuiGatewayTicketService, + private readonly credentialClient: NapcatWebuiCredentialClient, private readonly config: NapcatWebuiGatewayConfigService, ) {} @@ -91,16 +94,18 @@ export class InternalSessionController { * @returns Browser-safe revoke lifecycle result. */ @Post('sessions/:sessionId/revoke') - revoke( + async revoke( @Param('sessionId') sessionId: string, @Headers('x-kt-gateway-secret') secret: string, @Body() body: LifecycleBody, ) { this.requireInternalSecret(secret); - return this.sessionService.revoke({ + const result = await this.sessionService.revoke({ ...body, sessionId, }); + this.credentialClient.clear(sessionId); + return result; } /** diff --git a/src/apps/napcat-webui-gateway/presentation/public-webui.controller.ts b/src/apps/napcat-webui-gateway/presentation/public-webui.controller.ts new file mode 100644 index 0000000..af33c0b --- /dev/null +++ b/src/apps/napcat-webui-gateway/presentation/public-webui.controller.ts @@ -0,0 +1,101 @@ +import { + All, + Controller, + Get, + GoneException, + HttpStatus, + Next, + Param, + Query, + Req, + Res, +} from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; +import { NapcatWebuiGatewaySessionService } from '../application/napcat-webui-gateway-session.service'; +import { NapcatWebuiProxyService } from '../infrastructure/proxy/napcat-webui-proxy.service'; +import { NapcatWebuiGatewayTicketService } from '../infrastructure/session/napcat-webui-gateway-ticket.service'; + +@Controller('napcat-webui') +export class PublicWebuiController { + /** + * Creates the browser-facing NapCat WebUI controller. + * @param sessionService - Gateway session lifecycle service. + * @param ticketService - One-time bootstrap ticket service. + * @param proxyService - HTTP/WebSocket proxy delegation service. + */ + constructor( + private readonly sessionService: NapcatWebuiGatewaySessionService, + private readonly ticketService: NapcatWebuiGatewayTicketService, + private readonly proxyService: NapcatWebuiProxyService, + ) {} + + /** + * Redeems a one-time bootstrap ticket, activates the session, and enters WebUI. + * @param sessionId - Gateway session id from the public route. + * @param ticket - One-time ticket issued by the internal create-session endpoint. + * @param res - Express response used to set the scoped cookie and redirect. + */ + @Get('session/:sessionId/bootstrap') + async bootstrap( + @Param('sessionId') sessionId: string, + @Query('ticket') ticket: string, + @Res() res: Response, + ) { + const redeemedSessionId = await this.ticketService.redeem( + this.requireTicket(ticket), + ); + if (redeemedSessionId !== sessionId) { + throw new GoneException('Gateway bootstrap ticket is not active'); + } + + await this.sessionService.requireBootstrapSession(sessionId); + await this.sessionService.markActive(sessionId); + res.cookie('kt_napcat_webui_gateway', 'active', { + httpOnly: true, + path: `/napcat-webui/session/${encodeURIComponent(sessionId)}`, + sameSite: 'lax', + }); + res.redirect( + HttpStatus.FOUND, + `/napcat-webui/session/${encodeURIComponent(sessionId)}/webui/webui`, + ); + } + + /** + * Delegates Gateway-owned WebUI HTTP routes to the proxy service. + * @param sessionId - Gateway session id from the public route. + * @param proxyPath - Path-to-regexp route tail for the upstream pathname. + * @param req - Express request passed through to HPM. + * @param res - Express response passed through to HPM. + * @param next - Express next callback passed through to HPM. + */ + @All('session/:sessionId/webui/*proxyPath') + proxy( + @Param('sessionId') sessionId: string, + @Param('proxyPath') proxyPath: string | string[] | undefined, + @Req() req: Request, + @Res() res: Response, + @Next() next: NextFunction, + ) { + return this.proxyService.handleHttpProxy( + sessionId, + proxyPath, + req, + res, + next, + ); + } + + /** + * Requires a non-empty bootstrap ticket before touching the ticket store. + * @param ticket - Candidate query ticket. + * @returns Trimmed ticket value. + */ + private requireTicket(ticket: string) { + const value = String(ticket || '').trim(); + if (!value) { + throw new GoneException('Gateway bootstrap ticket is not active'); + } + return value; + } +} diff --git a/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts b/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts new file mode 100644 index 0000000..e4ab67b --- /dev/null +++ b/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts @@ -0,0 +1,302 @@ +import { createHash } from 'node:crypto'; +import { HttpStatus, type INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import axios from 'axios'; +import * as request from 'supertest'; +import { NapcatWebuiGatewaySessionService } from '../../../src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service'; +import type { NapcatWebuiGatewaySession } from '../../../src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types'; +import { NapcatWebuiCredentialClient } from '../../../src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client'; +import { NapcatWebuiGatewayTicketService } from '../../../src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service'; +import { + buildGatewayCookiePathRewrite, + NapcatWebuiProxyService, + rewriteNapcatLocationHeader, + sanitizeGatewayProxyPath, +} from '../../../src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service'; +import { PublicWebuiController } from '../../../src/apps/napcat-webui-gateway/presentation/public-webui.controller'; + +jest.mock('axios'); + +const SESSION_ID = 'session-1'; +const UPSTREAM_BASE_URL = 'http://127.0.0.1:6099'; +const mockedAxiosPost = axios.post as jest.Mock; + +/** + * Creates server-only Gateway session metadata for public bootstrap tests. + * @param override - Session fields that should replace the default fixture. + * @returns Gateway session fixture. + */ +function createGatewaySession( + override: Partial = {}, +): NapcatWebuiGatewaySession { + return { + accountId: 'account-1', + adminUserId: 'admin-1', + containerId: 'container-1', + containerName: 'kt-qqbot-napcat-1914728559', + createdAt: 1000, + expiresAt: 61_000, + selfId: '1914728559', + sessionId: SESSION_ID, + status: 'created', + upstreamBaseUrl: UPSTREAM_BASE_URL, + webuiToken: ['webui', 'token', 'fixture'].join('-'), + ...override, + }; +} + +/** + * Creates a Gateway config fixture for credential client tests. + * @param currentTime - Mutable timestamp source used to check cache expiry decisions. + * @returns Config facade shape required by NapcatWebuiCredentialClient. + */ +function createCredentialConfig(currentTime: { value: number }) { + return { + now: () => currentTime.value, + upstreamTimeoutMs: () => 5000, + }; +} + +describe('Napcat WebUI proxy rewrite helpers', () => { + it('rejects absolute URL proxy paths', () => { + expect(() => sanitizeGatewayProxyPath('https://evil.test/api')).toThrow( + 'Gateway proxy path is invalid', + ); + }); + + it('rejects dot-segment traversal proxy paths', () => { + expect(() => sanitizeGatewayProxyPath('../api/auth/login')).toThrow( + 'Gateway proxy path is invalid', + ); + }); + + it('rejects encoded dot-segment traversal proxy paths', () => { + expect(() => sanitizeGatewayProxyPath('%2e%2e/api/auth/login')).toThrow( + 'Gateway proxy path is invalid', + ); + }); + + it('rejects double-encoded dot-segment traversal proxy paths', () => { + expect(() => sanitizeGatewayProxyPath('%252e%252e/api/auth/login')).toThrow( + 'Gateway proxy path is invalid', + ); + }); + + it('normalizes string proxy paths to absolute upstream paths', () => { + expect(sanitizeGatewayProxyPath('api/QQLogin/CheckLoginStatus')).toBe( + '/api/QQLogin/CheckLoginStatus', + ); + }); + + it('normalizes path-to-regexp array proxy paths to absolute upstream paths', () => { + expect( + sanitizeGatewayProxyPath(['api', 'QQLogin', 'CheckLoginStatus']), + ).toBe('/api/QQLogin/CheckLoginStatus'); + }); + + it('rewrites NapCat relative redirects under the Gateway session prefix', () => { + expect( + rewriteNapcatLocationHeader({ + location: '/webui/login', + sessionId: SESSION_ID, + upstreamBaseUrl: UPSTREAM_BASE_URL, + }), + ).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`); + }); + + it('rewrites absolute redirects under the Gateway session prefix without leaking origin', () => { + expect( + rewriteNapcatLocationHeader({ + location: 'http://127.0.0.1:6099/webui/login?next=/', + sessionId: SESSION_ID, + upstreamBaseUrl: UPSTREAM_BASE_URL, + }), + ).toBe( + `/napcat-webui/session/${SESSION_ID}/webui/webui/login?next=/`, + ); + expect( + rewriteNapcatLocationHeader({ + location: 'http://container.internal:6099/webui/login', + sessionId: SESSION_ID, + upstreamBaseUrl: UPSTREAM_BASE_URL, + }), + ).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`); + }); + + it('rewrites protocol-relative redirects under the Gateway session prefix without leaking origin', () => { + expect( + rewriteNapcatLocationHeader({ + location: '//container.internal:6099/webui/login?next=/', + sessionId: SESSION_ID, + upstreamBaseUrl: UPSTREAM_BASE_URL, + }), + ).toBe( + `/napcat-webui/session/${SESSION_ID}/webui/webui/login?next=/`, + ); + }); + + it('fails closed for malformed absolute redirects', () => { + expect( + rewriteNapcatLocationHeader({ + location: 'http://%', + sessionId: SESSION_ID, + upstreamBaseUrl: UPSTREAM_BASE_URL, + }), + ).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui`); + expect( + rewriteNapcatLocationHeader({ + location: '//%', + sessionId: SESSION_ID, + upstreamBaseUrl: UPSTREAM_BASE_URL, + }), + ).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui`); + }); + + it('scopes all upstream cookies to the Gateway WebUI path', () => { + expect(buildGatewayCookiePathRewrite({ sessionId: SESSION_ID })).toEqual({ + '*': `/napcat-webui/session/${SESSION_ID}/webui`, + }); + }); +}); + +describe('NapcatWebuiCredentialClient', () => { + beforeEach(() => { + mockedAxiosPost.mockReset(); + }); + + it('exchanges the WebUI token hash and caches the server-side Credential until session expiry', async () => { + const currentTime = { value: 1000 }; + const client = new NapcatWebuiCredentialClient( + createCredentialConfig(currentTime) as never, + ); + mockedAxiosPost.mockResolvedValue({ data: { Credential: 'credential-1' } }); + + await expect(client.getCredential(createGatewaySession())).resolves.toBe( + 'credential-1', + ); + await expect(client.getCredential(createGatewaySession())).resolves.toBe( + 'credential-1', + ); + + expect(mockedAxiosPost).toHaveBeenCalledTimes(1); + expect(mockedAxiosPost).toHaveBeenCalledWith( + `${UPSTREAM_BASE_URL}/api/auth/login`, + { + hash: createHash('sha256') + .update('webui-token-fixture.napcat') + .digest('hex'), + }, + { timeout: 5000 }, + ); + }); + + it('clears cached Credential when the Gateway session is revoked', async () => { + const currentTime = { value: 1000 }; + const client = new NapcatWebuiCredentialClient( + createCredentialConfig(currentTime) as never, + ); + mockedAxiosPost + .mockResolvedValueOnce({ data: { Credential: 'credential-1' } }) + .mockResolvedValueOnce({ data: { Credential: 'credential-2' } }); + + await expect(client.getCredential(createGatewaySession())).resolves.toBe( + 'credential-1', + ); + client.clear(SESSION_ID); + + await expect(client.getCredential(createGatewaySession())).resolves.toBe( + 'credential-2', + ); + expect(mockedAxiosPost).toHaveBeenCalledTimes(2); + }); +}); + +describe('PublicWebuiController bootstrap', () => { + let app: INestApplication; + let ticketService: { redeem: jest.Mock }; + let sessionService: { + markActive: jest.Mock; + requireBootstrapSession: jest.Mock; + }; + + beforeAll(async () => { + ticketService = { + redeem: jest.fn(), + }; + sessionService = { + markActive: jest.fn(), + requireBootstrapSession: jest.fn(), + }; + + const moduleRef = await Test.createTestingModule({ + controllers: [PublicWebuiController], + providers: [ + { + provide: NapcatWebuiGatewaySessionService, + useValue: sessionService, + }, + { + provide: NapcatWebuiGatewayTicketService, + useValue: ticketService, + }, + { + provide: NapcatWebuiProxyService, + useValue: { + handleHttpProxy: jest.fn(), + }, + }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + beforeEach(() => { + ticketService.redeem.mockReset(); + sessionService.markActive.mockReset(); + sessionService.requireBootstrapSession.mockReset(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('redeems a ticket, activates the session, sets an HttpOnly cookie, and redirects to WebUI', async () => { + ticketService.redeem.mockResolvedValue(SESSION_ID); + sessionService.requireBootstrapSession.mockResolvedValue( + createGatewaySession(), + ); + sessionService.markActive.mockResolvedValue( + createGatewaySession({ status: 'active' }), + ); + + const response = await request(app.getHttpServer()) + .get(`/napcat-webui/session/${SESSION_ID}/bootstrap?ticket=ticket-1`) + .expect(HttpStatus.FOUND); + + expect(ticketService.redeem).toHaveBeenCalledWith('ticket-1'); + expect(sessionService.requireBootstrapSession).toHaveBeenCalledWith( + SESSION_ID, + ); + expect(sessionService.markActive).toHaveBeenCalledWith(SESSION_ID); + expect(response.headers.location).toBe( + `/napcat-webui/session/${SESSION_ID}/webui/webui`, + ); + expect(response.headers['set-cookie']).toEqual([ + expect.stringContaining(`Path=/napcat-webui/session/${SESSION_ID}`), + ]); + expect(response.headers['set-cookie'][0]).toContain('HttpOnly'); + }); + + it('rejects expired tickets without activating the session', async () => { + ticketService.redeem.mockResolvedValue(undefined); + + await request(app.getHttpServer()) + .get(`/napcat-webui/session/${SESSION_ID}/bootstrap?ticket=expired`) + .expect(HttpStatus.GONE); + + expect(sessionService.requireBootstrapSession).not.toHaveBeenCalled(); + expect(sessionService.markActive).not.toHaveBeenCalled(); + }); +}); diff --git a/test/apps/napcat-webui-gateway/session-store.spec.ts b/test/apps/napcat-webui-gateway/session-store.spec.ts index 0dccb8b..dfc5180 100644 --- a/test/apps/napcat-webui-gateway/session-store.spec.ts +++ b/test/apps/napcat-webui-gateway/session-store.spec.ts @@ -8,6 +8,7 @@ import { type NapcatWebuiGatewaySession, type NapcatWebuiGatewaySessionStore, } from '../../../src/apps/napcat-webui-gateway/domain/napcat-webui-gateway.types'; +import { NapcatWebuiCredentialClient } from '../../../src/apps/napcat-webui-gateway/infrastructure/napcat-webui-credential.client'; import { NapcatWebuiGatewayRedisStore } from '../../../src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-redis.store'; import { NapcatWebuiGatewayTicketService } from '../../../src/apps/napcat-webui-gateway/infrastructure/session/napcat-webui-gateway-ticket.service'; import { InternalSessionController } from '../../../src/apps/napcat-webui-gateway/presentation/internal-session.controller'; @@ -60,10 +61,7 @@ class MemorySessionStore implements NapcatWebuiGatewaySessionStore { * @param patch - Fields to merge into the stored session. * @returns Updated session. */ - async update( - sessionId: string, - patch: Partial, - ) { + async update(sessionId: string, patch: Partial) { const current = this.sessions.get(sessionId); if (!current) throw new Error(`Missing session ${sessionId}`); const next = { ...current, ...patch }; @@ -160,20 +158,12 @@ class FakeRedis { throw new Error('Unexpected Redis key count'); } - const [ - sessionKey, - sessionId, - patchJson, - userAccountKeyPrefix, - now, - ] = args; + const [sessionKey, sessionId, patchJson, userAccountKeyPrefix, now] = args; const currentJson = this.values.get(sessionKey); if (!currentJson) return [0, 'Gateway session is not active']; const current = JSON.parse(currentJson) as NapcatWebuiGatewaySession; - const patch = JSON.parse( - patchJson, - ) as Partial; + const patch = JSON.parse(patchJson) as Partial; const next = { ...current, ...patch, @@ -235,7 +225,9 @@ class FakeRedis { * @returns Session creation payload. */ function createSessionInput( - override: Partial[0]> = {}, + override: Partial< + Parameters[0] + > = {}, ) { return { accountId: 'account-1', @@ -368,7 +360,9 @@ describe('NapcatWebuiGatewaySessionService', () => { service.create(createSessionInput({ webuiToken: ' ' })), ).rejects.toThrow('Gateway session field webuiToken is required'); await expect( - service.create(createSessionInput({ upstreamBaseUrl: 'ftp://127.0.0.1' })), + service.create( + createSessionInput({ upstreamBaseUrl: 'ftp://127.0.0.1' }), + ), ).rejects.toThrow('Gateway session upstream URL is invalid'); expect(store.sessions.size).toBe(0); }); @@ -425,9 +419,9 @@ describe('NapcatWebuiGatewaySessionService', () => { ); const session = await service.create(createSessionInput()); - await expect(service.requireProxySession(session.sessionId)).rejects.toThrow( - 'Gateway session is not active', - ); + await expect( + service.requireProxySession(session.sessionId), + ).rejects.toThrow('Gateway session is not active'); await expect( service.requireBootstrapSession(session.sessionId), ).resolves.toMatchObject({ @@ -458,9 +452,9 @@ describe('NapcatWebuiGatewaySessionService', () => { currentTime.value = 70_000; - await expect(service.requireProxySession(session.sessionId)).rejects.toThrow( - 'Gateway session is not active', - ); + await expect( + service.requireProxySession(session.sessionId), + ).rejects.toThrow('Gateway session is not active'); expect(await store.find(session.sessionId)).toMatchObject({ status: 'expired', }); @@ -479,9 +473,9 @@ describe('NapcatWebuiGatewaySessionService', () => { sessionId: session.sessionId, }); - await expect(service.requireProxySession(session.sessionId)).rejects.toThrow( - 'Gateway session is not active', - ); + await expect( + service.requireProxySession(session.sessionId), + ).rejects.toThrow('Gateway session is not active'); }); }); @@ -540,9 +534,9 @@ describe('NapcatWebuiGatewayRedisStore', () => { sessionId: second.sessionId, status: 'created', }); - expect(redis.values.get('napcat:webui:user-account:admin-1:account-1')).toBe( - second.sessionId, - ); + expect( + redis.values.get('napcat:webui:user-account:admin-1:account-1'), + ).toBe(second.sessionId); }); it('rejects stale non-terminal updates when the index points at a newer session', async () => { @@ -579,9 +573,11 @@ describe('NapcatWebuiGatewayRedisStore', () => { }), ).rejects.toThrow('Gateway session is not active'); expect(redis.values.get(indexKey)).toBe(second.sessionId); - expect(JSON.parse(redis.values.get(firstSessionKey) || '{}')).toMatchObject({ - status: 'created', - }); + expect(JSON.parse(redis.values.get(firstSessionKey) || '{}')).toMatchObject( + { + status: 'created', + }, + ); }); it('rejects non-terminal updates when the user-account index is missing', async () => { @@ -766,6 +762,9 @@ describe('NapcatWebuiGatewayTicketService', () => { describe('InternalSessionController', () => { let app: INestApplication; + const credentialClient = { + clear: jest.fn(), + }; const store = new MemorySessionStore(); const currentTime = { value: 1000 }; const config = createConfig(currentTime); @@ -780,6 +779,10 @@ describe('InternalSessionController', () => { provide: NapcatWebuiGatewayConfigService, useValue: config, }, + { + provide: NapcatWebuiCredentialClient, + useValue: credentialClient, + }, { provide: NAPCAT_WEBUI_GATEWAY_SESSION_STORE, useValue: store, @@ -796,6 +799,7 @@ describe('InternalSessionController', () => { }); beforeEach(() => { + credentialClient.clear.mockReset(); store.sessions.clear(); currentTime.value = 1000; }); @@ -892,6 +896,7 @@ describe('InternalSessionController', () => { .set('x-kt-gateway-secret', INTERNAL_SECRET) .send({ adminUserId: 'admin-1' }) .expect(HttpStatus.CREATED); + expect(credentialClient.clear).toHaveBeenCalledWith(sessionId); await request(app.getHttpServer()) .post(`/internal/sessions/${sessionId}/heartbeat`) .set('x-kt-gateway-secret', INTERNAL_SECRET)