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 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');
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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'],
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user