chromiumfish 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ npx chromiumfish fetch # download + cache the browser build
12
12
  ```javascript
13
13
  import { ChromiumFish } from "chromiumfish";
14
14
 
15
- const browser = await ChromiumFish({ personaSeed: 27182, headless: true });
15
+ const browser = await ChromiumFish({ personaSeed: "alpha-7", headless: true });
16
16
  const page = await browser.newPage();
17
17
  await page.goto("https://abrahamjuliot.github.io/creepjs/");
18
18
  await page.screenshot({ path: "fp.png" });
@@ -26,7 +26,7 @@ await browser.close();
26
26
 
27
27
  | Option | Default | Description |
28
28
  |--------|---------|-------------|
29
- | `personaSeed` | — | Integer seed for a stable, internally-consistent fingerprint persona. |
29
+ | `personaSeed` | — | String id for a stable, internally-consistent fingerprint persona (any string; a number works too). |
30
30
  | `headless` | `true` | Run headless (SwiftShader). |
31
31
  | `proxy` | — | Playwright proxy object, e.g. `{ server, username, password }`. |
32
32
  | `windowSize` | `[1920, 1080]` | Window dimensions (`null` to omit). |
package/dist/fetch.js CHANGED
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
11
11
  import * as https from "node:https";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
- import { browserVersion, releaseBaseUrl } from "./version.js";
14
+ import { assertSafeVersion, browserVersion, releaseBaseUrl } from "./version.js";
15
15
  export function cacheRoot() {
16
16
  const env = process.env.CHROMIUMFISH_CACHE_DIR;
17
17
  if (env)
@@ -35,12 +35,13 @@ export function platformSlug() {
35
35
  throw new Error(`unsupported platform: ${process.platform}`);
36
36
  }
37
37
  function assetName(version) {
38
+ assertSafeVersion(version);
38
39
  const slug = platformSlug();
39
40
  const ext = slug.startsWith("win") ? "zip" : "tar.gz";
40
41
  return `chromiumfish-${version}-${slug}.${ext}`;
41
42
  }
42
43
  export function installDir(version = browserVersion()) {
43
- return path.join(cacheRoot(), version, platformSlug());
44
+ return path.join(cacheRoot(), assertSafeVersion(version), platformSlug());
44
45
  }
45
46
  const BINARY_NAMES = ["chromiumfish", "chrome", "chromiumfish.exe", "chrome.exe", "ChromiumFish"];
46
47
  export function findBinary(root) {
@@ -48,8 +49,15 @@ export function findBinary(root) {
48
49
  return null;
49
50
  for (const name of BINARY_NAMES) {
50
51
  const direct = path.join(root, name);
51
- if (fs.existsSync(direct) && fs.statSync(direct).isFile())
52
- return direct;
52
+ // statSync can throw if the file is removed between existsSync and stat
53
+ // (TOCTOU); treat any stat failure as "not a usable binary here".
54
+ try {
55
+ if (fs.statSync(direct).isFile())
56
+ return direct;
57
+ }
58
+ catch {
59
+ /* keep looking */
60
+ }
53
61
  }
54
62
  const stack = [root];
55
63
  while (stack.length) {
@@ -64,11 +72,15 @@ export function findBinary(root) {
64
72
  }
65
73
  return null;
66
74
  }
75
+ // Idle-timeout (ms) applied to every download/fetch socket. A stalled server
76
+ // (no bytes for this long) aborts instead of hanging the launch forever.
77
+ const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000;
78
+ const FETCH_IDLE_TIMEOUT_MS = 30_000;
67
79
  function download(url, dest) {
68
80
  fs.mkdirSync(path.dirname(dest), { recursive: true });
69
81
  return new Promise((resolve, reject) => {
70
82
  const get = (u) => {
71
- https.get(u, (res) => {
83
+ const req = https.get(u, (res) => {
72
84
  if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
73
85
  res.resume();
74
86
  return get(res.headers.location);
@@ -80,15 +92,31 @@ function download(url, dest) {
80
92
  const total = Number(res.headers["content-length"] || 0);
81
93
  let read = 0;
82
94
  const out = fs.createWriteStream(dest);
95
+ // On any failure: tear down both streams and remove the partial file
96
+ // so a later run doesn't trip over a truncated/corrupt download.
97
+ const fail = (err) => {
98
+ res.destroy();
99
+ out.destroy();
100
+ try {
101
+ fs.rmSync(dest, { force: true });
102
+ }
103
+ catch { /* best effort */ }
104
+ reject(err);
105
+ };
83
106
  res.on("data", (c) => {
84
107
  read += c.length;
85
108
  if (total)
86
109
  process.stderr.write(`\r[chromiumfish] ${Math.floor((read / total) * 100)}%`);
87
110
  });
111
+ res.on("error", fail);
88
112
  res.pipe(out);
89
113
  out.on("finish", () => { process.stderr.write("\n"); out.close(() => resolve()); });
90
- out.on("error", reject);
91
- }).on("error", reject);
114
+ out.on("error", fail);
115
+ });
116
+ req.on("error", reject);
117
+ req.setTimeout(DOWNLOAD_IDLE_TIMEOUT_MS, () => {
118
+ req.destroy(new Error(`download timed out (no data for ${DOWNLOAD_IDLE_TIMEOUT_MS}ms) for ${u}`));
119
+ });
92
120
  };
93
121
  process.stderr.write(`[chromiumfish] downloading ${url}\n`);
94
122
  get(url);
@@ -96,20 +124,27 @@ function download(url, dest) {
96
124
  }
97
125
  function fetchText(url) {
98
126
  return new Promise((resolve, reject) => {
99
- const get = (u) => https.get(u, (res) => {
100
- if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
101
- res.resume();
102
- return get(res.headers.location);
103
- }
104
- if (res.statusCode !== 200) {
105
- res.resume();
106
- return reject(new Error(`HTTP ${res.statusCode}`));
107
- }
108
- let data = "";
109
- res.setEncoding("utf8");
110
- res.on("data", (c) => (data += c));
111
- res.on("end", () => resolve(data));
112
- }).on("error", reject);
127
+ const get = (u) => {
128
+ const req = https.get(u, (res) => {
129
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
130
+ res.resume();
131
+ return get(res.headers.location);
132
+ }
133
+ if (res.statusCode !== 200) {
134
+ res.resume();
135
+ return reject(new Error(`HTTP ${res.statusCode}`));
136
+ }
137
+ let data = "";
138
+ res.setEncoding("utf8");
139
+ res.on("data", (c) => (data += c));
140
+ res.on("end", () => resolve(data));
141
+ res.on("error", reject);
142
+ });
143
+ req.on("error", reject);
144
+ req.setTimeout(FETCH_IDLE_TIMEOUT_MS, () => {
145
+ req.destroy(new Error(`request timed out for ${u}`));
146
+ });
147
+ };
113
148
  get(url);
114
149
  });
115
150
  }
package/dist/ip2tz.js CHANGED
@@ -18,10 +18,12 @@
18
18
  */
19
19
  import { createHash } from "node:crypto";
20
20
  import * as fs from "node:fs";
21
+ import * as http from "node:http";
21
22
  import * as https from "node:https";
22
23
  import * as path from "node:path";
24
+ import * as tls from "node:tls";
23
25
  import { cacheRoot } from "./fetch.js";
24
- import { GEOIP_FALLBACK_VERSION, geoipBaseUrl, geoipLatestManifestUrl, geoipVersion } from "./version.js";
26
+ import { assertSafeVersion, GEOIP_FALLBACK_VERSION, geoipBaseUrl, geoipLatestManifestUrl, geoipVersion, } from "./version.js";
25
27
  const MAGIC = Buffer.from("IP2TZ\x01", "latin1");
26
28
  const V4_REC = 6; // uint32 start + uint16 tz_idx
27
29
  const V6_REC = 18; // 16-byte start + uint16 tz_idx
@@ -89,33 +91,42 @@ export async function resolveVersion(version = geoipVersion(), download = true)
89
91
  return cached?.version || GEOIP_FALLBACK_VERSION;
90
92
  }
91
93
  export function assetName(version = geoipVersion()) {
92
- return `ip2tz-${resolveVersionSync(version)}.bin`;
94
+ return `ip2tz-${assertSafeVersion(resolveVersionSync(version))}.bin`;
93
95
  }
94
96
  export function dbPath(version = geoipVersion()) {
95
- return path.join(geoipDir(), `ip2tz-${resolveVersionSync(version)}.bin`);
97
+ return path.join(geoipDir(), `ip2tz-${assertSafeVersion(resolveVersionSync(version))}.bin`);
96
98
  }
99
+ // Idle-timeout (ms) for the geoip manifest / DB / checksum fetches so a
100
+ // stalled server can't hang resolution or download forever.
101
+ const GET_IDLE_TIMEOUT_MS = 30_000;
97
102
  function get(url) {
98
103
  return new Promise((resolve, reject) => {
99
- const go = (u) => https
100
- .get(u, (res) => {
101
- if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
102
- res.resume();
103
- return go(res.headers.location);
104
- }
105
- if (res.statusCode !== 200) {
106
- res.resume();
107
- return reject(new Error(`HTTP ${res.statusCode}`));
108
- }
109
- const chunks = [];
110
- res.on("data", (c) => chunks.push(c));
111
- res.on("end", () => resolve(Buffer.concat(chunks)));
112
- })
113
- .on("error", reject);
104
+ const go = (u) => {
105
+ const req = https
106
+ .get(u, (res) => {
107
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
108
+ res.resume();
109
+ return go(res.headers.location);
110
+ }
111
+ if (res.statusCode !== 200) {
112
+ res.resume();
113
+ return reject(new Error(`HTTP ${res.statusCode}`));
114
+ }
115
+ const chunks = [];
116
+ res.on("data", (c) => chunks.push(c));
117
+ res.on("end", () => resolve(Buffer.concat(chunks)));
118
+ res.on("error", reject);
119
+ })
120
+ .on("error", reject);
121
+ req.setTimeout(GET_IDLE_TIMEOUT_MS, () => {
122
+ req.destroy(new Error(`request timed out for ${u}`));
123
+ });
124
+ };
114
125
  go(url);
115
126
  });
116
127
  }
117
128
  export async function fetchDb(version = geoipVersion(), force = false) {
118
- const v = await resolveVersion(version); // concrete, e.g. "2026.06"
129
+ const v = assertSafeVersion(await resolveVersion(version)); // concrete, e.g. "2026.06"
119
130
  const dest = path.join(geoipDir(), `ip2tz-${v}.bin`);
120
131
  if (fs.existsSync(dest) && !force)
121
132
  return dest;
@@ -256,28 +267,29 @@ function parseV6(ip) {
256
267
  }
257
268
  return b;
258
269
  }
259
- let cached = null;
270
+ // Keyed by *resolved* concrete version so a later lookup with a different
271
+ // version doesn't silently reuse the first DB loaded.
272
+ const cache = new Map();
260
273
  async function getDb(version = geoipVersion(), download = true) {
261
- if (cached)
262
- return cached;
263
- const v = await resolveVersion(version, download);
274
+ const v = assertSafeVersion(await resolveVersion(version, download));
275
+ const existing = cache.get(v);
276
+ if (existing)
277
+ return existing;
264
278
  let p = path.join(geoipDir(), `ip2tz-${v}.bin`);
265
279
  if (!fs.existsSync(p)) {
266
280
  if (!download)
267
281
  throw new Error("ip2tz DB not installed. Call fetchDb().");
268
282
  p = await fetchDb(v);
269
283
  }
270
- cached = Ip2TzDB.load(p);
271
- return cached;
284
+ const db = Ip2TzDB.load(p);
285
+ cache.set(v, db);
286
+ return db;
272
287
  }
273
288
  export async function lookupTimezone(ip, version = geoipVersion(), download = true) {
274
289
  return (await getDb(version, download)).lookup(ip);
275
290
  }
276
- export function egressIp(proxy, timeoutMs = 8000) {
277
- // Note: proxy support for the probe would require an https-proxy agent; when
278
- // a proxy is configured we currently probe direct and rely on the caller to
279
- // pass an explicit IP if egress differs.
280
- void proxy;
291
+ /** Probe the egress IP directly (no proxy). */
292
+ function egressDirect(timeoutMs) {
281
293
  return new Promise((resolve) => {
282
294
  const req = https.get(EGRESS_PROBE, { headers: { "User-Agent": "chromiumfish" } }, (res) => {
283
295
  let data = "";
@@ -299,6 +311,101 @@ export function egressIp(proxy, timeoutMs = 8000) {
299
311
  });
300
312
  });
301
313
  }
314
+ /**
315
+ * Probe the egress IP *through an HTTP(S) proxy* by CONNECT-tunnelling to the
316
+ * probe host, so the resolved timezone matches the proxy's exit — the whole
317
+ * point of timezone:"auto". Mirrors what the Python SDK gets from urllib's
318
+ * ProxyHandler. Returns null on any failure (never falls back to a direct
319
+ * probe, which would report the machine's real-IP timezone).
320
+ */
321
+ function egressViaProxy(proxy, timeoutMs) {
322
+ return new Promise((resolve) => {
323
+ let pu;
324
+ let tu;
325
+ try {
326
+ pu = new URL(proxy);
327
+ tu = new URL(EGRESS_PROBE);
328
+ }
329
+ catch {
330
+ return resolve(null);
331
+ }
332
+ let settled = false;
333
+ const done = (ip) => {
334
+ if (!settled) {
335
+ settled = true;
336
+ resolve(ip);
337
+ }
338
+ };
339
+ const headers = {};
340
+ if (pu.username) {
341
+ const creds = `${decodeURIComponent(pu.username)}:${decodeURIComponent(pu.password)}`;
342
+ headers["Proxy-Authorization"] = "Basic " + Buffer.from(creds).toString("base64");
343
+ }
344
+ const connectReq = http.request({
345
+ host: pu.hostname,
346
+ port: Number(pu.port) || (pu.protocol === "https:" ? 443 : 80),
347
+ method: "CONNECT",
348
+ path: `${tu.hostname}:443`,
349
+ headers,
350
+ timeout: timeoutMs,
351
+ });
352
+ connectReq.on("connect", (res, socket) => {
353
+ if (res.statusCode !== 200) {
354
+ socket.destroy();
355
+ return done(null);
356
+ }
357
+ const tlsSock = tls.connect({ socket, servername: tu.hostname }, () => {
358
+ // HTTP/1.0 so the response isn't chunk-encoded (simpler to parse).
359
+ tlsSock.write(`GET ${tu.pathname} HTTP/1.0\r\nHost: ${tu.hostname}\r\n` +
360
+ `User-Agent: chromiumfish\r\nAccept: application/json\r\nConnection: close\r\n\r\n`);
361
+ });
362
+ let raw = "";
363
+ tlsSock.setEncoding("utf8");
364
+ tlsSock.setTimeout(timeoutMs, () => {
365
+ tlsSock.destroy();
366
+ done(null);
367
+ });
368
+ tlsSock.on("data", (d) => (raw += d));
369
+ tlsSock.on("end", () => {
370
+ const body = raw.split("\r\n\r\n").slice(1).join("\r\n\r\n");
371
+ const m = body.match(/\{[\s\S]*\}/);
372
+ try {
373
+ done(m ? JSON.parse(m[0]).ip || null : null);
374
+ }
375
+ catch {
376
+ done(null);
377
+ }
378
+ });
379
+ tlsSock.on("error", () => done(null));
380
+ });
381
+ connectReq.on("error", () => done(null));
382
+ connectReq.on("timeout", () => {
383
+ connectReq.destroy();
384
+ done(null);
385
+ });
386
+ connectReq.end();
387
+ });
388
+ }
389
+ export function egressIp(proxy, timeoutMs = 8000) {
390
+ if (proxy) {
391
+ let scheme = "";
392
+ try {
393
+ scheme = new URL(proxy).protocol;
394
+ }
395
+ catch {
396
+ /* invalid proxy URL */
397
+ }
398
+ if (scheme === "http:" || scheme === "https:")
399
+ return egressViaProxy(proxy, timeoutMs);
400
+ // SOCKS / unknown schemes aren't supported for the probe. Return null
401
+ // (leave the timezone unset) rather than probing the direct connection
402
+ // and reporting the machine's real-IP timezone — the incoherence we want
403
+ // to avoid.
404
+ process.stderr.write(`[chromiumfish] egress probe: unsupported proxy scheme '${scheme || proxy}'; skipping timezone resolution\n`);
405
+ return Promise.resolve(null);
406
+ }
407
+ return egressDirect(timeoutMs);
408
+ }
302
409
  export async function resolveTimezone(opts = {}) {
303
410
  const { proxy, version = geoipVersion(), download = true } = opts;
304
411
  let ip = opts.ip;
@@ -6,8 +6,8 @@ import { type Browser, type LaunchOptions } from "playwright-core";
6
6
  */
7
7
  export declare const BASE_ARGS: string[];
8
8
  export interface ChromiumFishOptions extends Omit<LaunchOptions, "executablePath"> {
9
- /** Integer seed for a stable, internally-consistent fingerprint persona. */
10
- personaSeed?: number;
9
+ /** String id for a stable, internally-consistent fingerprint persona. */
10
+ personaSeed?: string;
11
11
  /** Run headless (SwiftShader). Defaults to true. */
12
12
  headless?: boolean;
13
13
  /** Window dimensions; defaults to [1920, 1080]. Pass null to omit. */
@@ -28,6 +28,6 @@ export declare function buildArgs(opts: ChromiumFishOptions): string[];
28
28
  * Launch ChromiumFish and return a standard Playwright `Browser`.
29
29
  *
30
30
  * import { ChromiumFish } from "chromiumfish";
31
- * const browser = await ChromiumFish({ personaSeed: 27182, headless: true });
31
+ * const browser = await ChromiumFish({ personaSeed: "alpha-7", headless: true });
32
32
  */
33
33
  export declare function ChromiumFish(opts?: ChromiumFishOptions): Promise<Browser>;
package/dist/launcher.js CHANGED
@@ -20,8 +20,12 @@ function proxyToUrl(proxy) {
20
20
  return undefined;
21
21
  const { server, username, password } = proxy;
22
22
  if (username && server.includes("://")) {
23
- const [scheme, host] = server.split("://", 2);
24
- return `${scheme}://${username}:${password ?? ""}@${host}`;
23
+ const idx = server.indexOf("://");
24
+ const scheme = server.slice(0, idx);
25
+ const host = server.slice(idx + 3);
26
+ // Percent-encode credentials so a password containing ':' / '@' / '/'
27
+ // can't corrupt the URL; the egress probe decodes them again.
28
+ return `${scheme}://${encodeURIComponent(username)}:${encodeURIComponent(password ?? "")}@${host}`;
25
29
  }
