fix: 支持NapCat WebUI单点登录

This commit is contained in:
sunlei 2026-06-24 18:56:15 +08:00
parent 7e52972830
commit 89f68f6db0
2 changed files with 155 additions and 2 deletions

View File

@ -13,6 +13,7 @@ import type { NapcatWebuiGatewaySession } from '../../domain/napcat-webui-gatewa
import { NapcatWebuiCredentialClient } from '../napcat-webui-credential.client'; import { NapcatWebuiCredentialClient } from '../napcat-webui-credential.client';
const GATEWAY_WEBUI_PREFIX = '/napcat-webui/session'; const GATEWAY_WEBUI_PREFIX = '/napcat-webui/session';
const GATEWAY_BROWSER_TOKEN_PREFIX = 'kt-napcat-webui-gateway:';
const TEXT_REWRITE_EXTENSIONS = ['.css', '.html', '.js', '.mjs'] as const; const TEXT_REWRITE_EXTENSIONS = ['.css', '.html', '.js', '.mjs'] as const;
const STRIPPED_UPSTREAM_HEADERS = [ const STRIPPED_UPSTREAM_HEADERS = [
'authorization', 'authorization',
@ -42,6 +43,12 @@ type RewriteTextResponseInput = {
sessionId: string; sessionId: string;
}; };
type RewriteWebSocketSearchInput = {
credential: string;
search: string;
upstreamPath: string;
};
/** /**
* Normalizes a Gateway route tail into a safe upstream pathname. * Normalizes a Gateway route tail into a safe upstream pathname.
* @param input - Route parameter from Nest/path-to-regexp. * @param input - Route parameter from Nest/path-to-regexp.
@ -133,11 +140,78 @@ export function rewriteNapcatTextResponse(input: RewriteTextResponseInput) {
input.sessionId, input.sessionId,
)}/webui`; )}/webui`;
return input.body.replace( const rewritten = input.body.replace(
/(^|[\s"'`(=,:])\/(webui|api|File|plugin)(?=\/|[?#"'`)]|$)/g, /(^|[\s"'`(=,:])\/(webui|api|File|plugin)(?=\/|[?#"'`)]|$)/g,
(_match, leader: string, root: string) => (_match, leader: string, root: string) =>
`${leader}${gatewayWebuiPrefix}/${root}`, `${leader}${gatewayWebuiPrefix}/${root}`,
); );
return injectGatewayBrowserToken({
body: rewritten,
sessionId: input.sessionId,
});
}
/**
* Rewrites WebSocket query strings that NapCat authenticates with query tokens instead of headers.
* @param input - Upstream path, browser search string, and server-side Credential for the active session.
* @returns Search string safe to send upstream without preserving browser-supplied terminal tokens.
*/
export function rewriteNapcatWebSocketSearch(
input: RewriteWebSocketSearchInput,
) {
if (input.upstreamPath !== '/api/ws/terminal') {
return input.search;
}
const params = new URLSearchParams(input.search);
params.set('token', input.credential);
const serialized = params.toString();
return serialized ? `?${serialized}` : '';
}
/**
* Injects a non-secret local token into NapCat WebUI HTML so React enters authenticated routes while Gateway handles real auth server-side.
* @param input - HTML body candidate and Gateway session id used to build the browser-only token.
* @returns HTML with a one-time SSO bootstrap script, or unchanged non-HTML text.
*/
function injectGatewayBrowserToken(input: RewriteTextResponseInput) {
if (
input.body.includes('data-kt-napcat-webui-gateway-sso') ||
!/<html[\s>]/i.test(input.body)
) {
return input.body;
}
const script = buildGatewayBrowserTokenScript(input.sessionId);
if (/<head[\s>]/i.test(input.body)) {
return input.body.replace(/<head(\s[^>]*)?>/i, (headTag) => {
return `${headTag}${script}`;
});
}
return input.body.replace(
/<script\b/i,
`${script}<script`,
);
}
/**
* Builds the SSO bootstrap script that writes only a Gateway-scoped dummy token, never NapCat WebUI token or Credential.
* @param sessionId - Gateway session id already visible in the iframe URL.
* @returns Inline script placed before NapCat's module entry runs.
*/
function buildGatewayBrowserTokenScript(sessionId: string) {
const browserToken = `${GATEWAY_BROWSER_TOKEN_PREFIX}${sessionId}`;
const storedTokenLiteral = JSON.stringify(JSON.stringify(browserToken));
return [
'<script data-kt-napcat-webui-gateway-sso>',
'try{',
`localStorage.setItem("token",${storedTokenLiteral});`,
'}catch(_error){}',
'</script>',
].join('');
} }
/** /**
@ -273,7 +347,11 @@ export class NapcatWebuiProxyService {
); );
const credential = await this.credentialClient.getCredential(session); const credential = await this.credentialClient.getCredential(session);
this.stripBrowserHeaders(req); this.stripBrowserHeaders(req);
req.url = `${match.proxyPath}${match.search}`; req.url = `${match.proxyPath}${rewriteNapcatWebSocketSearch({
credential,
search: match.search,
upstreamPath: match.proxyPath,
})}`;
const proxy = this.createProxy(session, credential); const proxy = this.createProxy(session, credential);
proxy.upgrade(req, socket, head); proxy.upgrade(req, socket, head);
} catch { } catch {

View File

@ -42,6 +42,81 @@ describe('NapcatWebuiProxyService response rewriting', () => {
expect(rewritten).not.toContain('"/api"'); expect(rewritten).not.toContain('"/api"');
}); });
it('injects a Gateway-only browser token so NapCat WebUI can enter authenticated routes without leaking secrets', () => {
const rewriteNapcatTextResponse = (
proxyModule as {
rewriteNapcatTextResponse?: (input: {
body: string;
sessionId: string;
}) => string;
}
).rewriteNapcatTextResponse;
expect(typeof rewriteNapcatTextResponse).toBe('function');
const rewritten = rewriteNapcatTextResponse?.({
body: [
'<!doctype html><html><head>',
'<script type="module" src="/webui/assets/index.js"></script>',
'</head><body></body></html>',
].join(''),
sessionId: 'sess_1',
});
expect(rewritten).toContain('data-kt-napcat-webui-gateway-sso');
expect(rewritten).toContain('localStorage.setItem("token"');
expect(rewritten).toContain('kt-napcat-webui-gateway:sess_1');
expect(rewritten).not.toContain('Credential');
expect(rewritten).not.toContain('webui-token-fixture');
});
it('keeps non-HTML text responses free of Gateway browser-token injection', () => {
const rewriteNapcatTextResponse = (
proxyModule as {
rewriteNapcatTextResponse?: (input: {
body: string;
sessionId: string;
}) => string;
}
).rewriteNapcatTextResponse;
const rewritten = rewriteNapcatTextResponse?.({
body: 'const baseURL="/api";',
sessionId: 'sess_1',
});
expect(rewritten).not.toContain('localStorage.setItem("token"');
});
it('replaces browser terminal WebSocket token query with the server-side Credential', () => {
const rewriteNapcatWebSocketSearch = (
proxyModule as {
rewriteNapcatWebSocketSearch?: (input: {
credential: string;
search: string;
upstreamPath: string;
}) => string;
}
).rewriteNapcatWebSocketSearch;
expect(typeof rewriteNapcatWebSocketSearch).toBe('function');
expect(
rewriteNapcatWebSocketSearch?.({
credential: 'credential-1',
search: '?id=terminal-1&token=browser-dummy',
upstreamPath: '/api/ws/terminal',
}),
).toBe('?id=terminal-1&token=credential-1');
expect(
rewriteNapcatWebSocketSearch?.({
credential: 'credential-1',
search: '?id=other&token=browser-dummy',
upstreamPath: '/api/ws/other',
}),
).toBe('?id=other&token=browser-dummy');
});
it('does not buffer NapCat API or SSE responses for text rewriting', () => { it('does not buffer NapCat API or SSE responses for text rewriting', () => {
const shouldRewriteNapcatTextResponse = ( const shouldRewriteNapcatTextResponse = (
proxyModule as { proxyModule as {