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';
|
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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user