26
30
  return server;
27
31
  }
@@ -40,7 +44,7 @@ export function buildArgs(opts) {
40
44
  * Launch ChromiumFish and return a standard Playwright `Browser`.
41
45
  *
42
46
  * import { ChromiumFish } from "chromiumfish";
43
- * const browser = await ChromiumFish({ personaSeed: 27182, headless: true });
47
+ * const browser = await ChromiumFish({ personaSeed: "alpha-7", headless: true });
44
48
  */
45
49
  export async function ChromiumFish(opts = {}) {
46
50
  const { personaSeed, headless = true, windowSize, version, download = true, timezone, args, ...launch } = opts;
package/dist/version.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * SDK downloads by default; override it with `CHROMIUMFISH_VERSION`.
7
7
  */
8
8
  /** SDK package version (kept in sync with package.json). */
9
- export declare const SDK_VERSION = "0.1.2";
9
+ export declare const SDK_VERSION = "0.1.4";
10
10
  /** Default ChromiumFish browser build to fetch. Matches src/chrome/VERSION. */
11
11
  export declare const DEFAULT_BROWSER_VERSION = "150.0.7844";
12
12
  /** Public repo hosting the release assets. */
@@ -26,6 +26,14 @@ export declare const DEFAULT_GEOIP_VERSION = "latest";
26
26
  * pointer). Bump occasionally so the offline floor stays recent.
27
27
  */
28
28
  export declare const GEOIP_FALLBACK_VERSION = "2026.06";
29
+ /**
30
+ * Reject version strings that aren't a plain build tag. Versions are
31
+ * interpolated into filesystem cache paths and release URLs, so a crafted
32
+ * value like `../../../etc` would escape the cache dir (path traversal).
33
+ * Real tags are digits, dots, and hyphens (e.g. "150.0.7844", "2026.06",
34
+ * "latest").
35
+ */
36
+ export declare function assertSafeVersion(version: string): string;
29
37
  export declare function browserVersion(): string;
30
38
  export declare function releaseBaseUrl(version?: string): string;
31
39
  export declare function geoipVersion(): string;
package/dist/version.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * SDK downloads by default; override it with `CHROMIUMFISH_VERSION`.
7
7
  */
8
8
  /** SDK package version (kept in sync with package.json). */
9
- export const SDK_VERSION = "0.1.2";
9
+ export const SDK_VERSION = "0.1.4";
10
10
  /** Default ChromiumFish browser build to fetch. Matches src/chrome/VERSION. */
11
11
  export const DEFAULT_BROWSER_VERSION = "150.0.7844";
12
12
  /** Public repo hosting the release assets. */
@@ -26,14 +26,27 @@ export const DEFAULT_GEOIP_VERSION = "latest";
26
26
  * pointer). Bump occasionally so the offline floor stays recent.
