fix: 拒绝插件重定向HTTP错误状态

This commit is contained in:
sunlei 2026-06-19 18:32:11 +08:00
parent fc12e608a6
commit 87522a4326
4 changed files with 32 additions and 7 deletions

View File

@ -361,7 +361,7 @@ function getHttpRequestOptions(
function getRedirectRequestOptions( function getRedirectRequestOptions(
args: Record<string, unknown>, args: Record<string, unknown>,
): QqbotPluginResolveRedirectRequest { ): QqbotPluginResolveRedirectRequest {
const candidate = args.input || args.options || args; const candidate = args.input ?? args.options ?? args;
if (!isRecord(candidate)) { if (!isRecord(candidate)) {
throw new Error('Plugin host redirect options must be an object'); throw new Error('Plugin host redirect options must be an object');
} }

View File

@ -16,6 +16,7 @@ export type QqbotPluginHttpClientRequest = {
export type QqbotPluginResolveRedirectRequest = { export type QqbotPluginResolveRedirectRequest = {
context?: string; context?: string;
failureMessage?: (statusCode: number) => string;
headers?: Record<string, string>; headers?: Record<string, string>;
maxRedirects?: number; maxRedirects?: number;
timeoutMessage?: string; timeoutMessage?: string;
@ -194,7 +195,7 @@ export class QqbotPluginHttpClientService {
* Requests one URL and returns its redirect Location after the response body is drained. * Requests one URL and returns its redirect Location after the response body is drained.
* @param url - Validated HTTP(S) URL to request. * @param url - Validated HTTP(S) URL to request.
* @param input - Headers and timeout options shared across the redirect chain. * @param input - Headers and timeout options shared across the redirect chain.
* @returns Redirect Location header when the response is 3xx, otherwise `undefined`. * @returns Redirect Location header when the response is 3xx, otherwise `undefined`; rejects for HTTP error statuses.
*/ */
private requestRedirectLocation( private requestRedirectLocation(
url: URL, url: URL,
@ -222,6 +223,16 @@ export class QqbotPluginHttpClientService {
response.on('error', reject); response.on('error', reject);
response.on('end', () => { response.on('end', () => {
if (statusCode >= 400) {
reject(
createPluginHttpError(
input.failureMessage?.(statusCode) ||
`${context}请求失败:${statusCode}`,
statusCode,
),
);
return;
}
if ( if (
statusCode >= 300 && statusCode >= 300 &&
statusCode < 400 && statusCode < 400 &&

View File

@ -132,11 +132,11 @@ describe('QQBot plugin host bridge', () => {
const options = { const options = {
maxRedirects: 3, maxRedirects: 3,
timeoutMs: 1000, timeoutMs: 1000,
url: 'https://b23.tv/abc123', url: 'https://short.example/abc123',
}; };
httpClient.resolveRedirect.mockResolvedValue({ httpClient.resolveRedirect.mockResolvedValue({
finalUrl: 'https://www.bilibili.com/video/BV1xx411c7mD', finalUrl: 'https://target.example/video/123',
redirects: ['https://www.bilibili.com/video/BV1xx411c7mD'], redirects: ['https://target.example/video/123'],
}); });
await expect( await expect(
@ -148,8 +148,8 @@ describe('QQBot plugin host bridge', () => {
).resolves.toEqual({ ).resolves.toEqual({
ok: true, ok: true,
value: { value: {
finalUrl: 'https://www.bilibili.com/video/BV1xx411c7mD', finalUrl: 'https://target.example/video/123',
redirects: ['https://www.bilibili.com/video/BV1xx411c7mD'], redirects: ['https://target.example/video/123'],
}, },
}); });
expect(httpClient.resolveRedirect).toHaveBeenCalledWith(options); expect(httpClient.resolveRedirect).toHaveBeenCalledWith(options);

View File

@ -23,6 +23,11 @@ describe('QQBot plugin HTTP client redirect resolver', () => {
response.end(); response.end();
return; return;
} }
if (request.url === '/missing') {
response.writeHead(404, { 'Content-Type': 'text/plain' });
response.end('missing');
return;
}
response.writeHead(200, { 'Content-Type': 'text/plain' }); response.writeHead(200, { 'Content-Type': 'text/plain' });
response.end('ok'); response.end('ok');
}); });
@ -67,4 +72,13 @@ describe('QQBot plugin HTTP client redirect resolver', () => {
}), }),
).rejects.toThrow('插件 HTTP 重定向仅支持 http/https'); ).rejects.toThrow('插件 HTTP 重定向仅支持 http/https');
}); });
it('rejects HTTP error statuses while resolving redirects', async () => {
await expect(
new QqbotPluginHttpClientService().resolveRedirect({
timeoutMs: 1000,
url: `${baseUrl}/missing`,
}),
).rejects.toMatchObject({ statusCode: 404 });
});
}); });