@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 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 script = `const controller=new AbortController();const timeout=setTimeout(()=>controller.abort(),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}fetch(${JSON.stringify(verifyPath)}, {method:'POST', credentials:'same-origin', signal:controller.signal, headers:{'content-type':'application/json'}, body:JSON.stringify({ticket:${JSON.stringify(ticket)}})}).then(r=>{if(r.ok)location.reload();else failed()}).catch(failed).finally(()=>clearTimeout(timeout))`;
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
- try { ticket = JSON.parse(body).ticket; } catch { return concealed(); }
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`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stopnetic/js",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Server-side Stopnetic guard for JavaScript websites, Node.js and Express-compatible servers.",
5
5
  "type": "module",
6
6
  "license": "MIT",