fix: detect and report Cloudflare challenge blocks

This commit is contained in:
WebStreamr 2025-05-29 17:17:17 +00:00
parent 62098f8e84
commit 0638ce0a8c
No known key found for this signature in database
9 changed files with 91 additions and 13 deletions

View file

@ -0,0 +1 @@
export class CloudflareChallengeError extends Error {}

View file

@ -1 +1,2 @@
export * from './CloudflareChallengeError';
export * from './NotFoundError';

View file

@ -7,7 +7,7 @@ import { DoodStream } from './DoodStream';
import { Dropload } from './Dropload';
import { SuperVideo } from './SuperVideo';
import { ExternalUrl } from './ExternalUrl';
import { NotFoundError } from '../error';
import { CloudflareChallengeError, NotFoundError } from '../error';
export class ExtractorRegistry {
private readonly logger: winston.Logger;
@ -43,6 +43,20 @@ export class ExtractorRegistry {
return undefined;
}
/* istanbul ignore next */
if (error instanceof CloudflareChallengeError) {
this.logger.warn(`${extractor.id}: Request was blocked by Cloudflare challenge.`, ctx);
return {
url: url,
isExternal: true,
cloudflareChallenge: true,
label: url.host,
sourceId: '',
meta,
};
}
this.logger.warn(`${extractor.id} error: ${error}`, ctx);
return undefined;
}

View file

@ -22,8 +22,9 @@ export interface Meta {
export interface UrlResult {
url: URL;
isExternal?: boolean;
cloudflareChallenge?: boolean;
label: string;
sourceId: string;
sourceId?: string;
meta: Meta;
requestHeaders?: Record<string, string>;
}

View file

@ -3,7 +3,7 @@ import axios, { AxiosError } from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
import { Fetcher } from './Fetcher';
import { Context } from '../types';
import { NotFoundError } from '../error';
import { CloudflareChallengeError, NotFoundError } from '../error';
const axiosMock = new AxiosMockAdapter(axios);
@ -125,6 +125,12 @@ describe('fetch', () => {
await expect(fetcher.text(ctx, new URL('https://some-url.test/'))).rejects.toBeInstanceOf(NotFoundError);
});
test('converts Cloudflare challenge block to custom CloudflareChallengeError', async () => {
axiosMock.onGet().reply(403, undefined, { 'cf-mitigated': 'challenge' });
await expect(fetcher.text(ctx, new URL('https://some-url.test/'))).rejects.toBeInstanceOf(CloudflareChallengeError);
});
test('passes through other errors', async () => {
axiosMock.onGet().networkError();

View file

@ -3,7 +3,7 @@ import TTLCache from '@isaacs/ttlcache';
import UserAgent from 'user-agents';
import winston from 'winston';
import { Context } from '../types';
import { NotFoundError } from '../error';
import { CloudflareChallengeError, NotFoundError } from '../error';
export class Fetcher {
private readonly axios: AxiosInstance;
@ -69,9 +69,16 @@ export class Fetcher {
try {
return await callable();
} catch (error) {
if (isAxiosError(error) && error.response?.status === 404) {
throw new NotFoundError();
if (isAxiosError(error)) {
if (error.response?.status === 404) {
throw new NotFoundError();
}
if (error.response?.headers['cf-mitigated'] === 'challenge') {
throw new CloudflareChallengeError();
}
}
throw error;
}
};

View file

@ -3,7 +3,7 @@ import { ExtractorRegistry } from '../extractor';
import { StreamResolver } from './StreamResolver';
import { Handler, MeineCloud, MostraGuarda } from '../handler';
import { Fetcher } from './Fetcher';
import { Context } from '../types';
import { Context, CountryCode, UrlResult } from '../types';
import { NotFoundError } from '../error';
jest.mock('../utils/Fetcher');
@ -57,6 +57,34 @@ describe('resolve', () => {
expect(streams).toMatchSnapshot();
});
test('adds Cloudflare blocked info', async () => {
class MockHandler implements Handler {
readonly id = 'mockhandler';
readonly label = 'MockHandler';
readonly contentTypes = ['movie'];
readonly countryCodes: CountryCode[] = ['de'];
readonly handle = async (): Promise<(UrlResult | undefined)[]> => {
return [{
url: new URL('https://example.com'),
isExternal: true,
cloudflareChallenge: true,
label: 'example.com',
sourceId: '',
meta: {
countryCode: 'de',
},
}];
};
}
const streams = await streamResolver.resolve(ctx, [new MockHandler()], 'movie', 'tt11655566');
expect(streams).toMatchSnapshot();
});
test('ignores not found errors', async () => {
const mockHandler: Handler = {
id: 'mockhandler',

View file

@ -85,14 +85,24 @@ export class StreamResolver {
titleSecondLineEntries.push(`💾 ${bytes.format(urlResult.meta.bytes, { unitSeparator: ' ' })}`);
}
titleSecondLineEntries.push(`⚙️ ${urlResult.label}`);
const title = [urlResult.meta.title ?? '', titleSecondLineEntries.join(' ')].join('\n');
const titleLines = [];
if (urlResult.meta.title) {
titleLines.push(urlResult.meta.title);
}
titleLines.push(titleSecondLineEntries.join(' '));
if (urlResult.cloudflareChallenge) {
titleLines.push('Request was blocked by Cloudflare.');
}
const title = titleLines.join('\n');
return {
[urlResult.isExternal ? 'externalUrl' : 'url']: urlResult.url.href,
name,
title,
behaviorHints: {
bingeGroup: `webstreamr-${urlResult.sourceId}`,
...(urlResult.sourceId && { bingeGroup: `webstreamr-${urlResult.sourceId}` }),
...(urlResult.requestHeaders !== undefined && {
notWebReady: true,
proxyHeaders: { request: urlResult.requestHeaders },

View file

@ -1,5 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`resolve adds Cloudflare blocked info 1`] = `
[
{
"behaviorHints": {},
"externalUrl": "https://example.com/",
"name": "WebStreamr 🇩🇪 external",
"title": "⚙️ example.com
Request was blocked by Cloudflare.",
},
]
`;
exports[`resolve returns sorted results 1`] = `
[
{
@ -78,8 +90,7 @@ exports[`resolve returns sorted results 1`] = `
},
"externalUrl": "https://mixdrop.ag/e/3nzwveprim63or6",
"name": "WebStreamr 🇩🇪 external",
"title": "
⚙️ mixdrop.ag",
"title": "⚙️ mixdrop.ag",
},
{
"behaviorHints": {
@ -87,8 +98,7 @@ exports[`resolve returns sorted results 1`] = `
},
"externalUrl": "https://mixdrop.ag/e/vk196d6xfzwwo1",
"name": "WebStreamr 🇮🇹 external",
"title": "
⚙️ mixdrop.ag",
"title": "⚙️ mixdrop.ag",
},
]
`;