@liarjs/checks
v0.4.0
Published
52 browser-fingerprint consistency rules as pure functions: score a JS fingerprint against itself and against what the TLS/HTTP layer saw. Bot and anti-detect browser detection, no dependencies.
Maintainers
Readme
@liarjs/checks
52 browser-fingerprint consistency rules as pure functions. Give it a fingerprint (and, optionally, what the edge saw on the wire) and get back a 0-100 score plus every check that fired. No network, no state, no dependencies beyond the types.
npm i @liarjs/checksimport { collect } from '@liarjs/collect';
import { computeVerdict, emptyServerData } from '@liarjs/checks';
const client = await collect();
// JS layer only
const offline = computeVerdict(client);
// cross-layer: pass what the edge observed for the same request
const server = { ...emptyServerData(), ...(await fetch('/api/net.json').then((r) => r.json())) };
const verdict = computeVerdict(client, server);
verdict.score; // 0..100, where 100 means nothing contradicted anything else
verdict.label; // 'Trustworthy' | 'Suspicious' | 'Likely spoofed / bot'
verdict.checks; // [{ id, title, status: 'ok'|'warn'|'bad'|'info', detail, weight }]The two halves
44 checks need only the browser. 8 compare the JS story against the network layer and are listed in EDGE_ONLY_CHECKS:
import { EDGE_ONLY_CHECKS } from '@liarjs/checks';
// ['ua-http-js', 'platform', 'tz', 'lang', 'webrtc-ip', 'http-proto', 'tls-ver', 'cf-bot']Call computeVerdict(client) without a server argument and those eight are skipped, not failed, so you get an honest JS-layer score instead of eight false positives.
JS-layer checks
| id | what it catches | max −pts |
|---|---|---|
| webdriver | navigator.webdriver is set | 40 |
| native-integrity | core APIs are not genuine [native code]: the JS-mask vs engine-patch line | 35 |
| headless-ua | HeadlessChrome token in the UA | 30 |
| api-formfactor | the exposed Web API set is a desktop build's under a mobile claim, or the reverse | 22 |
| gpu-triad | WebGL GPU ↔ WebGPU adapter.info identity | 22 |
| worker-consistency | a Web Worker reports different UA / languages / hardware / timezone / GPU / canvas | 20 |
| font-metrics | the claimed OS's own font families have no metrics: named, but no font file behind the name | 20 |
| canvas-lie | identical canvas draws read back differently, or OffscreenCanvas disagrees | 18 |
| webgl-lie | the same WebGL scene rendered twice reads back different pixels | 18 |
| gl-ext-formfactor | mobile-only compressed-texture extensions under a desktop renderer string | 16 |
| font-generic | sans-serif, serif and monospace collapse onto one width | 16 |
| window-screen | the window is larger than the screen it says it is on | 15 |
| webgl-pair | WebGL and WebGL2 name different GPUs on one machine | 15 |
| uach-ver | UA-CH fullVersionList ≠ the version in the UA string | 15 |
| plugins-ver | plugin/mimeType face doesn't match the claimed Chrome version | 15 |
| perm-notif | Notification.permission ≠ permissions.query() state | 15 |
| tz-offset | Intl timezone implies a different offset than getTimezoneOffset() | 15 |
| os-fonts | installed fonts describe a different OS than the UA claims | 14 |
| api-mobile-missing | claims Android but exposes none of Android's own interfaces | 14 |
| webgpu-features | WebGPU offers ASTC/ETC2 while the GPU is presented as desktop | 12 |
| webrtc-candidates | ICE gathering finished with zero candidates, not even an mDNS host | 12 |
| ua-mobile | mobile hints contradict the UA or maxTouchPoints | 12 |
| domrect-lie | getBoundingClientRect unstable across reads | 12 |
| chrome-object | the UA claims Chrome but window.chrome is missing | 12 |
| langs-empty | navigator.languages is empty | 10 |
| gpu-age | GPU too old to be real for a current Chrome (MAX_TEXTURE_SIZE) | 10 |
| webgpu-empty | WebGPU returned an adapter but adapter.info is blank | 10 |
| headless-viewport | outerHeight === innerHeight: the window reports no browser UI | 10 |
| font-methods | the measureText and layout font paths disagree | 10 |
| screen-extended | screen.isExtended is true on a device presenting as a phone | 10 |
| api-notification | new Notification() constructs on an Android claim, where it must throw | 10 |
| audio-params | DynamicsCompressor factory defaults are off spec | 8 |
| heap-os | jsHeapSizeLimit is the value another platform's V8 build reports | 8 |
| voice-locale | speech-synthesis voice language ≠ locale (leaks host OS language) | 8 |
| touch-pointer | maxTouchPoints contradicts (any-pointer: coarse) | 8 |
| codecs | claims Chrome but cannot play H.264 (a plain Chromium build) | 6 |
| browser-age | Chrome several majors behind stable: a pinned build, not an updating user | 6 |
| cjk-fonts | CJK fonts installed on a non-CJK locale (leaks host region) | 6 |
| colordepth | screen.colorDepth ≠ 24 | 6 |
| lang-base | navigator.languages lacks a bare base tag (en) | 6 |
| tz-dst | the reported January/July offsets don't match the zone's DST rule | 6 |
| storage-quota | StorageManager quota below 1 GB | 4 |
| webrtc-mdns | host ICE candidates expose raw local addresses instead of .local | 4 |
| conn-rtt | navigator.connection.rtt === 0 | 3 |
Cross-layer checks
| id | what it catches | max −pts |
|---|---|---|
| ua-http-js | User-Agent header ≠ navigator.userAgent | 25 |
| cf-bot | the edge already classifies you as a known bot | 25 |
| platform | Sec-CH-UA-Platform ≠ navigator.platform | 15 |
| tz | IP-derived timezone ≠ browser timezone | 12 |
| webrtc-ip | WebRTC-exposed public IP ≠ the IP the connection came from | 10 |
| lang | Accept-Language ≠ navigator.languages[0] | 8 |
| http-proto | modern Chrome that negotiated HTTP/1.1 | 6 |
| tls-ver | modern Chrome that negotiated TLS < 1.3 | 6 |
Scoring
The score is 100 − the sum of the weights of the checks whose status is warn or bad; info and ok cost nothing. Labels: ≥ 85 Trustworthy, ≥ 60 Suspicious, below that Likely spoofed / bot.
Weights are deliberately blunt. A JS-level override of a native function (native-integrity, −35) is a categorically bigger tell than a 4-point storage-quota oddity, and the ordering matters more than the exact arithmetic.
Where ServerData comes from
Anything that can see the request before the browser does. On Cloudflare Workers that is request.cf plus headers: IP, ASN, colo, IP-derived timezone, HTTP protocol, TLS version/cipher, ClientHello length and extension/cipher hashes, Sec-CH-UA-*, Accept-Language, verified-bot category. The reference implementation is a ~60-line Worker in the repo; the hosted one is https://liarjs.dev/api/net.json.
Scoring server-side
The rules are pure and have no DOM dependency, so they run just as well on samples your own site collected with @liarjs/collect in send mode:
app.post('/fp', (req, res) => {
const verdict = computeVerdict(req.body, { ...emptyServerData(), ip: req.ip, ipTimezone: geo(req.ip) });
store(req.body.meta.fpHash, verdict.score, verdict.checks.filter((c) => c.status === 'bad'));
res.sendStatus(200);
});MIT © liarjs.dev
