fix: fall back to firefox, then plain fetch, when impers refuses a fingerprint

impers resolves the chrome alias to its newest fingerprint (chrome150 as
of impers 0.1.1), but the native library it loads can be an older system
copy of libcurl-impersonate found before its own pinned download. Such a
copy refuses the fingerprint with an ImpersonateError before any request
leaves, and oc died on the spot with "Impersonating chrome150 is not
supported" (#40).

A refused identity now downgrades the same way a 403 already did: chrome
falls back to firefox, and when both are refused the plain fetch
transport still gets the page. Any other impers failure propagates
unchanged.
This commit is contained in:
only-cli
2026-08-31 09:11:36 -04:00
parent 65322bc5b6
commit b02dce55af
2 changed files with 102 additions and 8 deletions
+31 -7
View File
@@ -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
View File
@@ -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/);
}));