fix: 加固NapCat WebUI网关代理边界

This commit is contained in:
sunlei 2026-06-24 16:30:12 +08:00
parent eb162ddba6
commit a8a1c3503f
6 changed files with 84 additions and 30 deletions

View File

@ -4,6 +4,7 @@ import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express'; import type { NextFunction, Request, Response } from 'express';
import { import {
createProxyMiddleware, createProxyMiddleware,
fixRequestBody,
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';
@ -74,30 +75,33 @@ export function rewriteNapcatLocationHeader(input: RewriteLocationInput) {
const location = input.location.trim(); const location = input.location.trim();
if (!location) return location; if (!location) return location;
const fallback = `${gatewayPrefix}/webui`;
if (location.startsWith('//')) { if (location.startsWith('//')) {
try { try {
const upstream = new URL(input.upstreamBaseUrl); const upstream = new URL(input.upstreamBaseUrl);
const target = new URL(`${upstream.protocol}${location}`); const target = new URL(`${upstream.protocol}${location}`);
return `${gatewayPrefix}${target.pathname}${target.search}${target.hash}`; return toGatewayRedirectLocation(gatewayPrefix, target.pathname);
} catch { } catch {
return `${gatewayPrefix}/webui`; return fallback;
} }
} }
if (location.startsWith('/')) {
return `${gatewayPrefix}${location}`;
}
if (!/^[a-z][a-z0-9+.-]*:/i.test(location)) { if (!/^[a-z][a-z0-9+.-]*:/i.test(location)) {
return `${gatewayPrefix}/${location.replace(/^\/+/, '')}`; try {
const target = new URL(location, 'http://gateway.local');
return toGatewayRedirectLocation(gatewayPrefix, target.pathname);
} catch {
return fallback;
}
} }
try { try {
const target = new URL(location); const target = new URL(location);
if (target.protocol === 'http:' || target.protocol === 'https:') { if (target.protocol === 'http:' || target.protocol === 'https:') {
return `${gatewayPrefix}${target.pathname}${target.search}${target.hash}`; return toGatewayRedirectLocation(gatewayPrefix, target.pathname);
} }
return `${gatewayPrefix}/webui`; return fallback;
} catch { } catch {
return `${gatewayPrefix}/webui`; return fallback;
} }
} }
@ -134,6 +138,25 @@ function decodeProxyPath(value: string) {
throw new BadRequestException('Gateway proxy path is invalid'); throw new BadRequestException('Gateway proxy path is invalid');
} }
/**
* Builds a browser redirect that keeps only the upstream pathname.
* @param gatewayPrefix - Public Gateway session route prefix for one Admin session.
* @param upstreamPathname - Upstream redirect pathname after URL parsing removed search/hash.
* @returns Gateway-scoped Location header, falling back to WebUI root for unsafe paths.
*/
function toGatewayRedirectLocation(
gatewayPrefix: string,
upstreamPathname: string,
) {
try {
return `${gatewayPrefix}${sanitizeGatewayProxyPath(
upstreamPathname || '/webui',
)}`;
} catch {
return `${gatewayPrefix}/webui`;
}
}
@Injectable() @Injectable()
export class NapcatWebuiProxyService { export class NapcatWebuiProxyService {
/** /**
@ -227,9 +250,10 @@ export class NapcatWebuiProxyService {
error: (_error, _req, res) => { error: (_error, _req, res) => {
this.writeProxyError(res); this.writeProxyError(res);
}, },
proxyReq: (proxyReq) => { proxyReq: (proxyReq, req) => {
proxyReq.removeHeader('cookie'); proxyReq.removeHeader('cookie');
proxyReq.setHeader('Authorization', `Bearer ${credential}`); proxyReq.setHeader('Authorization', `Bearer ${credential}`);
fixRequestBody(proxyReq, req);
}, },
proxyReqWs: (proxyReq) => { proxyReqWs: (proxyReq) => {
proxyReq.removeHeader('cookie'); proxyReq.removeHeader('cookie');

View File

@ -10,11 +10,12 @@ import { NapcatWebuiGatewayModule } from './napcat-webui-gateway.module';
*/ */
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(NapcatWebuiGatewayModule, { const app = await NestFactory.create(NapcatWebuiGatewayModule, {
bodyParser: false,
bufferLogs: true, bufferLogs: true,
}); });
app.useLogger(app.get(Logger)); app.useLogger(app.get(Logger));
app.use(json({ limit: '64kb' })); app.use('/internal', json({ limit: '64kb' }));
app.use(urlencoded({ extended: true, limit: '64kb' })); app.use('/internal', urlencoded({ extended: true, limit: '64kb' }));
await app.listen(app.get(NapcatWebuiGatewayConfigService).port()); await app.listen(app.get(NapcatWebuiGatewayConfigService).port());
app.get(NapcatWebuiProxyService).bindWebSocketUpgrade(app.getHttpServer()); app.get(NapcatWebuiProxyService).bindWebSocketUpgrade(app.getHttpServer());
} }

