fix: 加固NapCat WebUI网关代理边界
This commit is contained in:
parent
eb162ddba6
commit
a8a1c3503f
@ -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');
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -248,8 +248,6 @@ export class QqbotNapcatWebuiGatewayService {
|
||||
selfId: account.selfId,
|
||||
},
|
||||
container: {
|
||||
id: runtime.id,
|
||||
name: runtime.name,
|
||||
webuiStatus,
|
||||
},
|
||||
expiresAt: gatewaySession.expiresAt,
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user