@stopnetic/js 0.1.2 → 0.1.3
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 +29 -0
- package/dist/bot-protection.js +51 -0
- package/dist/browser-check.js +55 -0
- package/dist/guard.d.ts +15 -0
- package/dist/guard.js +109 -9
- package/dist/server.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# @stopnetic/js - JavaScript and TypeScript website guard
|
|
2
2
|
|
|
3
|
+
## First-party automation mode (unreleased)
|
|
4
|
+
|
|
5
|
+
Repository server option `botProtection: 'monitor' | 'block'` defaults to `'off'`.
|
|
6
|
+
Environment-based server helpers also support `STOPNETIC_BOT_PROTECTION`.
|
|
7
|
+
This requires the matching service and a new SDK release after 0.1.2. Client
|
|
8
|
+
automation evidence can be hidden; stealth agents may still pass. Public routes
|
|
9
|
+
stay public. No external detection provider is used.
|
|
10
|
+
See [configuration, tests and limits](../../docs/bot-protection.md).
|
|
11
|
+
|
|
12
|
+
Prepared protocol 2 adds browser-report consistency, fresh server-verified SHA-256
|
|
13
|
+
work and a per-binding verification budget. Modern secure-context Web Crypto is
|
|
14
|
+
required for block mode. Fully concealed browsers doing valid work may still
|
|
15
|
+
pass; monitor records evidence without these additional denials.
|
|
16
|
+
|
|
17
|
+
Additional prepared hardening observes non-native webdriver getter replacements.
|
|
18
|
+
For Vercel-hosted server guards, optional `botClientSource: 'vercel'` adds shared
|
|
19
|
+
network budgets that survive cookie clearing and SDK worker changes. Use monitor
|
|
20
|
+
first. It needs actual trusted Vercel ingress and the matching service; missing
|
|
21
|
+
metadata withholds private access. The raw IP remains on the customer server;
|
|
22
|
+
a daily, site-scoped HMAC key is used only for short-lived counters, not events.
|
|
23
|
+
Server helpers accept `STOPNETIC_BOT_CLIENT_SOURCE=vercel`. Default source is `none`; no customer database or external detector is required.
|
|
24
|
+
Native-looking low-volume stealth and network rotation can still pass. See the
|
|
25
|
+
linked protection guide for thresholds, shared-network risks and test results.
|
|
26
|
+
|
|
27
|
+
Prepared `requireApprovedAccount: true` plus a server-side authorizeVisitor
|
|
28
|
+
callback can additionally require the customer's currently approved account for
|
|
29
|
+
every protected request. This is an integration contract, not a login/passkey
|
|
30
|
+
service. See [approved access](../../docs/approved-access.md).
|
|
31
|
+
|
|
3
32
|
## Verification screen update (unreleased)
|
|
4
33
|
|
|
5
34
|
The shared guard now prepares a centered Stopnetic-branded verification screen,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
|
|
4
|
+
// Coarse, spoofable observations. No raw user-agent, IP or fingerprint leaves
|
|
5
|
+
// the installation. An empty list is never evidence of a human visitor.
|
|
6
|
+
/** @param {Request} request @param {boolean | null} [webdriver] @param {boolean} [inconsistent] @param {boolean} [tampered] */
|
|
7
|
+
export function automationSignals(request, webdriver = null, inconsistent = false, tampered = false) {
|
|
8
|
+
const ua = (request.headers.get('user-agent') || '').slice(0, 1024);
|
|
9
|
+
const signals = [];
|
|
10
|
+
if (/bot|crawler|spider/i.test(ua)) signals.push('ua_bot');
|
|
11
|
+
if (/\b(?:curl|wget|python|httpclient|node-fetch|undici|go-http-client|postmanruntime)\b/i.test(ua)) signals.push('ua_script');
|
|
12
|
+
if (/headless/i.test(ua)) signals.push('ua_headless');
|
|
13
|
+
if (webdriver === true) signals.push('webdriver');
|
|
14
|
+
if (inconsistent) signals.push('webdriver_inconsistent');
|
|
15
|
+
if (tampered) signals.push('webdriver_tampered');
|
|
16
|
+
return signals;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sameBotControl(actual, expected) {
|
|
20
|
+
return actual?.version === 2 && actual.mode === expected.mode
|
|
21
|
+
&& Array.isArray(actual.signals) && actual.signals.length === expected.signals.length
|
|
22
|
+
&& actual.signals.every((signal, index) => signal === expected.signals[index]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Trust only an explicitly configured Vercel ingress. Never fall back to
|
|
26
|
+
// arbitrary X-Forwarded-For, country, cookies or browser-supplied identifiers.
|
|
27
|
+
export function botClientContext(request, source, secret, scope) {
|
|
28
|
+
if (source === 'none') return {};
|
|
29
|
+
let network = null;
|
|
30
|
+
if (process.env.VERCEL === '1') {
|
|
31
|
+
const raw = (request.headers.get('x-vercel-forwarded-for') || '').trim();
|
|
32
|
+
const family = raw.length <= 45 && !raw.includes('%') ? isIP(raw) : 0;
|
|
33
|
+
if (family === 4) network = 'v4:' + raw;
|
|
34
|
+
if (family === 6) {
|
|
35
|
+
const canonical = new URL(`http://[${raw}]/`).hostname.slice(1, -1);
|
|
36
|
+
const [left, right] = canonical.split('::');
|
|
37
|
+
const head = left ? left.split(':') : [];
|
|
38
|
+
const tail = right ? right.split(':') : [];
|
|
39
|
+
const groups = right !== undefined ? [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail] : head;
|
|
40
|
+
const words = groups.map(value => parseInt(value, 16));
|
|
41
|
+
// IPv4-mapped IPv6 must share its IPv4 budget; ordinary IPv6 groups use
|
|
42
|
+
// /64 so rotating addresses inside one network cannot reset the budget.
|
|
43
|
+
network = words.slice(0, 5).every(value => value === 0) && words[5] === 65535
|
|
44
|
+
? 'v4:' + [words[6] >> 8, words[6] & 255, words[7] >> 8, words[7] & 255].join('.')
|
|
45
|
+
: 'v6:' + words.slice(0, 4).map(value => value.toString(16).padStart(4, '0')).join(':');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const epoch = Math.floor(Date.now() / 86400000);
|
|
49
|
+
return { bot_client: { version: 1, source, key: network === null ? null
|
|
50
|
+
: createHmac('sha256', secret).update(`stopnetic-network-v1:${scope}:${epoch}:${network}`).digest('hex') } };
|
|
51
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const botWorkChallenge = { version: 1, algorithm: 'sha256', difficulty: 12, max_nonce: 1_048_575 };
|
|
2
|
+
|
|
3
|
+
export function validBotWork(value) {
|
|
4
|
+
return value && Object.keys(value).length === 4
|
|
5
|
+
&& Object.entries(botWorkChallenge).every(([key, expected]) => value[key] === expected);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// This function is serialized into the nonce-authorized challenge script.
|
|
9
|
+
// These observations can be forged; server-verified work is a separate check.
|
|
10
|
+
export function collectBrowserEvidence() {
|
|
11
|
+
let webdriver = null;
|
|
12
|
+
let prototypeValue = null;
|
|
13
|
+
let webdriverTampered = false;
|
|
14
|
+
try { if (typeof navigator.webdriver === 'boolean') webdriver = navigator.webdriver; } catch { /* Unavailable, not a bot verdict. */ }
|
|
15
|
+
try {
|
|
16
|
+
let prototype = Object.getPrototypeOf(navigator);
|
|
17
|
+
for (let depth = 0; prototype && depth < 4; depth++, prototype = Object.getPrototypeOf(prototype)) {
|
|
18
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'webdriver');
|
|
19
|
+
if (typeof descriptor?.get === 'function') {
|
|
20
|
+
// A replaced getter is an observation, not proof of automation.
|
|
21
|
+
const source = Function.prototype.toString.call(descriptor.get);
|
|
22
|
+
const value = descriptor.get.call(navigator);
|
|
23
|
+
if (typeof value === 'boolean') {
|
|
24
|
+
prototypeValue = value;
|
|
25
|
+
webdriverTampered = webdriver !== null && !/\{\s*\[native code\]\s*\}/.test(source);
|
|
26
|
+
}
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} catch { /* Privacy tooling and unsupported APIs are not denial evidence. */ }
|
|
31
|
+
return { webdriver, webdriverInconsistent: webdriver !== null && prototypeValue !== null && webdriver !== prototypeValue, webdriverTampered };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A bounded, transparent work factor, not proof of humanity. No custom crypto,
|
|
35
|
+
// third-party script, persistent fingerprint or hidden background worker.
|
|
36
|
+
export async function solveBrowserWork(challengeId, signal) {
|
|
37
|
+
const encoder = new TextEncoder();
|
|
38
|
+
const started = performance.now();
|
|
39
|
+
for (let first = 0; first <= 1_048_575; first += 32) {
|
|
40
|
+
if (signal.aborted || performance.now() - started > 8000) return null;
|
|
41
|
+
const values = await Promise.all(Array.from({ length: 32 }, async (_, offset) => {
|
|
42
|
+
const nonce = first + offset;
|
|
43
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(`stopnetic-work-v1:${challengeId}:${nonce}`)));
|
|
44
|
+
return digest[0] === 0 && (digest[1] & 240) === 0 ? nonce : null;
|
|
45
|
+
}));
|
|
46
|
+
const result = values.find(value => value !== null);
|
|
47
|
+
if (result !== undefined) return result;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function verificationScript(ticket, verifyPath, enabled, challengeId) {
|
|
53
|
+
const evidence = enabled ? `body.browser=(${collectBrowserEvidence.toString()})();try{const nonce=await (${solveBrowserWork.toString()})(${JSON.stringify(challengeId)},controller.signal);if(nonce!==null)body.proof={version:1,nonce}}catch{}` : '';
|
|
54
|
+
return `const controller=new AbortController();const timeout=setTimeout(()=>{controller.abort();failed()},12000);document.getElementById('check-retry').addEventListener('click',()=>location.reload());function failed(){document.getElementById('check-spinner').hidden=true;document.getElementById('check-title').textContent='Unable to complete the check';document.getElementById('check-message').textContent='Please try again. If the problem continues, contact the website owner.';document.getElementById('check-retry').hidden=false}async function run(){const body={ticket:${JSON.stringify(ticket)}};${evidence};const result=await fetch(${JSON.stringify(verifyPath)},{method:'POST',credentials:'same-origin',signal:controller.signal,headers:{'content-type':'application/json'},body:JSON.stringify(body)});if(result.ok)location.reload();else failed()}run().catch(failed).finally(()=>clearTimeout(timeout))`;
|
|
55
|
+
}
|
package/dist/guard.d.ts
CHANGED
|
@@ -13,6 +13,19 @@ export type StopneticOptions = {
|
|
|
13
13
|
timeoutMs?: number;
|
|
14
14
|
publicPaths?: string[];
|
|
15
15
|
reportEvents?: boolean;
|
|
16
|
+
/** Require the customer's server-validated approved account on protected routes.
|
|
17
|
+
* Rechecked on each request; independent of bot detection. */
|
|
18
|
+
requireApprovedAccount?: boolean;
|
|
19
|
+
/** Must validate an active session and explicit approval using trusted server
|
|
20
|
+
* data. Only literal true grants approval. Never trust a visitor header/flag. */
|
|
21
|
+
authorizeVisitor?: (request: Request) => boolean | Promise<boolean>;
|
|
22
|
+
/** First-party, spoofable automation signals on protected routes. Default off.
|
|
23
|
+
* Monitor records evidence; block requires bounded server-verified work and
|
|
24
|
+
* rejects detected signals. Neither establishes human identity. */
|
|
25
|
+
botProtection?: 'off' | 'monitor' | 'block';
|
|
26
|
+
/** Optional shared network traffic budgets. Vercel-only trusted ingress; raw IP
|
|
27
|
+
* stays on the customer server. Missing metadata withholds private access. */
|
|
28
|
+
botClientSource?: 'none' | 'vercel';
|
|
16
29
|
/** Optional coarse request analytics. No raw visitor IP or user-agent is sent. */
|
|
17
30
|
trafficInsights?: boolean;
|
|
18
31
|
/** Opt into country controls; requires trusted countrySource and no publicPaths. */
|
|
@@ -25,6 +38,8 @@ export type StopneticOptions = {
|
|
|
25
38
|
};
|
|
26
39
|
export function parseAdditionalDecisionPublicKeys(value?: string): string[];
|
|
27
40
|
export function createStopnetic(options: StopneticOptions): {
|
|
41
|
+
/** Local configured capability declaration, not a deployment attestation. */
|
|
42
|
+
readonly capabilities: { readonly approvedAccountGate: boolean; readonly botProtocol: 2 };
|
|
28
43
|
check(request: Request): Promise<Response | null>;
|
|
29
44
|
verify(request: Request): Promise<Response>;
|
|
30
45
|
};
|
package/dist/guard.js
CHANGED
|
@@ -2,6 +2,8 @@ import { createHash, createHmac, randomBytes, timingSafeEqual, verify as verifyS
|
|
|
2
2
|
import { trustedSigningKeys } from './signing-keys.js';
|
|
3
3
|
import { trafficContext } from './traffic-context.js';
|
|
4
4
|
import { verificationPage } from './verification-page.js';
|
|
5
|
+
import { automationSignals, sameBotControl, botClientContext } from './bot-protection.js';
|
|
6
|
+
import { validBotWork, verificationScript } from './browser-check.js';
|
|
5
7
|
export { parseAdditionalDecisionPublicKeys } from './signing-keys.js';
|
|
6
8
|
|
|
7
9
|
const unavailable = retryAfter => new Response(null, { status: 503, headers: {
|
|
@@ -51,10 +53,9 @@ async function limitedBody(request, maxBytes) {
|
|
|
51
53
|
return Buffer.concat(chunks, length).toString('utf8');
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
function bootstrap(ticket, verifyPath) {
|
|
56
|
+
function bootstrap(ticket, verifyPath, botProtection, challengeId) {
|
|
55
57
|
const nonce = randomBytes(16).toString('base64');
|
|
56
|
-
const
|
|
57
|
-
const html = verificationPage(nonce, script);
|
|
58
|
+
const html = verificationPage(nonce, verificationScript(ticket, verifyPath, botProtection !== 'off', challengeId));
|
|
58
59
|
return new Response(html, { status: 404, headers: {
|
|
59
60
|
'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'vary': 'Cookie',
|
|
60
61
|
'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'`,
|
|
@@ -64,7 +65,7 @@ function bootstrap(ticket, verifyPath) {
|
|
|
64
65
|
|
|
65
66
|
/** Pilot guard for server-side Next.js Proxy and Route Handlers. */
|
|
66
67
|
export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, additionalDecisionPublicKeys = [], cookieSecret, verifyPath = '/__stopnetic/verify',
|
|
67
|
-
cookieName = 'stopnetic_pilot_session', secureCookie = true, fetchImpl = fetch, timeoutMs = 2000, publicPaths = [], reportEvents = true, guardMode = 'selected', trafficInsights = false, countrySource = 'none', countryBlocking = false }) {
|
|
68
|
+
cookieName = 'stopnetic_pilot_session', secureCookie = true, fetchImpl = fetch, timeoutMs = 2000, publicPaths = [], reportEvents = true, guardMode = 'selected', trafficInsights = false, countrySource = 'none', countryBlocking = false, botProtection = 'off', botClientSource = 'none', requireApprovedAccount = false, authorizeVisitor }) {
|
|
68
69
|
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(siteId || '')) throw new Error('Invalid siteId');
|
|
69
70
|
const credential = typeof apiToken === 'string' ? apiToken.match(/^[a-z]{1,32}_([a-f0-9]{32})\.[A-Za-z0-9_-]{43}$/) : null;
|
|
70
71
|
if (!credential) throw new Error('apiToken must be an issued installation key');
|
|
@@ -86,6 +87,13 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
86
87
|
if (!Array.isArray(publicPaths) || publicPaths.some(path => typeof path !== 'string' || !/^\/[a-zA-Z0-9_\-/.]*(?:\*)?$/.test(path))) throw new Error('Invalid publicPaths');
|
|
87
88
|
if (!['selected', 'site'].includes(guardMode)) throw new Error('Invalid guardMode');
|
|
88
89
|
if (typeof countryBlocking !== 'boolean') throw new Error('Invalid countryBlocking');
|
|
90
|
+
if (typeof requireApprovedAccount !== 'boolean') throw new Error('Invalid requireApprovedAccount');
|
|
91
|
+
if (requireApprovedAccount && typeof authorizeVisitor !== 'function') throw new Error('requireApprovedAccount requires a server-side authorizeVisitor function');
|
|
92
|
+
if (!requireApprovedAccount && authorizeVisitor !== undefined) throw new Error('authorizeVisitor requires requireApprovedAccount');
|
|
93
|
+
if (requireApprovedAccount && publicPaths.length) throw new Error('requireApprovedAccount requires no publicPaths bypasses');
|
|
94
|
+
if (!['none', 'vercel'].includes(botClientSource) || (botClientSource !== 'none' && botProtection === 'off')) throw new Error('botClientSource requires an enabled botProtection mode and supported ingress');
|
|
95
|
+
if (!['off', 'monitor', 'block'].includes(botProtection)) throw new Error('Invalid botProtection');
|
|
96
|
+
if (botProtection !== 'off' && publicPaths.length) throw new Error('botProtection requires no publicPaths bypasses');
|
|
89
97
|
if (countryBlocking && (countrySource === 'none' || publicPaths.length)) throw new Error('countryBlocking requires an explicit countrySource and no publicPaths bypasses');
|
|
90
98
|
const publicKeys = trustedSigningKeys(decisionPublicKey, additionalDecisionPublicKeys);
|
|
91
99
|
const verifyEndpoint = new URL('./verify', endpoint);
|
|
@@ -135,6 +143,39 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
135
143
|
|| (country.result !== 'disabled' && country.revision === null)
|
|
136
144
|
|| (!['disabled', 'pass'].includes(country.result) && payload.action !== 'block')) throw new Error('Invalid country decision');
|
|
137
145
|
}
|
|
146
|
+
if (botProtection !== 'off') {
|
|
147
|
+
const bot = payload.bot_decision;
|
|
148
|
+
if (!sameBotControl(payload.bot_control, input.bot_control) || !sameBotControl(bot, input.bot_control)
|
|
149
|
+
|| !Array.isArray(bot.server_signals) || bot.server_signals.length > 5
|
|
150
|
+
|| new Set(bot.server_signals).size !== bot.server_signals.length
|
|
151
|
+
|| !bot.server_signals.every(signal => ['work_missing', 'work_invalid', 'verification_rate', 'request_rate', 'network_verification_rate'].includes(signal))
|
|
152
|
+
|| typeof bot.work_verified !== 'boolean'
|
|
153
|
+
|| !['observe', 'continue', 'block', 'public'].includes(bot.result)
|
|
154
|
+
|| (botProtection === 'monitor' && bot.result !== 'observe')
|
|
155
|
+
|| (botProtection === 'block' && (bot.result === 'observe'
|
|
156
|
+
|| ((input.bot_control.signals.length > 0 || bot.server_signals.length > 0) && bot.result === 'continue')
|
|
157
|
+
|| (purpose === 'verify' && payload.action === 'verified' && !bot.work_verified)))
|
|
158
|
+
|| (bot.result === 'block' && payload.action !== 'block')
|
|
159
|
+
|| (bot.result === 'public' && payload.action !== 'public'
|
|
160
|
+
&& !(payload.action === 'block' && payload.country_decision
|
|
161
|
+
&& !['disabled', 'pass'].includes(payload.country_decision.result)))) throw new Error('Invalid automation decision');
|
|
162
|
+
}
|
|
163
|
+
if (botClientSource !== 'none') {
|
|
164
|
+
const client = payload.bot_client;
|
|
165
|
+
if (client?.version !== 1 || client.source !== botClientSource || client.key !== input.bot_client?.key) throw new Error('Invalid client rate context');
|
|
166
|
+
}
|
|
167
|
+
if (requireApprovedAccount) {
|
|
168
|
+
const control = payload.approved_access;
|
|
169
|
+
const approval = payload.approval_decision;
|
|
170
|
+
const deniedCountry = payload.country_decision && !['disabled', 'pass'].includes(payload.country_decision.result);
|
|
171
|
+
if (control?.version !== 1 || control.required !== true || control.status !== input.approved_access.status
|
|
172
|
+
|| approval?.version !== 1 || !['public', 'approved', 'required', 'unavailable'].includes(approval.result)
|
|
173
|
+
|| (approval.result === 'approved' && control.status !== 'approved')
|
|
174
|
+
|| (approval.result === 'required' && (control.status !== 'unapproved' || payload.action !== 'block'))
|
|
175
|
+
|| (approval.result === 'unavailable' && (control.status !== 'unavailable' || !deniedCountry || payload.action !== 'block'))
|
|
176
|
+
|| (approval.result === 'public' && payload.action !== 'public' && !(deniedCountry && payload.action === 'block'))
|
|
177
|
+
|| (purpose === 'verify' && payload.action === 'verified' && approval.result !== 'approved')) throw new Error('Invalid account approval decision');
|
|
178
|
+
}
|
|
138
179
|
return { ...payload, receipt: envelope.decision };
|
|
139
180
|
}
|
|
140
181
|
|
|
@@ -155,6 +196,11 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
155
196
|
const payload = verified(value, cookieSecret);
|
|
156
197
|
const binding = browserBinding(request);
|
|
157
198
|
return payload?.kind === 'session' && payload.site === siteId && payload.installation === installation
|
|
199
|
+
&& (botProtection === 'off' || (payload.bot_mode === botProtection && payload.bot_version === 2
|
|
200
|
+
&& (typeof payload.webdriver === 'boolean' || payload.webdriver === null)
|
|
201
|
+
&& typeof payload.webdriver_inconsistent === 'boolean' && typeof payload.webdriver_tampered === 'boolean'
|
|
202
|
+
&& (botProtection !== 'block' || payload.work_verified === true)))
|
|
203
|
+
&& (botClientSource === 'none' || (payload.bot_client_key && payload.bot_client_key === clientContext(request).bot_client.key))
|
|
158
204
|
&& binding && payload.browser_hash === hash(binding) ? payload : null;
|
|
159
205
|
}
|
|
160
206
|
|
|
@@ -163,6 +209,27 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
163
209
|
country: trafficContext(request, true, countrySource).country } } : {};
|
|
164
210
|
}
|
|
165
211
|
|
|
212
|
+
function clientContext(request) { return botClientContext(request, botClientSource, cookieSecret, `${siteId}:${installationId}`); }
|
|
213
|
+
|
|
214
|
+
function botControl(request, webdriver = null, inconsistent = false, tampered = false) {
|
|
215
|
+
return botProtection === 'off' ? {} : { bot_control: { version: 2, mode: botProtection,
|
|
216
|
+
signals: automationSignals(request, webdriver, inconsistent, tampered) } };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function approvedAccess(request) {
|
|
220
|
+
if (!requireApprovedAccount) return {};
|
|
221
|
+
let status;
|
|
222
|
+
let timer;
|
|
223
|
+
try {
|
|
224
|
+
const result = await Promise.race([Promise.resolve().then(() => authorizeVisitor(request)),
|
|
225
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Approval unavailable')), timeoutMs); })]);
|
|
226
|
+
status = result === true ? 'approved' : 'unapproved';
|
|
227
|
+
}
|
|
228
|
+
catch { status = 'unavailable'; }
|
|
229
|
+
finally { clearTimeout(timer); }
|
|
230
|
+
return { approved_access: { version: 1, required: true, status } };
|
|
231
|
+
}
|
|
232
|
+
|
|
166
233
|
async function check(request) {
|
|
167
234
|
const path = new URL(request.url).pathname;
|
|
168
235
|
if (path === verifyPath) return null;
|
|
@@ -179,7 +246,7 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
179
246
|
let policy;
|
|
180
247
|
try {
|
|
181
248
|
const context = trafficContext(request, trafficInsights, countrySource);
|
|
182
|
-
policy = await remote('decision', { path, method: request.method, ...countryControl(request), ...(context ? { traffic_context: context } : {}), ...(guardMode === 'site' ? { guard_mode: 'site' } : {}), ...(binding ? { browser_binding: binding } : {}),
|
|
249
|
+
policy = await remote('decision', { path, method: request.method, ...countryControl(request), ...await approvedAccess(request), ...botControl(request, session?.webdriver, session?.webdriver_inconsistent, session?.webdriver_tampered), ...clientContext(request), ...(context ? { traffic_context: context } : {}), ...(guardMode === 'site' ? { guard_mode: 'site' } : {}), ...(binding ? { browser_binding: binding } : {}),
|
|
183
250
|
...(challenge ? { session_challenge: challenge } : {}) });
|
|
184
251
|
} catch (error) { return unavailable(error?.retryAfter); }
|
|
185
252
|
if (!policy || !['public', 'block', 'human', 'verified', 'entitled', 'default'].includes(policy.action)) return unavailable();
|
|
@@ -190,9 +257,13 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
190
257
|
if (session) { await report(policy, 'ALLOW', true); return null; }
|
|
191
258
|
if (policy.action === 'verified' || !eligible) { await report(policy, 'BLOCK'); return concealed(); }
|
|
192
259
|
if (!/^[A-Za-z0-9_-]{43}$/.test(policy.challenge_id || '')) return unavailable();
|
|
260
|
+
if (botProtection !== 'off' && !validBotWork(policy.bot_work)) return unavailable();
|
|
193
261
|
const ticket = signed({ v: 1, kind: 'challenge', site: siteId, path, exp: Date.now() + 60_000,
|
|
262
|
+
...(requireApprovedAccount ? { approval_required: true } : {}),
|
|
263
|
+
...(botClientSource !== 'none' ? { bot_client_key: clientContext(request).bot_client.key } : {}),
|
|
264
|
+
...(botProtection !== 'off' ? { bot_mode: botProtection, bot_version: 2 } : {}),
|
|
194
265
|
challenge_id: policy.challenge_id, browser_hash: hash(binding), installation }, cookieSecret);
|
|
195
|
-
const response = bootstrap(ticket, verifyPath);
|
|
266
|
+
const response = bootstrap(ticket, verifyPath, botProtection, policy.challenge_id);
|
|
196
267
|
response.headers.set('set-cookie', cookie(browserCookieName, signed({ v: 1, kind: 'browser', site: siteId,
|
|
197
268
|
binding, installation, exp: Date.now() + 600_000 }, cookieSecret), 600));
|
|
198
269
|
await report(policy, 'CHALLENGE');
|
|
@@ -213,22 +284,51 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
213
284
|
let body;
|
|
214
285
|
try { body = await limitedBody(request, 4096); } catch { return concealed(); }
|
|
215
286
|
let ticket;
|
|
216
|
-
|
|
287
|
+
let browser;
|
|
288
|
+
let proof;
|
|
289
|
+
try { const input = JSON.parse(body); ticket = input.ticket; browser = input.browser; proof = input.proof; } catch { return concealed(); }
|
|
217
290
|
const payload = verified(ticket, cookieSecret);
|
|
218
291
|
const binding = browserBinding(request);
|
|
219
292
|
if (payload?.kind !== 'challenge' || payload.site !== siteId || payload.installation !== installation
|
|
220
293
|
|| typeof payload.path !== 'string' || !/^\/[^\s?#]*$/.test(payload.path) || !binding
|
|
221
294
|
|| payload.browser_hash !== hash(binding) || !/^[A-Za-z0-9_-]{43}$/.test(payload.challenge_id || '')) return concealed();
|
|
295
|
+
if (requireApprovedAccount && payload.approval_required !== true) return concealed();
|
|
296
|
+
if (botProtection !== 'off' && (payload.bot_mode !== botProtection || payload.bot_version !== 2 || !browser
|
|
297
|
+
|| !Object.hasOwn(browser, 'webdriver') || Object.keys(browser).length !== 3
|
|
298
|
+
|| typeof browser.webdriverTampered !== 'boolean'
|
|
299
|
+
|| typeof browser.webdriverInconsistent !== 'boolean'
|
|
300
|
+
|| !(typeof browser.webdriver === 'boolean' || browser.webdriver === null))) return concealed();
|
|
301
|
+
if (botProtection !== 'off' && proof !== undefined && (!proof || Object.keys(proof).length !== 2
|
|
302
|
+
|| proof.version !== 1 || !Number.isSafeInteger(proof.nonce) || proof.nonce < 0 || proof.nonce > 1_048_575)) return concealed();
|
|
303
|
+
if (botClientSource !== 'none') {
|
|
304
|
+
const client = clientContext(request).bot_client;
|
|
305
|
+
if (!client.key) return unavailable();
|
|
306
|
+
if (payload.bot_client_key !== client.key) return concealed();
|
|
307
|
+
}
|
|
308
|
+
let workVerified = false;
|
|
222
309
|
try {
|
|
223
|
-
const result = await remote('verify', { path: payload.path, method: 'POST', challenge_id: payload.challenge_id, browser_binding: binding, ...countryControl(request)
|
|
310
|
+
const result = await remote('verify', { path: payload.path, method: 'POST', challenge_id: payload.challenge_id, browser_binding: binding, ...countryControl(request), ...await approvedAccess(request), ...botControl(request, browser?.webdriver, browser?.webdriverInconsistent, browser?.webdriverTampered), ...clientContext(request),
|
|
311
|
+
...(botProtection !== 'off' && proof ? { bot_proof: proof } : {}) });
|
|
224
312
|
if (result.action === 'block' && result.country_decision && !['disabled', 'pass'].includes(result.country_decision.result)) {
|
|
225
313
|
await report(result, 'BLOCK');
|
|
226
314
|
return concealed();
|
|
227
315
|
}
|
|
316
|
+
if (result.action === 'block' && result.bot_decision?.result === 'block') {
|
|
317
|
+
await report(result, 'BLOCK');
|
|
318
|
+
return concealed();
|
|
319
|
+
}
|
|
320
|
+
if (result.action === 'block' && result.approval_decision?.result === 'required') {
|
|
321
|
+
await report(result, 'BLOCK');
|
|
322
|
+
return concealed();
|
|
323
|
+
}
|
|
228
324
|
if (result.action !== 'verified' || result.challenge_id !== payload.challenge_id) return unavailable();
|
|
325
|
+
workVerified = result.bot_decision?.work_verified === true;
|
|
229
326
|
await report(result, 'VERIFIED');
|
|
230
327
|
} catch (error) { return unavailable(error?.retryAfter); }
|
|
231
328
|
const session = signed({ v: 1, kind: 'session', site: siteId, installation,
|
|
329
|
+
...(botProtection !== 'off' ? { bot_mode: botProtection, bot_version: 2, webdriver: browser.webdriver,
|
|
330
|
+
webdriver_inconsistent: browser.webdriverInconsistent, webdriver_tampered: browser.webdriverTampered, work_verified: workVerified } : {}),
|
|
331
|
+
...(botClientSource !== 'none' ? { bot_client_key: clientContext(request).bot_client.key } : {}),
|
|
232
332
|
browser_hash: hash(binding), challenge_id: payload.challenge_id, challenge_path: payload.path,
|
|
233
333
|
exp: Date.now() + 300_000 }, cookieSecret);
|
|
234
334
|
return new Response(null, { status: 204, headers: {
|
|
@@ -237,5 +337,5 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
237
337
|
} });
|
|
238
338
|
}
|
|
239
339
|
|
|
240
|
-
return { check, verify };
|
|
340
|
+
return { check, verify, capabilities: Object.freeze({ approvedAccountGate: requireApprovedAccount, botProtocol: 2 }) };
|
|
241
341
|
}
|
package/dist/server.js
CHANGED
|
@@ -4,6 +4,7 @@ export const accessBase = '/api/stopnetic';
|
|
|
4
4
|
|
|
5
5
|
/** Server environment only. Never import this module into browser code. */
|
|
6
6
|
export function optionsFromEnv(env = process.env) {
|
|
7
|
+
if (env.STOPNETIC_REQUIRE_APPROVED_ACCOUNT && !['true', 'false'].includes(env.STOPNETIC_REQUIRE_APPROVED_ACCOUNT)) throw new Error('Invalid STOPNETIC_REQUIRE_APPROVED_ACCOUNT');
|
|
7
8
|
return {
|
|
8
9
|
origin: env.STOPNETIC_WEBSITE_ORIGIN,
|
|
9
10
|
siteId: env.STOPNETIC_SITE_ID,
|
|
@@ -16,6 +17,9 @@ export function optionsFromEnv(env = process.env) {
|
|
|
16
17
|
cookieSecret: env.STOPNETIC_COOKIE_SECRET,
|
|
17
18
|
guardMode: 'site',
|
|
18
19
|
trafficInsights: env.STOPNETIC_TRAFFIC_INSIGHTS === 'true',
|
|
20
|
+
botProtection: env.STOPNETIC_BOT_PROTECTION || 'off',
|
|
21
|
+
botClientSource: env.STOPNETIC_BOT_CLIENT_SOURCE || 'none',
|
|
22
|
+
requireApprovedAccount: env.STOPNETIC_REQUIRE_APPROVED_ACCOUNT === 'true',
|
|
19
23
|
countryBlocking: env.STOPNETIC_COUNTRY_BLOCKING === 'true',
|
|
20
24
|
countrySource: env.STOPNETIC_COUNTRY_SOURCE || 'none',
|
|
21
25
|
verifyPath: `${accessBase}/verify`,
|