View File

@ -248,8 +248,6 @@ export class QqbotNapcatWebuiGatewayService {
selfId: account.selfId, selfId: account.selfId,
}, },
container: { container: {
id: runtime.id,
name: runtime.name,
webuiStatus, webuiStatus,
}, },
expiresAt: gatewaySession.expiresAt, expiresAt: gatewaySession.expiresAt,

View File

@ -18,12 +18,6 @@ export class QqbotNapcatWebuiSessionAccountDto {
} }
export class QqbotNapcatWebuiSessionContainerDto { export class QqbotNapcatWebuiSessionContainerDto {
@ApiProperty({ description: 'NapCat container id.' })
id: string;
@ApiProperty({ description: 'NapCat container name.' })
name: string;
@ApiProperty({ @ApiProperty({
description: 'Browser-safe WebUI availability status.', description: 'Browser-safe WebUI availability status.',
enum: ['offline', 'online', 'unknown'], enum: ['offline', 'online', 'unknown'],

View File

@ -1,4 +1,6 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { HttpStatus, type INestApplication } from '@nestjs/common'; import { HttpStatus, type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import axios from 'axios'; import axios from 'axios';
@ -19,6 +21,7 @@ jest.mock('axios');
const SESSION_ID = 'session-1'; const SESSION_ID = 'session-1';
const UPSTREAM_BASE_URL = 'http://127.0.0.1:6099'; const UPSTREAM_BASE_URL = 'http://127.0.0.1:6099';
const repoRoot = resolve(__dirname, '../../..');
const mockedAxiosPost = axios.post as jest.Mock; const mockedAxiosPost = axios.post as jest.Mock;
/** /**
@ -57,6 +60,15 @@ function createCredentialConfig(currentTime: { value: number }) {
}; };
} }
/**
* Reads source files from the API repository for proxy bootstrap boundary checks.
* @param relativePath - Repository-relative TypeScript source path.
* @returns File text used by structural regression assertions.
*/
function readApiSource(relativePath: string) {
return readFileSync(resolve(repoRoot, relativePath), 'utf8');
}
describe('Napcat WebUI proxy rewrite helpers', () => { describe('Napcat WebUI proxy rewrite helpers', () => {
it('rejects absolute URL proxy paths', () => { it('rejects absolute URL proxy paths', () => {
expect(() => sanitizeGatewayProxyPath('https://evil.test/api')).toThrow( expect(() => sanitizeGatewayProxyPath('https://evil.test/api')).toThrow(
@ -111,9 +123,7 @@ describe('Napcat WebUI proxy rewrite helpers', () => {
sessionId: SESSION_ID, sessionId: SESSION_ID,
upstreamBaseUrl: UPSTREAM_BASE_URL, upstreamBaseUrl: UPSTREAM_BASE_URL,
}), }),
).toBe( ).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
`/napcat-webui/session/${SESSION_ID}/webui/webui/login?next=/`,
);
expect( expect(
rewriteNapcatLocationHeader({ rewriteNapcatLocationHeader({
location: 'http://container.internal:6099/webui/login', location: 'http://container.internal:6099/webui/login',
@ -130,9 +140,24 @@ describe('Napcat WebUI proxy rewrite helpers', () => {
sessionId: SESSION_ID, sessionId: SESSION_ID,
upstreamBaseUrl: UPSTREAM_BASE_URL, upstreamBaseUrl: UPSTREAM_BASE_URL,
}), }),
).toBe( ).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
`/napcat-webui/session/${SESSION_ID}/webui/webui/login?next=/`, });
);
it('drops upstream redirect query and hash fragments before returning browser locations', () => {
expect(
rewriteNapcatLocationHeader({
location: '/webui/login?Credential=secret#token',
sessionId: SESSION_ID,
upstreamBaseUrl: UPSTREAM_BASE_URL,
}),
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
expect(
rewriteNapcatLocationHeader({
location: 'webui/login?ticket=secret#hash',
sessionId: SESSION_ID,
upstreamBaseUrl: UPSTREAM_BASE_URL,
}),
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
}); });
it('fails closed for malformed absolute redirects', () => { it('fails closed for malformed absolute redirects', () => {
@ -157,6 +182,20 @@ describe('Napcat WebUI proxy rewrite helpers', () => {
'*': `/napcat-webui/session/${SESSION_ID}/webui`, '*': `/napcat-webui/session/${SESSION_ID}/webui`,
}); });
}); });
it('keeps request bodies available for the public proxy route', () => {
const mainSource = readApiSource('src/apps/napcat-webui-gateway/main.ts');
const proxySource = readApiSource(
'src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts',
);
expect(mainSource).toContain("app.use('/internal', json");
expect(mainSource).toContain("app.use('/internal', urlencoded");
expect(mainSource).toContain('bodyParser: false');
expect(mainSource).not.toContain('app.use(json({ limit');
expect(mainSource).not.toContain('app.use(urlencoded({ extended');
expect(proxySource).toContain('fixRequestBody');
});
}); });
describe('NapcatWebuiCredentialClient', () => { describe('NapcatWebuiCredentialClient', () => {

View File

@ -97,8 +97,6 @@ const createSafeSessionResponse = () => ({
selfId: '1914728559', selfId: '1914728559',
}, },
container: { container: {
id: '2001',
name: 'kt-qqbot-napcat-1914728559',
webuiStatus: 'online', webuiStatus: 'online',
}, },
expiresAt: EXPIRES_AT_FIXTURE, expiresAt: EXPIRES_AT_FIXTURE,
@ -394,8 +392,6 @@ describe('QqbotNapcatWebuiGatewayService', () => {
selfId: '1914728559', selfId: '1914728559',
}, },
container: { container: {
id: '2001',
name: 'kt-qqbot-napcat-1914728559',
webuiStatus: 'online', webuiStatus: 'online',
}, },
expiresAt: EXPIRES_AT_FIXTURE, expiresAt: EXPIRES_AT_FIXTURE,
@ -416,6 +412,8 @@ describe('QqbotNapcatWebuiGatewayService', () => {
}); });
expect(serialized).not.toContain(WEBUI_TOKEN_FIXTURE); expect(serialized).not.toContain(WEBUI_TOKEN_FIXTURE);
expect(serialized).not.toContain('Credential'); expect(serialized).not.toContain('Credential');
expect(serialized).not.toContain('2001');
expect(serialized).not.toContain('kt-qqbot-napcat-1914728559');
expect(serialized).not.toContain('172.18.0.23'); expect(serialized).not.toContain('172.18.0.23');
expect(serialized).not.toContain('6099'); expect(serialized).not.toContain('6099');
expect(serialized).not.toContain(INTERNAL_SECRET_FIXTURE); expect(serialized).not.toContain(INTERNAL_SECRET_FIXTURE);