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 {
createProxyMiddleware,
fixRequestBody,
type RequestHandler,
} from 'http-proxy-middleware';
import { NapcatWebuiGatewaySessionService } from '../../application/napcat-webui-gateway-session.service';
@ -74,30 +75,33 @@ export function rewriteNapcatLocationHeader(input: RewriteLocationInput) {
const location = input.location.trim();
if (!location) return location;
const fallback = `${gatewayPrefix}/webui`;
if (location.startsWith('//')) {
try {
const upstream = new URL(input.upstreamBaseUrl);
const target = new URL(`${upstream.protocol}${location}`);
return `${gatewayPrefix}${target.pathname}${target.search}${target.hash}`;
return toGatewayRedirectLocation(gatewayPrefix, target.pathname);
} catch {
return `${gatewayPrefix}/webui`;
return fallback;
}
}
if (location.startsWith('/')) {
return `${gatewayPrefix}${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 {
const target = new URL(location);
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 {
return `${gatewayPrefix}/webui`;
return fallback;
}
}
@ -134,6 +138,25 @@ function decodeProxyPath(value: string) {
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()
export class NapcatWebuiProxyService {
/**
@ -227,9 +250,10 @@ export class NapcatWebuiProxyService {
error: (_error, _req, res) => {
this.writeProxyError(res);
},
proxyReq: (proxyReq) => {
proxyReq: (proxyReq, req) => {
proxyReq.removeHeader('cookie');
proxyReq.setHeader('Authorization', `Bearer ${credential}`);
fixRequestBody(proxyReq, req);
},
proxyReqWs: (proxyReq) => {
proxyReq.removeHeader('cookie');

View File

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

View File

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

View File

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

View File

@ -1,4 +1,6 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { HttpStatus, type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import axios from 'axios';
@ -19,6 +21,7 @@ jest.mock('axios');
const SESSION_ID = 'session-1';
const UPSTREAM_BASE_URL = 'http://127.0.0.1:6099';
const repoRoot = resolve(__dirname, '../../..');
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', () => {
it('rejects absolute URL proxy paths', () => {
expect(() => sanitizeGatewayProxyPath('https://evil.test/api')).toThrow(
@ -111,9 +123,7 @@ describe('Napcat WebUI proxy rewrite helpers', () => {
sessionId: SESSION_ID,
upstreamBaseUrl: UPSTREAM_BASE_URL,
}),
).toBe(
`/napcat-webui/session/${SESSION_ID}/webui/webui/login?next=/`,
);
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
expect(
rewriteNapcatLocationHeader({
location: 'http://container.internal:6099/webui/login',
@ -130,9 +140,24 @@ describe('Napcat WebUI proxy rewrite helpers', () => {
sessionId: SESSION_ID,
upstreamBaseUrl: UPSTREAM_BASE_URL,
}),
).toBe(
`/napcat-webui/session/${SESSION_ID}/webui/webui/login?next=/`,
);
).toBe(`/napcat-webui/session/${SESSION_ID}/webui/webui/login`);
});
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', () => {
@ -157,6 +182,20 @@ describe('Napcat WebUI proxy rewrite helpers', () => {
'*': `/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', () => {

View File

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