fix: 支持NapCat WebUI单点登录
This commit is contained in:
parent
7e52972830
commit
89f68f6db0
@ -13,6 +13,7 @@ import type { NapcatWebuiGatewaySession } from '../../domain/napcat-webui-gatewa
|
||||
import { NapcatWebuiCredentialClient } from '../napcat-webui-credential.client';
|
||||
|
||||
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 STRIPPED_UPSTREAM_HEADERS = [
|
||||
'authorization',
|
||||
@ -42,6 +43,12 @@ type RewriteTextResponseInput = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
type RewriteWebSocketSearchInput = {
|
||||
credential: string;
|
||||
search: string;
|
||||
upstreamPath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes a Gateway route tail into a safe upstream pathname.
|
||||
* @param input - Route parameter from Nest/path-to-regexp.
|
||||
@ -133,11 +140,78 @@ export function rewriteNapcatTextResponse(input: RewriteTextResponseInput) {
|
||||
input.sessionId,
|
||||
)}/webui`;
|
||||
|
||||
return input.body.replace(
|
||||
const rewritten = input.body.replace(
|
||||
/(^|[\s"'`(=,:])\/(webui|api|File|plugin)(?=\/|[?#"'`)]|$)/g,
|
||||
(_match, leader: string, root: string) =>
|
||||
`${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);
|
||||
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);
|
||||
proxy.upgrade(req, socket, head);
|
||||
} catch {
|
||||
|
||||
@ -42,6 +42,81 @@ describe('NapcatWebuiProxyService response rewriting', () => {
|
||||
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', () => {
|
||||
const shouldRewriteNapcatTextResponse = (
|
||||
proxyModule as {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user