fix: 修复NapCat WebUI资源路径代理

This commit is contained in:
sunlei 2026-06-24 18:24:28 +08:00
parent e88bf7449c
commit f46cc5cb3b
2 changed files with 150 additions and 11 deletions

View File

@ -5,6 +5,7 @@ import type { NextFunction, Request, Response } from 'express';
import { import {
createProxyMiddleware, createProxyMiddleware,
fixRequestBody, fixRequestBody,
responseInterceptor,
type RequestHandler, type RequestHandler,
} from 'http-proxy-middleware'; } from 'http-proxy-middleware';
import { NapcatWebuiGatewaySessionService } from '../../application/napcat-webui-gateway-session.service'; import { NapcatWebuiGatewaySessionService } from '../../application/napcat-webui-gateway-session.service';
@ -12,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 TEXT_REWRITE_EXTENSIONS = ['.css', '.html', '.js', '.mjs'] as const;
const STRIPPED_UPSTREAM_HEADERS = [ const STRIPPED_UPSTREAM_HEADERS = [
'authorization', 'authorization',
'cookie', 'cookie',
@ -35,6 +37,11 @@ type CookiePathRewriteInput = {
sessionId: string; sessionId: string;
}; };
type RewriteTextResponseInput = {
body: string;
sessionId: 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.
@ -116,6 +123,23 @@ export function buildGatewayCookiePathRewrite(input: CookiePathRewriteInput) {
}; };
} }
/**
* Rewrites absolute NapCat WebUI browser paths so assets, APIs, and plugin resources stay under the active Gateway session.
* @param input - Text response body from NapCat plus the Gateway session id that owns the browser lifecycle.
* @returns Body text whose absolute NapCat root paths point back through the Gateway session prefix.
*/
export function rewriteNapcatTextResponse(input: RewriteTextResponseInput) {
const gatewayWebuiPrefix = `${GATEWAY_WEBUI_PREFIX}/${encodeURIComponent(
input.sessionId,
)}/webui`;
return input.body.replace(
/(^|[\s"'`(=,:])\/(webui|api|File|plugin)(?=\/|[?#"'`)]|$)/g,
(_match, leader: string, root: string) =>
`${leader}${gatewayWebuiPrefix}/${root}`,
);
}
/** /**
* Decodes path text until stable so nested encoded traversal cannot pass through. * Decodes path text until stable so nested encoded traversal cannot pass through.
* @param value - Raw route path text. * @param value - Raw route path text.
@ -157,6 +181,28 @@ function toGatewayRedirectLocation(
} }
} }
/**
* Checks whether a proxied upstream path may contain browser-executable absolute paths that need Gateway prefix rewriting.
* @param upstreamPath - Sanitized upstream pathname with an optional query string.
* @returns Whether the response should be buffered for text rewriting.
*/
export function shouldRewriteNapcatTextResponse(upstreamPath: string) {
const pathname = new URL(upstreamPath, 'http://gateway.local').pathname;
const filename = pathname.split('/').pop() || '';
const extensionIndex = filename.lastIndexOf('.');
const extension =
extensionIndex >= 0 ? filename.slice(extensionIndex).toLowerCase() : '';
if (TEXT_REWRITE_EXTENSIONS.includes(extension as never)) {
return true;
}
return (
pathname === '/webui' ||
(pathname.startsWith('/webui/') && extensionIndex < 0)
);
}
@Injectable() @Injectable()
export class NapcatWebuiProxyService { export class NapcatWebuiProxyService {
/** /**
@ -190,7 +236,11 @@ export class NapcatWebuiProxyService {
this.stripBrowserHeaders(req); this.stripBrowserHeaders(req);
req.url = upstreamPath; req.url = upstreamPath;
const proxy = this.createProxy(session, credential); const proxy = this.createProxy(
session,
credential,
shouldRewriteNapcatTextResponse(upstreamPath),
);
return proxy(req, res, next); return proxy(req, res, next);
} }
@ -240,6 +290,7 @@ export class NapcatWebuiProxyService {
private createProxy( private createProxy(
session: NapcatWebuiGatewaySession, session: NapcatWebuiGatewaySession,
credential: string, credential: string,
rewriteTextResponse = false,
): RequestHandler<Request, Response, NextFunction> { ): RequestHandler<Request, Response, NextFunction> {
return createProxyMiddleware<Request, Response, NextFunction>({ return createProxyMiddleware<Request, Response, NextFunction>({
changeOrigin: true, changeOrigin: true,
@ -259,23 +310,44 @@ export class NapcatWebuiProxyService {
proxyReq.removeHeader('cookie'); proxyReq.removeHeader('cookie');
proxyReq.setHeader('Authorization', `Bearer ${credential}`); proxyReq.setHeader('Authorization', `Bearer ${credential}`);
}, },
proxyRes: (proxyRes) => { proxyRes: rewriteTextResponse
const location = proxyRes.headers.location; ? responseInterceptor(async (responseBuffer, proxyRes) => {
if (typeof location === 'string') { this.rewriteLocationHeader(proxyRes.headers, session);
proxyRes.headers.location = rewriteNapcatLocationHeader({ return rewriteNapcatTextResponse({
location, body: responseBuffer.toString('utf8'),
sessionId: session.sessionId, sessionId: session.sessionId,
upstreamBaseUrl: session.upstreamBaseUrl, });
}); })
} : (proxyRes) => {
}, this.rewriteLocationHeader(proxyRes.headers, session);
},
}, },
secure: false, secure: false,
selfHandleResponse: rewriteTextResponse,
target: session.upstreamBaseUrl, target: session.upstreamBaseUrl,
ws: true, ws: true,
}); });
} }
/**
* Rewrites one upstream Location header in-place so browser redirects remain inside the Gateway route.
* @param headers - Upstream response headers exposed by HPM.
* @param session - Active Gateway session whose public prefix should own redirects.
*/
private rewriteLocationHeader(
headers: Record<string, number | string | string[] | undefined>,
session: NapcatWebuiGatewaySession,
) {
const location = headers.location;
if (typeof location === 'string') {
headers.location = rewriteNapcatLocationHeader({
location,
sessionId: session.sessionId,
upstreamBaseUrl: session.upstreamBaseUrl,
});
}
}
/** /**
* Builds the upstream URL path while preserving the original query string. * Builds the upstream URL path while preserving the original query string.
* @param proxyPath - Gateway route tail to sanitize. * @param proxyPath - Gateway route tail to sanitize.

View File

@ -0,0 +1,67 @@
import * as proxyModule from '../../../../src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service';
describe('NapcatWebuiProxyService response rewriting', () => {
it('keeps NapCat WebUI absolute resource and API paths inside the Gateway session', () => {
const rewriteNapcatTextResponse = (
proxyModule as {
rewriteNapcatTextResponse?: (input: {
body: string;
sessionId: string;
}) => string;
}
).rewriteNapcatTextResponse;
expect(typeof rewriteNapcatTextResponse).toBe('function');
const rewritten = rewriteNapcatTextResponse?.({
body: [
'<script type="module" src="/webui/assets/index.js"></script>',
'<link rel="stylesheet" href="/webui/assets/index.css">',
'<script>const baseURL="/api"; const file="/File/font/upload/webui";</script>',
'url("/webui/fonts/CustomFont.woff")',
].join(''),
sessionId: 'sess_1',
});
expect(rewritten).toContain(
'src="/napcat-webui/session/sess_1/webui/webui/assets/index.js"',
);
expect(rewritten).toContain(
'href="/napcat-webui/session/sess_1/webui/webui/assets/index.css"',
);
expect(rewritten).toContain(
'baseURL="/napcat-webui/session/sess_1/webui/api"',
);
expect(rewritten).toContain(
'file="/napcat-webui/session/sess_1/webui/File/font/upload/webui"',
);
expect(rewritten).toContain(
'url("/napcat-webui/session/sess_1/webui/webui/fonts/CustomFont.woff")',
);
expect(rewritten).not.toContain('"/webui/assets');
expect(rewritten).not.toContain('"/api"');
});
it('does not buffer NapCat API or SSE responses for text rewriting', () => {
const shouldRewriteNapcatTextResponse = (
proxyModule as {
shouldRewriteNapcatTextResponse?: (upstreamPath: string) => boolean;
}
).shouldRewriteNapcatTextResponse;
expect(typeof shouldRewriteNapcatTextResponse).toBe('function');
expect(shouldRewriteNapcatTextResponse?.('/api/Log/GetLogRealTime')).toBe(
false,
);
expect(
shouldRewriteNapcatTextResponse?.('/api/base/GetSysStatusRealTime'),
).toBe(false);
expect(shouldRewriteNapcatTextResponse?.('/webui')).toBe(true);
expect(shouldRewriteNapcatTextResponse?.('/webui/assets/index.js')).toBe(
true,
);
expect(
shouldRewriteNapcatTextResponse?.('/webui/fonts/CustomFont.woff'),
).toBe(false);
});
});