@stopnetic/js 0.1.4 → 0.1.5
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 +6 -0
- package/dist/guard.d.ts +10 -1
- package/dist/guard.js +65 -7
- package/dist/index.js +7 -1
- package/dist/interaction-trap.js +42 -42
- package/dist/request-monitoring.js +28 -0
- package/dist/server.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @stopnetic/js - JavaScript and TypeScript website guard
|
|
2
2
|
|
|
3
|
+
Repository increment after0.1.4: optional `diagnostics: true` and
|
|
4
|
+
`connectionInsights: 'vercel'` add correlated references and daily scoped JA4
|
|
5
|
+
observations. Server helpers read `STOPNETIC_DIAGNOSTICS` and
|
|
6
|
+
`STOPNETIC_CONNECTION_INSIGHTS`. Defaults false/none; matching service/new SDK
|
|
7
|
+
required. [Configuration and measurement guide](../../docs/request-monitoring.md).
|
|
8
|
+
|
|
3
9
|
Experimental repository increment: `interactionTrap: 'monitor' | 'block'` adds a
|
|
4
10
|
server-backed hidden-action observation. Default off; requires the matching new
|
|
5
11
|
service and SDK release after 0.1.3. Server helpers also read
|
package/dist/guard.d.ts
CHANGED
|
@@ -30,6 +30,12 @@ export type StopneticOptions = {
|
|
|
30
30
|
* off. Monitor records explicit activation; block withholds that bound session.
|
|
31
31
|
* Requires matching service; ignoring the action never proves human identity. */
|
|
32
32
|
interactionTrap?: 'off' | 'monitor' | 'block';
|
|
33
|
+
/** Opt-in short-lived trace/request references on guard responses and events.
|
|
34
|
+
* These are diagnostic labels, never access credentials. Default false. */
|
|
35
|
+
diagnostics?: boolean;
|
|
36
|
+
/** Observe a configured Vercel JA4 header as a daily, installation-scoped keyed
|
|
37
|
+
* group. No raw fingerprint/IP is sent. Missing data never selects a denial. */
|
|
38
|
+
connectionInsights?: 'none' | 'vercel';
|
|
33
39
|
/** Optional coarse request analytics. No raw visitor IP or user-agent is sent. */
|
|
34
40
|
trafficInsights?: boolean;
|
|
35
41
|
/** Opt into country controls; requires trusted countrySource and no publicPaths. */
|
|
@@ -43,7 +49,10 @@ export type StopneticOptions = {
|
|
|
43
49
|
export function parseAdditionalDecisionPublicKeys(value?: string): string[];
|
|
44
50
|
export function createStopnetic(options: StopneticOptions): {
|
|
45
51
|
/** Local configured capability declaration, not a deployment attestation. */
|
|
46
|
-
readonly capabilities: { readonly approvedAccountGate: boolean; readonly botProtocol: 2; readonly interactionTrapProtocol: 1 };
|
|
52
|
+
readonly capabilities: { readonly approvedAccountGate: boolean; readonly botProtocol: 2; readonly interactionTrapProtocol: 1; readonly monitoringProtocol: 1 };
|
|
47
53
|
check(request: Request): Promise<Response | null>;
|
|
48
54
|
verify(request: Request): Promise<Response>;
|
|
55
|
+
/** References for this exact Request after check/verify. Add to an allowed
|
|
56
|
+
* downstream response when using a custom Proxy; empty when diagnostics off. */
|
|
57
|
+
diagnosticHeaders(request: Request): Headers;
|
|
49
58
|
};
|
package/dist/guard.js
CHANGED
|
@@ -5,6 +5,7 @@ import { verificationPage } from './verification-page.js';
|
|
|
5
5
|
import { automationSignals, sameBotControl, botClientContext } from './bot-protection.js';
|
|
6
6
|
import { validBotWork, verificationScript } from './browser-check.js';
|
|
7
7
|
import { interactionMarkup, validateTrapDecision } from './interaction-trap.js';
|
|
8
|
+
import { connectionObservation, sameMonitoring } from './request-monitoring.js';
|
|
8
9
|
export { parseAdditionalDecisionPublicKeys } from './signing-keys.js';
|
|
9
10
|
|
|
10
11
|
const unavailable = retryAfter => new Response(null, { status: 503, headers: {
|
|
@@ -66,7 +67,7 @@ function bootstrap(ticket, verifyPath, botProtection, challengeId, trap) {
|
|
|
66
67
|
|
|
67
68
|
/** Pilot guard for server-side Next.js Proxy and Route Handlers. */
|
|
68
69
|
export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, additionalDecisionPublicKeys = [], cookieSecret, verifyPath = '/__stopnetic/verify',
|
|
69
|
-
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', interactionTrap = 'off', requireApprovedAccount = false, authorizeVisitor }) {
|
|
70
|
+
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', interactionTrap = 'off', diagnostics = false, connectionInsights = 'none', requireApprovedAccount = false, authorizeVisitor }) {
|
|
70
71
|
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(siteId || '')) throw new Error('Invalid siteId');
|
|
71
72
|
const credential = typeof apiToken === 'string' ? apiToken.match(/^[a-z]{1,32}_([a-f0-9]{32})\.[A-Za-z0-9_-]{43}$/) : null;
|
|
72
73
|
if (!credential) throw new Error('apiToken must be an issued installation key');
|
|
@@ -84,6 +85,8 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
84
85
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000) throw new Error('Invalid timeoutMs');
|
|
85
86
|
if (typeof reportEvents !== 'boolean') throw new Error('Invalid reportEvents');
|
|
86
87
|
if (typeof trafficInsights !== 'boolean') throw new Error('Invalid trafficInsights');
|
|
88
|
+
if (typeof diagnostics !== 'boolean') throw new Error('Invalid diagnostics');
|
|
89
|
+
if (!['none', 'vercel'].includes(connectionInsights)) throw new Error('Invalid connectionInsights');
|
|
87
90
|
if (!['none', 'vercel', 'cloudflare'].includes(countrySource)) throw new Error('Invalid countrySource');
|
|
88
91
|
if (!Array.isArray(publicPaths) || publicPaths.some(path => typeof path !== 'string' || !/^\/[a-zA-Z0-9_\-/.]*(?:\*)?$/.test(path))) throw new Error('Invalid publicPaths');
|
|
89
92
|
if (!['selected', 'site'].includes(guardMode)) throw new Error('Invalid guardMode');
|
|
@@ -104,6 +107,41 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
104
107
|
const eventsEndpoint = new URL('./events', endpoint);
|
|
105
108
|
const browserCookieName = `${cookieName}_browser`;
|
|
106
109
|
const installation = hash(apiToken);
|
|
110
|
+
const requestMonitoring = new WeakMap();
|
|
111
|
+
|
|
112
|
+
function startMonitoring(request) {
|
|
113
|
+
if (!diagnostics && connectionInsights === 'none') return;
|
|
114
|
+
const session = validSession(request);
|
|
115
|
+
const previous = session?.diagnostic_trace;
|
|
116
|
+
const trace = /^[A-Za-z0-9_-]{22}$/.test(previous || '') ? previous
|
|
117
|
+
: session?.challenge_id ? createHmac('sha256', cookieSecret)
|
|
118
|
+
.update(`stopnetic-trace-v1:${siteId}:${installationId}:${session.challenge_id}`).digest('base64url').slice(0, 22)
|
|
119
|
+
: randomBytes(16).toString('base64url');
|
|
120
|
+
const connection = connectionObservation(request, connectionInsights, cookieSecret, `${siteId}:${installationId}`);
|
|
121
|
+
requestMonitoring.set(request, { request_id: randomBytes(16).toString('base64url'), validated: false,
|
|
122
|
+
monitoring_context: { version: 1, ...(diagnostics ? { trace_id: trace } : {}), ...(connection ? { connection } : {}) } });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function monitoringInput(request) {
|
|
126
|
+
const state = requestMonitoring.get(request);
|
|
127
|
+
return state ? { request_id: state.request_id, monitoring_context: state.monitoring_context } : {};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function diagnosticHeaders(request) {
|
|
131
|
+
const headers = new Headers();
|
|
132
|
+
const state = requestMonitoring.get(request);
|
|
133
|
+
if (diagnostics && state) {
|
|
134
|
+
headers.set('x-stopnetic-request-id', state.request_id);
|
|
135
|
+
headers.set('x-stopnetic-trace-id', state.monitoring_context.trace_id);
|
|
136
|
+
headers.set('x-stopnetic-diagnostic-status', state.validated ? 'decision-validated' : 'no-validated-decision');
|
|
137
|
+
}
|
|
138
|
+
return headers;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function diagnosticResponse(request, response) {
|
|
142
|
+
if (response) diagnosticHeaders(request).forEach((value, name) => response.headers.set(name, value));
|
|
143
|
+
return response;
|
|
144
|
+
}
|
|
107
145
|
|
|
108
146
|
function browserBinding(request) {
|
|
109
147
|
const payload = verified(cookieValue(request, browserCookieName), cookieSecret);
|
|
@@ -116,7 +154,7 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
116
154
|
}
|
|
117
155
|
|
|
118
156
|
async function remote(purpose, input) {
|
|
119
|
-
const context = { site_id: siteId, path: input.path, method: input.method, request_id: randomBytes(16).toString('base64url') };
|
|
157
|
+
const context = { site_id: siteId, path: input.path, method: input.method, request_id: input.request_id || randomBytes(16).toString('base64url') };
|
|
120
158
|
const response = await fetchImpl(purpose === 'decision' ? endpoint : verifyEndpoint, {
|
|
121
159
|
method: 'POST', signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
|
|
122
160
|
headers: { authorization: `Bearer ${apiToken}`, 'content-type': 'application/json' },
|
|
@@ -138,6 +176,7 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
138
176
|
|| !Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)
|
|
139
177
|
|| payload.issued_at > now + 5000 || payload.expires_at <= now || payload.expires_at <= payload.issued_at
|
|
140
178
|
|| payload.expires_at - payload.issued_at > 30_000) throw new Error('Invalid decision context or expiry');
|
|
179
|
+
if (input.monitoring_context && !sameMonitoring(payload.monitoring_context, input.monitoring_context)) throw new Error('Invalid monitoring context');
|
|
141
180
|
if (countryBlocking) {
|
|
142
181
|
const control = payload.country_control;
|
|
143
182
|
const country = payload.country_decision;
|
|
@@ -241,7 +280,7 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
241
280
|
return { approved_access: { version: 1, required: true, status } };
|
|
242
281
|
}
|
|
243
282
|
|
|
244
|
-
async function
|
|
283
|
+
async function checkRequest(request) {
|
|
245
284
|
const path = new URL(request.url).pathname;
|
|
246
285
|
if (path === verifyPath) return null;
|
|
247
286
|
if (publicPaths.some(rule => rule.endsWith('*') ? path.startsWith(rule.slice(0, -1)) : path === rule)) return null;
|
|
@@ -257,8 +296,9 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
257
296
|
let policy;
|
|
258
297
|
try {
|
|
259
298
|
const context = trafficContext(request, trafficInsights, countrySource);
|
|
260
|
-
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), ...trapControl(session), ...(context ? { traffic_context: context } : {}), ...(guardMode === 'site' ? { guard_mode: 'site' } : {}), ...(binding ? { browser_binding: binding } : {}),
|
|
299
|
+
policy = await remote('decision', { path, method: request.method, ...monitoringInput(request), ...countryControl(request), ...await approvedAccess(request), ...botControl(request, session?.webdriver, session?.webdriver_inconsistent, session?.webdriver_tampered), ...clientContext(request), ...trapControl(session), ...(context ? { traffic_context: context } : {}), ...(guardMode === 'site' ? { guard_mode: 'site' } : {}), ...(binding ? { browser_binding: binding } : {}),
|
|
261
300
|
...(challenge ? { session_challenge: challenge } : {}) });
|
|
301
|
+
const observed = requestMonitoring.get(request); if (observed) observed.validated = true;
|
|
262
302
|
} catch (error) { return unavailable(error?.retryAfter); }
|
|
263
303
|
if (!policy || !['public', 'block', 'human', 'verified', 'entitled', 'default'].includes(policy.action)) return unavailable();
|
|
264
304
|
// An older service must not silently apply selected-path semantics here.
|
|
@@ -276,6 +316,7 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
276
316
|
...(botClientSource !== 'none' ? { bot_client_key: clientContext(request).bot_client.key } : {}),
|
|
277
317
|
...(botProtection !== 'off' ? { bot_mode: botProtection, bot_version: 2 } : {}),
|
|
278
318
|
...(trap ? { trap_mode: interactionTrap, trap_token: trap.token } : {}),
|
|
319
|
+
...(diagnostics ? { diagnostic_trace: requestMonitoring.get(request).monitoring_context.trace_id } : {}),
|
|
279
320
|
challenge_id: policy.challenge_id, browser_hash: hash(binding), installation }, cookieSecret);
|
|
280
321
|
const response = bootstrap(ticket, verifyPath, botProtection, policy.challenge_id, trap);
|
|
281
322
|
response.headers.set('set-cookie', cookie(browserCookieName, signed({ v: 1, kind: 'browser', site: siteId,
|
|
@@ -284,7 +325,7 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
284
325
|
return response;
|
|
285
326
|
}
|
|
286
327
|
|
|
287
|
-
async function
|
|
328
|
+
async function verifyRequest(request) {
|
|
288
329
|
if (request.method !== 'POST') return new Response(null, { status: 405, headers: { 'cache-control': 'no-store' } });
|
|
289
330
|
const origin = request.headers.get('origin');
|
|
290
331
|
if (!origin) return concealed();
|
|
@@ -307,6 +348,11 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
307
348
|
if (payload?.kind !== 'challenge' || payload.site !== siteId || payload.installation !== installation
|
|
308
349
|
|| typeof payload.path !== 'string' || !/^\/[^\s?#]*$/.test(payload.path) || !binding
|
|
309
350
|
|| payload.browser_hash !== hash(binding) || !/^[A-Za-z0-9_-]{43}$/.test(payload.challenge_id || '')) return concealed();
|
|
351
|
+
// Never take a flow reference from a visitor header/body field. Only this
|
|
352
|
+
// installation's authenticated ticket may link verification to a challenge.
|
|
353
|
+
if (diagnostics && /^[A-Za-z0-9_-]{22}$/.test(payload.diagnostic_trace || '')) {
|
|
354
|
+
requestMonitoring.get(request).monitoring_context.trace_id = payload.diagnostic_trace;
|
|
355
|
+
}
|
|
310
356
|
if (requireApprovedAccount && payload.approval_required !== true) return concealed();
|
|
311
357
|
if (interactionTrap !== 'off' && (payload.trap_mode !== interactionTrap || !/^[A-Za-z0-9_-]{43}$/.test(payload.trap_token || ''))) return concealed();
|
|
312
358
|
if (interaction !== undefined && (interactionTrap === 'off' || !interaction || Object.keys(interaction).length !== 2
|
|
@@ -325,8 +371,9 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
325
371
|
}
|
|
326
372
|
let workVerified = false;
|
|
327
373
|
try {
|
|
328
|
-
const result = await remote(interaction ? 'trap' : '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), ...trapControl(),
|
|
374
|
+
const result = await remote(interaction ? 'trap' : 'verify', { path: payload.path, method: 'POST', challenge_id: payload.challenge_id, browser_binding: binding, ...monitoringInput(request), ...countryControl(request), ...await approvedAccess(request), ...botControl(request, browser?.webdriver, browser?.webdriverInconsistent, browser?.webdriverTampered), ...clientContext(request), ...trapControl(),
|
|
329
375
|
...(interaction ? { trap_activation: true } : {}), ...(botProtection !== 'off' && proof ? { bot_proof: proof } : {}) });
|
|
376
|
+
const observed = requestMonitoring.get(request); if (observed) observed.validated = true;
|
|
330
377
|
if (interaction || result.trap_decision?.result === 'block') {
|
|
331
378
|
if (result.challenge_id !== payload.challenge_id || !['block', 'observed'].includes(result.action)) return unavailable();
|
|
332
379
|
await report(result, result.action === 'block' ? 'BLOCK' : 'OBSERVED');
|
|
@@ -353,6 +400,7 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
353
400
|
webdriver_inconsistent: browser.webdriverInconsistent, webdriver_tampered: browser.webdriverTampered, work_verified: workVerified } : {}),
|
|
354
401
|
...(botClientSource !== 'none' ? { bot_client_key: clientContext(request).bot_client.key } : {}),
|
|
355
402
|
...(interactionTrap !== 'off' ? { trap_mode: interactionTrap } : {}),
|
|
403
|
+
...(diagnostics ? { diagnostic_trace: requestMonitoring.get(request).monitoring_context.trace_id } : {}),
|
|
356
404
|
browser_hash: hash(binding), challenge_id: payload.challenge_id, challenge_path: payload.path,
|
|
357
405
|
exp: Date.now() + 300_000 }, cookieSecret);
|
|
358
406
|
return new Response(null, { status: 204, headers: {
|
|
@@ -361,5 +409,15 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
361
409
|
} });
|
|
362
410
|
}
|
|
363
411
|
|
|
364
|
-
|
|
412
|
+
async function check(request) {
|
|
413
|
+
startMonitoring(request);
|
|
414
|
+
return diagnosticResponse(request, await checkRequest(request));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function verify(request) {
|
|
418
|
+
startMonitoring(request);
|
|
419
|
+
return diagnosticResponse(request, await verifyRequest(request));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return { check, verify, diagnosticHeaders, capabilities: Object.freeze({ approvedAccountGate: requireApprovedAccount, botProtocol: 2, interactionTrapProtocol: 1, monitoringProtocol: 1 }) };
|
|
365
423
|
}
|
package/dist/index.js
CHANGED
|
@@ -84,6 +84,7 @@ export function createStopneticHandler(options) {
|
|
|
84
84
|
// Copy headers, including individual cookies, without buffering the body.
|
|
85
85
|
const headers = new Headers(result.headers);
|
|
86
86
|
headers.set('cache-control', 'private, no-store');
|
|
87
|
+
guard.diagnosticHeaders(request).forEach((value, name) => headers.set(name, value));
|
|
87
88
|
return new Response(result.body, { status: result.status, statusText: result.statusText, headers });
|
|
88
89
|
};
|
|
89
90
|
}
|
|
@@ -103,6 +104,8 @@ function requestHeaders(req, origin) {
|
|
|
103
104
|
try { headers.set(name, value); } catch { /* Leave unsupported metadata unknown. */ }
|
|
104
105
|
}
|
|
105
106
|
}
|
|
107
|
+
const ja4 = req.headers['x-vercel-ja4-digest'];
|
|
108
|
+
if (ja4 !== undefined) headers.set('x-vercel-ja4-digest', typeof ja4 === 'string' && ja4.length <= 128 ? ja4 : 'invalid');
|
|
106
109
|
return headers;
|
|
107
110
|
}
|
|
108
111
|
|
|
@@ -145,6 +148,7 @@ export function createStopneticMiddleware(options) {
|
|
|
145
148
|
const verifyPath = options.verifyPath || '/__stopnetic/verify';
|
|
146
149
|
return async function stopnetic(req, res, next) {
|
|
147
150
|
let response;
|
|
151
|
+
let checked;
|
|
148
152
|
let close = false;
|
|
149
153
|
try {
|
|
150
154
|
const host = req.headers.host;
|
|
@@ -165,7 +169,8 @@ export function createStopneticMiddleware(options) {
|
|
|
165
169
|
}
|
|
166
170
|
} else {
|
|
167
171
|
// Checking ordinary requests must leave their bodies for the application.
|
|
168
|
-
|
|
172
|
+
checked = new Request(url, { method, headers });
|
|
173
|
+
response = await guard.check(checked);
|
|
169
174
|
}
|
|
170
175
|
}
|
|
171
176
|
} catch { response = unavailable(); close = true; }
|
|
@@ -174,6 +179,7 @@ export function createStopneticMiddleware(options) {
|
|
|
174
179
|
return;
|
|
175
180
|
}
|
|
176
181
|
res.setHeader('cache-control', 'private, no-store');
|
|
182
|
+
if (checked) guard.diagnosticHeaders(checked).forEach((value, name) => res.setHeader(name, value));
|
|
177
183
|
return next();
|
|
178
184
|
};
|
|
179
185
|
}
|
package/dist/interaction-trap.js
CHANGED
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
// Optional positive interaction evidence. Nothing here classifies visitors who
|
|
2
|
-
// ignore the decoy. No resource URL, form or focusable target is exposed.
|
|
3
|
-
export function interactionMarkup(id) {
|
|
4
|
-
return `<section hidden inert aria-hidden="true"><button type="button" tabindex="-1" id="${id}">Automated client verification shortcut</button></section>`;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
// Serialized into the same CSP-authorized script as the normal browser check.
|
|
8
|
-
// Merely reading the DOM, focusing, hovering or inspecting it is not activation.
|
|
9
|
-
export function installInteractionTrap(id, token, ticket, verifyPath, mode, evidence, blocked) {
|
|
10
|
-
let sent = false;
|
|
11
|
-
document.getElementById(id).addEventListener('click', () => {
|
|
12
|
-
if (sent) return;
|
|
13
|
-
sent = true;
|
|
14
|
-
if (mode === 'block') blocked();
|
|
15
|
-
const controller = new AbortController();
|
|
16
|
-
const deadline = setTimeout(() => controller.abort(), 8000);
|
|
17
|
-
fetch(verifyPath, { method: 'POST', credentials: 'same-origin', signal: controller.signal,
|
|
18
|
-
headers: { 'content-type': 'application/json' },
|
|
19
|
-
body: JSON.stringify({ ticket, browser: evidence(), interaction: { version: 1, token } }),
|
|
20
|
-
}).catch(() => {}).finally(() => clearTimeout(deadline));
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function validateTrapDecision(payload, expected, purpose) {
|
|
25
|
-
const control = payload.interaction_trap;
|
|
26
|
-
const decision = payload.trap_decision;
|
|
27
|
-
if (!control || Object.keys(control).length !== Object.keys(expected).length
|
|
28
|
-
|| Object.entries(expected).some(([key, value]) => control[key] !== value)
|
|
29
|
-
|| decision?.version !== 1 || decision.mode !== expected.mode
|
|
30
|
-
|| typeof decision.activated !== 'boolean' || Object.keys(decision).length !== 4) return false;
|
|
31
|
-
const result = decision.result;
|
|
32
|
-
const priorDenial = payload.action === 'block' && (payload.approval_decision?.result === 'required'
|
|
33
|
-
|| payload.country_decision && !['disabled', 'pass'].includes(payload.country_decision.result));
|
|
34
|
-
if (!['clear', 'observed', 'block', 'public'].includes(result)
|
|
35
|
-
|| (result === 'clear' && decision.activated)
|
|
36
|
-
|| (result === 'observed' && (!decision.activated || expected.mode !== 'monitor'))
|
|
37
|
-
|| (result === 'block' && (!decision.activated || expected.mode !== 'block' || payload.action !== 'block'))
|
|
38
|
-
|| (result === 'public' && (purpose !== 'decision' || (payload.action !== 'public' && !priorDenial) || decision.activated))) return false;
|
|
39
|
-
// An activation receipt cannot silently lose the positive evidence. Country
|
|
40
|
-
// and approval denials can precede activation, and retain their own reason.
|
|
41
|
-
return purpose !== 'trap' || decision.activated || !!priorDenial;
|
|
42
|
-
}
|
|
1
|
+
// Optional positive interaction evidence. Nothing here classifies visitors who
|
|
2
|
+
// ignore the decoy. No resource URL, form or focusable target is exposed.
|
|
3
|
+
export function interactionMarkup(id) {
|
|
4
|
+
return `<section hidden inert aria-hidden="true"><button type="button" tabindex="-1" id="${id}">Automated client verification shortcut</button></section>`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Serialized into the same CSP-authorized script as the normal browser check.
|
|
8
|
+
// Merely reading the DOM, focusing, hovering or inspecting it is not activation.
|
|
9
|
+
export function installInteractionTrap(id, token, ticket, verifyPath, mode, evidence, blocked) {
|
|
10
|
+
let sent = false;
|
|
11
|
+
document.getElementById(id).addEventListener('click', () => {
|
|
12
|
+
if (sent) return;
|
|
13
|
+
sent = true;
|
|
14
|
+
if (mode === 'block') blocked();
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const deadline = setTimeout(() => controller.abort(), 8000);
|
|
17
|
+
fetch(verifyPath, { method: 'POST', credentials: 'same-origin', signal: controller.signal,
|
|
18
|
+
headers: { 'content-type': 'application/json' },
|
|
19
|
+
body: JSON.stringify({ ticket, browser: evidence(), interaction: { version: 1, token } }),
|
|
20
|
+
}).catch(() => {}).finally(() => clearTimeout(deadline));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateTrapDecision(payload, expected, purpose) {
|
|
25
|
+
const control = payload.interaction_trap;
|
|
26
|
+
const decision = payload.trap_decision;
|
|
27
|
+
if (!control || Object.keys(control).length !== Object.keys(expected).length
|
|
28
|
+
|| Object.entries(expected).some(([key, value]) => control[key] !== value)
|
|
29
|
+
|| decision?.version !== 1 || decision.mode !== expected.mode
|
|
30
|
+
|| typeof decision.activated !== 'boolean' || Object.keys(decision).length !== 4) return false;
|
|
31
|
+
const result = decision.result;
|
|
32
|
+
const priorDenial = payload.action === 'block' && (payload.approval_decision?.result === 'required'
|
|
33
|
+
|| payload.country_decision && !['disabled', 'pass'].includes(payload.country_decision.result));
|
|
34
|
+
if (!['clear', 'observed', 'block', 'public'].includes(result)
|
|
35
|
+
|| (result === 'clear' && decision.activated)
|
|
36
|
+
|| (result === 'observed' && (!decision.activated || expected.mode !== 'monitor'))
|
|
37
|
+
|| (result === 'block' && (!decision.activated || expected.mode !== 'block' || payload.action !== 'block'))
|
|
38
|
+
|| (result === 'public' && (purpose !== 'decision' || (payload.action !== 'public' && !priorDenial) || decision.activated))) return false;
|
|
39
|
+
// An activation receipt cannot silently lose the positive evidence. Country
|
|
40
|
+
// and approval denials can precede activation, and retain their own reason.
|
|
41
|
+
return purpose !== 'trap' || decision.activated || !!priorDenial;
|
|
42
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// Observe the explicitly selected hosting header, not a browser identity.
|
|
4
|
+
// Raw connection metadata never leaves the customer's server. Group comparisons
|
|
5
|
+
// are meaningful only for one installation/site/UTC day, not across customers.
|
|
6
|
+
export function connectionObservation(request, source, secret, scope, now = Date.now()) {
|
|
7
|
+
if (source === 'none') return undefined;
|
|
8
|
+
const epoch = Math.floor(now / 86400000);
|
|
9
|
+
const base = { source: 'vercel', epoch, status: 'unavailable', ja4_group: null };
|
|
10
|
+
if (process.env.VERCEL !== '1') return base;
|
|
11
|
+
const value = request.headers.get('x-vercel-ja4-digest');
|
|
12
|
+
if (!value) return { ...base, status: 'missing' };
|
|
13
|
+
// Support the compact JA4 representation only; unknown formats are telemetry
|
|
14
|
+
// gaps, never access denials. No arbitrary forwarding/country fallback.
|
|
15
|
+
if (!/^[tqd][a-z0-9]{2}[di][0-9]{4}[a-z0-9]{2}_[a-f0-9]{12}_[a-f0-9]{12}$/.test(value)) {
|
|
16
|
+
return { ...base, status: 'invalid' };
|
|
17
|
+
}
|
|
18
|
+
return { ...base, status: 'observed', ja4_group: createHmac('sha256', secret)
|
|
19
|
+
.update(`stopnetic-ja4-v1:${scope}:${epoch}:${value}`).digest('hex') };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function sameMonitoring(actual, expected) {
|
|
23
|
+
if (!actual || actual.version !== 1 || Object.keys(actual).length !== Object.keys(expected).length
|
|
24
|
+
|| actual.trace_id !== expected.trace_id || Boolean(actual.connection) !== Boolean(expected.connection)) return false;
|
|
25
|
+
if (expected.connection && (Object.keys(actual.connection).length !== 4
|
|
26
|
+
|| Object.entries(expected.connection).some(([key, value]) => actual.connection[key] !== value))) return false;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
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_DIAGNOSTICS && !['true', 'false'].includes(env.STOPNETIC_DIAGNOSTICS)) throw new Error('Invalid STOPNETIC_DIAGNOSTICS');
|
|
7
8
|
if (env.STOPNETIC_REQUIRE_APPROVED_ACCOUNT && !['true', 'false'].includes(env.STOPNETIC_REQUIRE_APPROVED_ACCOUNT)) throw new Error('Invalid STOPNETIC_REQUIRE_APPROVED_ACCOUNT');
|
|
8
9
|
return {
|
|
9
10
|
origin: env.STOPNETIC_WEBSITE_ORIGIN,
|
|
@@ -20,6 +21,8 @@ export function optionsFromEnv(env = process.env) {
|
|
|
20
21
|
botProtection: env.STOPNETIC_BOT_PROTECTION || 'off',
|
|
21
22
|
botClientSource: env.STOPNETIC_BOT_CLIENT_SOURCE || 'none',
|
|
22
23
|
interactionTrap: env.STOPNETIC_INTERACTION_TRAP || 'off',
|
|
24
|
+
diagnostics: env.STOPNETIC_DIAGNOSTICS === 'true',
|
|
25
|
+
connectionInsights: env.STOPNETIC_CONNECTION_INSIGHTS || 'none',
|
|
23
26
|
requireApprovedAccount: env.STOPNETIC_REQUIRE_APPROVED_ACCOUNT === 'true',
|
|
24
27
|
countryBlocking: env.STOPNETIC_COUNTRY_BLOCKING === 'true',
|
|
25
28
|
countrySource: env.STOPNETIC_COUNTRY_SOURCE || 'none',
|