mirror of
https://github.com/only-cli/oc.git
synced 2026-09-15 10:40:56 +02:00
Merge pull request #41 from only-cli/fix/impers-unsupported-fingerprint
fix: fall back to firefox, then plain fetch, when impers refuses a fingerprint
This commit is contained in:
+31
-7
@@ -555,9 +555,24 @@ function captureSetCookie(jar, url, res) {
|
||||
jar.storeFromResponse(url, getSetCookieHeaders(res));
|
||||
}
|
||||
|
||||
async function viaImpers(impers, target, jar) {
|
||||
/**
|
||||
* Fetch a page through impers, downgrading identity when one is refused.
|
||||
* Exported so the downgrade chain can be proven against a fake impers; the
|
||||
* real entry point is fetchPage.
|
||||
* @param {any} impers - the impers module (or a stand-in with a get method)
|
||||
* @param {string} target
|
||||
* @param {object} [jar]
|
||||
* @returns {Promise<{url: string, html: string, status: number, via: string}>}
|
||||
*/
|
||||
export async function viaImpers(impers, target, jar) {
|
||||
// Some sites (Reddit) 403 the chrome fingerprint but accept firefox, so a
|
||||
// blocked first attempt gets one cheap retry with a second identity.
|
||||
// blocked first attempt gets one cheap retry with a second identity. An
|
||||
// ImpersonateError is the same story one layer down: impers resolves the
|
||||
// 'chrome' alias to its newest fingerprint, but the native library it loads
|
||||
// can be an older system copy of libcurl-impersonate that predates that
|
||||
// fingerprint and refuses it before any request leaves. Firefox aliases to
|
||||
// an older target that such a library usually still knows, and when both
|
||||
// identities are refused the plain fetch transport still gets the page.
|
||||
const asking = (impersonate) => (url) =>
|
||||
impers.get(url, {
|
||||
impersonate,
|
||||
@@ -566,14 +581,23 @@ async function viaImpers(impers, target, jar) {
|
||||
headers: jarHeaders(jar, url, {}),
|
||||
});
|
||||
const onResponse = (url, res) => captureSetCookie(jar, url, res);
|
||||
const attempt = async (impersonate) => {
|
||||
try {
|
||||
const { res } = await followRedirects(asking(impersonate), target, { onResponse });
|
||||
return { res, status: res.status ?? res.statusCode ?? 0 };
|
||||
} catch (err) {
|
||||
if (err?.name !== 'ImpersonateError') throw err;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
let via = 'impers:chrome';
|
||||
let { res } = await followRedirects(asking('chrome'), target, { onResponse });
|
||||
let status = res.status ?? res.statusCode ?? 0;
|
||||
if (status >= 400) {
|
||||
let got = await attempt('chrome');
|
||||
if (!got || got.status >= 400) {
|
||||
via = 'impers:firefox';
|
||||
({ res } = await followRedirects(asking('firefox'), target, { onResponse }));
|
||||
status = res.status ?? res.statusCode ?? 0;
|
||||
got = (await attempt('firefox')) ?? got;
|
||||
}
|
||||
if (!got) return viaFetch(target, jar);
|
||||
const { res, status } = got;
|
||||
if (status >= 400) throw new Error(`fetch failed: ${status} for ${target}`);
|
||||
assertReadableType(res.headers.get('content-type'));
|
||||
assertBodySize(Number(res.headers.get('content-length')) || 0, target);
|
||||
|
||||
+71
-1
@@ -5,7 +5,7 @@ import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import tls from 'node:tls';
|
||||
|
||||
const { fetchPage, followRedirects, resolveProxy, proxyGet } = await import('../src/fetch.js');
|
||||
const { fetchPage, followRedirects, resolveProxy, proxyGet, viaImpers } = await import('../src/fetch.js');
|
||||
|
||||
const BLOCKED_MESSAGE = 'blocked: private or internal URL';
|
||||
|
||||
@@ -727,3 +727,73 @@ test('the proxy transport counts the body against the same cap', async () => {
|
||||
proxy.close();
|
||||
}
|
||||
});
|
||||
|
||||
// A stand-in for the impers module whose get() either answers with a minimal
|
||||
// 200 page or refuses the identity the way impers does when the loaded native
|
||||
// library does not know the fingerprint an alias resolves to: it throws an
|
||||
// ImpersonateError before any request leaves the process (issue #40, where a
|
||||
// stale system libcurl-impersonate predating chrome150 broke oc outright).
|
||||
const fakeImpers = (refuse, html = '<html>ok</html>') => {
|
||||
const identities = [];
|
||||
const get = (url, opts) => {
|
||||
identities.push(opts.impersonate);
|
||||
if (refuse.includes(opts.impersonate)) {
|
||||
const err = new Error(`Impersonating ${opts.impersonate} is not supported`);
|
||||
err.name = 'ImpersonateError';
|
||||
return Promise.reject(err);
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
headers: new Map([['content-type', 'text/html']]),
|
||||
text: () => Promise.resolve(html),
|
||||
url,
|
||||
});
|
||||
};
|
||||
return { get, identities };
|
||||
};
|
||||
|
||||
test('a refused chrome fingerprint falls back to the firefox identity', () => withoutProxyEnv(async () => {
|
||||
const impers = fakeImpers(['chrome']);
|
||||
const page = await viaImpers(impers, 'https://public.example/page');
|
||||
assert.deepEqual(impers.identities, ['chrome', 'firefox']);
|
||||
assert.equal(page.via, 'impers:firefox');
|
||||
assert.equal(page.status, 200);
|
||||
assert.equal(page.html, '<html>ok</html>');
|
||||
}));
|
||||
|
||||
test('when both identities are refused the page still arrives via plain fetch', async () => {
|
||||
// The proxy is only here to give viaFetch somewhere real to land without
|
||||
// leaving the machine, same shape as the HTTP_PROXY wiring test above.
|
||||
const seen = [];
|
||||
const proxy = http.createServer((req, res) => {
|
||||
seen.push(req.url);
|
||||
res.writeHead(200, { 'content-type': 'text/html' });
|
||||
res.end('<html><title>via fetch</title></html>');
|
||||
});
|
||||
const port = await listen(proxy);
|
||||
const prev = Object.fromEntries(PROXY_ENV_KEYS.map((k) => [k, process.env[k]]));
|
||||
for (const k of PROXY_ENV_KEYS) delete process.env[k];
|
||||
process.env.HTTP_PROXY = `http://127.0.0.1:${port}`;
|
||||
try {
|
||||
const impers = fakeImpers(['chrome', 'firefox']);
|
||||
const page = await viaImpers(impers, 'http://1.1.1.1/page');
|
||||
assert.deepEqual(impers.identities, ['chrome', 'firefox']);
|
||||
assert.equal(page.via, 'fetch');
|
||||
assert.equal(page.status, 200);
|
||||
assert.equal(page.html, '<html><title>via fetch</title></html>');
|
||||
assert.deepEqual(seen, ['http://1.1.1.1/page']);
|
||||
} finally {
|
||||
proxy.close();
|
||||
for (const k of PROXY_ENV_KEYS) {
|
||||
if (prev[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = prev[k];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('only an ImpersonateError downgrades; other impers failures propagate', () => withoutProxyEnv(async () => {
|
||||
const impers = {
|
||||
get: () => Promise.reject(new Error('connection reset')),
|
||||
};
|
||||
await assert.rejects(() => viaImpers(impers, 'https://public.example/page'), /connection reset/);
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user