27
27
  */
28
28
  export const GEOIP_FALLBACK_VERSION = "2026.06";
29
+ /**
30
+ * Reject version strings that aren't a plain build tag. Versions are
31
+ * interpolated into filesystem cache paths and release URLs, so a crafted
32
+ * value like `../../../etc` would escape the cache dir (path traversal).
33
+ * Real tags are digits, dots, and hyphens (e.g. "150.0.7844", "2026.06",
34
+ * "latest").
35
+ */
36
+ export function assertSafeVersion(version) {
37
+ if (!/^[A-Za-z0-9._-]+$/.test(version) || version === "." || version === "..") {
38
+ throw new Error(`invalid version string: ${JSON.stringify(version)}`);
39
+ }
40
+ return version;
41
+ }
29
42
  export function browserVersion() {
30
- return process.env.CHROMIUMFISH_VERSION || DEFAULT_BROWSER_VERSION;
43
+ return assertSafeVersion(process.env.CHROMIUMFISH_VERSION || DEFAULT_BROWSER_VERSION);
31
44
  }
32
45
  export function releaseBaseUrl(version = browserVersion()) {
33
46
  return `https://github.com/${RELEASE_REPO}/releases/download/v${version}`;
34
47
  }
35
48
  export function geoipVersion() {
36
- return process.env.CHROMIUMFISH_GEOIP_VERSION || DEFAULT_GEOIP_VERSION;
49
+ return assertSafeVersion(process.env.CHROMIUMFISH_GEOIP_VERSION || DEFAULT_GEOIP_VERSION);
37
50
  }
38
51
  export function geoipBaseUrl(version = geoipVersion()) {
39
52
  return `https://github.com/${RELEASE_REPO}/releases/download/geoip-${version}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chromiumfish",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Stealth Chromium build with a drop-in Playwright harness — fetches and launches the ChromiumFish browser.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",