@stopnetic/js 0.1.0 → 0.1.1
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 +27 -0
- package/dist/guard.d.ts +7 -0
- package/dist/guard.js +44 -20
- package/dist/index.js +7 -0
- package/dist/server.js +3 -0
- package/dist/traffic-context.js +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,11 +1,38 @@
|
|
|
1
1
|
# @stopnetic/js - JavaScript and TypeScript website guard
|
|
2
2
|
|
|
3
|
+
Unreleased country controls: server options `countryBlocking: true` and a trusted
|
|
4
|
+
`countrySource` enable the compatible protocol. The `/server` environment helper
|
|
5
|
+
also reads `STOPNETIC_COUNTRY_BLOCKING=true`. Deploy and test guarded requests
|
|
6
|
+
before enabling the country policy in the dashboard. Remove local `publicPaths`
|
|
7
|
+
bypasses and secure your ingress/origin. Published 0.1.0 lacks this feature.
|
|
8
|
+
See [setup, recovery and coverage limits](../../docs/country-access.md).
|
|
9
|
+
|
|
3
10
|
Stopnetic checks access **on your server before sending pages, files or API data**.
|
|
4
11
|
Use it with Node.js and Express, with frontend examples for Angular, React, Vue,
|
|
5
12
|
Svelte, Solid and plain JavaScript. No frontend framework is a required dependency.
|
|
6
13
|
Your hosting server must run **Node.js 22 or newer**; browser-only JavaScript does
|
|
7
14
|
not enforce protection on its own. Keep your existing SSR or static rendering.
|
|
8
15
|
|
|
16
|
+
## Optional traffic insights (unreleased)
|
|
17
|
+
|
|
18
|
+
The working tree supports `trafficInsights: true` and
|
|
19
|
+
`countrySource: 'none' | 'vercel' | 'cloudflare'` in server guard options. The
|
|
20
|
+
`@stopnetic/js/server` helpers also read `STOPNETIC_TRAFFIC_INSIGHTS=true` and
|
|
21
|
+
`STOPNETIC_COUNTRY_SOURCE=none` from the website's server environment. These
|
|
22
|
+
settings are optional and collection is off by default. Published 0.1.0 does
|
|
23
|
+
not send these fields; use a compatible future release or local packed SDK.
|
|
24
|
+
|
|
25
|
+
Only a country code/source and broad device category are sent. No raw IPs or full
|
|
26
|
+
user-agent strings leave the server for this feature, and no network location
|
|
27
|
+
lookup is made. Unsupported sources/devices remain Unknown. Reported metadata
|
|
28
|
+
is approximate and does not authorize requests or identify people/bots.
|
|
29
|
+
|
|
30
|
+
Explicitly trust a country source only on the correct hosting ingress. For
|
|
31
|
+
Cloudflare, prevent direct-origin access or strip and rewrite visitor-supplied
|
|
32
|
+
headers. Deploy compatible service ingestion before updating and deploying the
|
|
33
|
+
website's guard. Disabling collection stops new context; stored context follows
|
|
34
|
+
the event retention/deletion policy.
|
|
35
|
+
|
|
9
36
|
## Install
|
|
10
37
|
|
|
11
38
|
Run inside your website project:
|
package/dist/guard.d.ts
CHANGED
|
@@ -13,6 +13,13 @@ export type StopneticOptions = {
|
|
|
13
13
|
timeoutMs?: number;
|
|
14
14
|
publicPaths?: string[];
|
|
15
15
|
reportEvents?: boolean;
|
|
16
|
+
/** Optional coarse request analytics. No raw visitor IP or user-agent is sent. */
|
|
17
|
+
trafficInsights?: boolean;
|
|
18
|
+
/** Opt into country controls; requires trusted countrySource and no publicPaths. */
|
|
19
|
+
countryBlocking?: boolean;
|
|
20
|
+
/** Explicitly trust country headers from your ingress; default none. Cloudflare
|
|
21
|
+
* origins must prevent direct requests/header spoofing. */
|
|
22
|
+
countrySource?: 'none' | 'vercel' | 'cloudflare';
|
|
16
23
|
/** Whole-site mode: unmatched routes public; requires the updated service. */
|
|
17
24
|
guardMode?: 'selected' | 'site';
|
|
18
25
|
};
|
package/dist/guard.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, timingSafeEqual, verify as verifySignature } from 'node:crypto';
|
|
2
2
|
import { trustedSigningKeys } from './signing-keys.js';
|
|
3
|
+
import { trafficContext } from './traffic-context.js';
|
|
3
4
|
export { parseAdditionalDecisionPublicKeys } from './signing-keys.js';
|
|
4
5
|
|
|
5
6
|
const unavailable = retryAfter => new Response(null, { status: 503, headers: {
|
|
@@ -62,14 +63,14 @@ function bootstrap(ticket, verifyPath) {
|
|
|
62
63
|
|
|
63
64
|
/** Pilot guard for server-side Next.js Proxy and Route Handlers. */
|
|
64
65
|
export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, additionalDecisionPublicKeys = [], cookieSecret, verifyPath = '/__stopnetic/verify',
|
|
65
|
-
cookieName = 'stopnetic_pilot_session', secureCookie = true, fetchImpl = fetch, timeoutMs = 2000, publicPaths = [], reportEvents = true, guardMode = 'selected' }) {
|
|
66
|
+
cookieName = 'stopnetic_pilot_session', secureCookie = true, fetchImpl = fetch, timeoutMs = 2000, publicPaths = [], reportEvents = true, guardMode = 'selected', trafficInsights = false, countrySource = 'none', countryBlocking = false }) {
|
|
66
67
|
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(siteId || '')) throw new Error('Invalid siteId');
|
|
67
68
|
const credential = typeof apiToken === 'string' ? apiToken.match(/^[a-z]{1,32}_([a-f0-9]{32})\.[A-Za-z0-9_-]{43}$/) : null;
|
|
68
69
|
if (!credential) throw new Error('apiToken must be an issued installation key');
|
|
69
70
|
const installationId = credential[1];
|
|
70
|
-
if (typeof cookieSecret !== 'string' || cookieSecret.trim().length < 32
|
|
71
|
-
|| /^<[^]*>$/.test(cookieSecret.trim())) throw new Error('cookieSecret must be at least 32 characters');
|
|
72
|
-
if (cookieSecret === apiToken) throw new Error('cookieSecret must be independent of apiToken');
|
|
71
|
+
if (typeof cookieSecret !== 'string' || cookieSecret.trim().length < 32
|
|
72
|
+
|| /^<[^]*>$/.test(cookieSecret.trim())) throw new Error('cookieSecret must be at least 32 characters');
|
|
73
|
+
if (cookieSecret === apiToken) throw new Error('cookieSecret must be independent of apiToken');
|
|
73
74
|
const endpoint = new URL(apiUrl);
|
|
74
75
|
if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error('Invalid apiUrl');
|
|
75
76
|
if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(endpoint.hostname))) {
|
|
@@ -79,8 +80,12 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
79
80
|
if (!/^[a-zA-Z0-9_-]+$/.test(cookieName)) throw new Error('Invalid cookieName');
|
|
80
81
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000) throw new Error('Invalid timeoutMs');
|
|
81
82
|
if (typeof reportEvents !== 'boolean') throw new Error('Invalid reportEvents');
|
|
83
|
+
if (typeof trafficInsights !== 'boolean') throw new Error('Invalid trafficInsights');
|
|
84
|
+
if (!['none', 'vercel', 'cloudflare'].includes(countrySource)) throw new Error('Invalid countrySource');
|
|
82
85
|
if (!Array.isArray(publicPaths) || publicPaths.some(path => typeof path !== 'string' || !/^\/[a-zA-Z0-9_\-/.]*(?:\*)?$/.test(path))) throw new Error('Invalid publicPaths');
|
|
83
86
|
if (!['selected', 'site'].includes(guardMode)) throw new Error('Invalid guardMode');
|
|
87
|
+
if (typeof countryBlocking !== 'boolean') throw new Error('Invalid countryBlocking');
|
|
88
|
+
if (countryBlocking && (countrySource === 'none' || publicPaths.length)) throw new Error('countryBlocking requires an explicit countrySource and no publicPaths bypasses');
|
|
84
89
|
const publicKeys = trustedSigningKeys(decisionPublicKey, additionalDecisionPublicKeys);
|
|
85
90
|
const verifyEndpoint = new URL('./verify', endpoint);
|
|
86
91
|
const eventsEndpoint = new URL('./events', endpoint);
|
|
@@ -120,6 +125,15 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
120
125
|
|| !Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)
|
|
121
126
|
|| payload.issued_at > now + 5000 || payload.expires_at <= now || payload.expires_at <= payload.issued_at
|
|
122
127
|
|| payload.expires_at - payload.issued_at > 30_000) throw new Error('Invalid decision context or expiry');
|
|
128
|
+
if (countryBlocking) {
|
|
129
|
+
const control = payload.country_control;
|
|
130
|
+
const country = payload.country_decision;
|
|
131
|
+
if (!control || control.version !== 1 || control.source !== input.country_control?.source || control.country !== input.country_control?.country
|
|
132
|
+
|| !country || country.version !== 1 || !['disabled', 'pass', 'country_blocked', 'country_unknown_blocked', 'country_guard_required'].includes(country.result)
|
|
133
|
+
|| (country.revision !== null && (typeof country.revision !== 'string' || !/^[a-f0-9-]{36}$/.test(country.revision)))
|
|
134
|
+
|| (country.result !== 'disabled' && country.revision === null)
|
|
135
|
+
|| (!['disabled', 'pass'].includes(country.result) && payload.action !== 'block')) throw new Error('Invalid country decision');
|
|
136
|
+
}
|
|
123
137
|
return { ...payload, receipt: envelope.decision };
|
|
124
138
|
}
|
|
125
139
|
|
|
@@ -139,27 +153,33 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
139
153
|
const value = cookieValue(request, cookieName);
|
|
140
154
|
const payload = verified(value, cookieSecret);
|
|
141
155
|
const binding = browserBinding(request);
|
|
142
|
-
return payload?.kind === 'session' && payload.site === siteId && payload.installation === installation
|
|
143
|
-
&& binding && payload.browser_hash === hash(binding) ? payload : null;
|
|
156
|
+
return payload?.kind === 'session' && payload.site === siteId && payload.installation === installation
|
|
157
|
+
&& binding && payload.browser_hash === hash(binding) ? payload : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function countryControl(request) {
|
|
161
|
+
return countryBlocking ? { country_control: { version: 1, source: countrySource,
|
|
162
|
+
country: trafficContext(request, true, countrySource).country } } : {};
|
|
144
163
|
}
|
|
145
164
|
|
|
146
165
|
async function check(request) {
|
|
147
166
|
const path = new URL(request.url).pathname;
|
|
148
167
|
if (path === verifyPath) return null;
|
|
149
168
|
if (publicPaths.some(rule => rule.endsWith('*') ? path.startsWith(rule.slice(0, -1)) : path === rule)) return null;
|
|
150
|
-
const session = validSession(request);
|
|
151
|
-
// Only link access to the page that originally requested this challenge.
|
|
152
|
-
// Sessions still authorize other configured routes without attributing them
|
|
153
|
-
// to this page's verification outcome. Legacy sessions remain usable.
|
|
154
|
-
const challenge = session?.challenge_path === path && /^[A-Za-z0-9_-]{43}$/.test(session.challenge_id || '')
|
|
155
|
-
? session.challenge_id : null;
|
|
169
|
+
const session = validSession(request);
|
|
170
|
+
// Only link access to the page that originally requested this challenge.
|
|
171
|
+
// Sessions still authorize other configured routes without attributing them
|
|
172
|
+
// to this page's verification outcome. Legacy sessions remain usable.
|
|
173
|
+
const challenge = session?.challenge_path === path && /^[A-Za-z0-9_-]{43}$/.test(session.challenge_id || '')
|
|
174
|
+
? session.challenge_id : null;
|
|
156
175
|
const eligible = request.method === 'GET' && (request.headers.get('accept') || '').includes('text/html');
|
|
157
|
-
const binding = challenge ? browserBinding(request)
|
|
158
|
-
: !session && eligible ? browserBinding(request) || randomBytes(32).toString('base64url') : null;
|
|
176
|
+
const binding = challenge ? browserBinding(request)
|
|
177
|
+
: !session && eligible ? browserBinding(request) || randomBytes(32).toString('base64url') : null;
|
|
159
178
|
let policy;
|
|
160
179
|
try {
|
|
161
|
-
|
|
162
|
-
|
|
180
|
+
const context = trafficContext(request, trafficInsights, countrySource);
|
|
181
|
+
policy = await remote('decision', { path, method: request.method, ...countryControl(request), ...(context ? { traffic_context: context } : {}), ...(guardMode === 'site' ? { guard_mode: 'site' } : {}), ...(binding ? { browser_binding: binding } : {}),
|
|
182
|
+
...(challenge ? { session_challenge: challenge } : {}) });
|
|
163
183
|
} catch (error) { return unavailable(error?.retryAfter); }
|
|
164
184
|
if (!policy || !['public', 'block', 'human', 'verified', 'entitled', 'default'].includes(policy.action)) return unavailable();
|
|
165
185
|
// An older service must not silently apply selected-path semantics here.
|
|
@@ -199,13 +219,17 @@ export function createStopnetic({ siteId, apiUrl, apiToken, decisionPublicKey, a
|
|
|
199
219
|
|| typeof payload.path !== 'string' || !/^\/[^\s?#]*$/.test(payload.path) || !binding
|
|
200
220
|
|| payload.browser_hash !== hash(binding) || !/^[A-Za-z0-9_-]{43}$/.test(payload.challenge_id || '')) return concealed();
|
|
201
221
|
try {
|
|
202
|
-
const result = await remote('verify', { path: payload.path, method: 'POST', challenge_id: payload.challenge_id, browser_binding: binding });
|
|
222
|
+
const result = await remote('verify', { path: payload.path, method: 'POST', challenge_id: payload.challenge_id, browser_binding: binding, ...countryControl(request) });
|
|
223
|
+
if (result.action === 'block' && result.country_decision && !['disabled', 'pass'].includes(result.country_decision.result)) {
|
|
224
|
+
await report(result, 'BLOCK');
|
|
225
|
+
return concealed();
|
|
226
|
+
}
|
|
203
227
|
if (result.action !== 'verified' || result.challenge_id !== payload.challenge_id) return unavailable();
|
|
204
228
|
await report(result, 'VERIFIED');
|
|
205
229
|
} catch (error) { return unavailable(error?.retryAfter); }
|
|
206
|
-
const session = signed({ v: 1, kind: 'session', site: siteId, installation,
|
|
207
|
-
browser_hash: hash(binding), challenge_id: payload.challenge_id, challenge_path: payload.path,
|
|
208
|
-
exp: Date.now() + 300_000 }, cookieSecret);
|
|
230
|
+
const session = signed({ v: 1, kind: 'session', site: siteId, installation,
|
|
231
|
+
browser_hash: hash(binding), challenge_id: payload.challenge_id, challenge_path: payload.path,
|
|
232
|
+
exp: Date.now() + 300_000 }, cookieSecret);
|
|
209
233
|
return new Response(null, { status: 204, headers: {
|
|
210
234
|
'cache-control': 'no-store',
|
|
211
235
|
'set-cookie': cookie(cookieName, session, 300),
|
package/dist/index.js
CHANGED
|
@@ -96,6 +96,13 @@ function requestHeaders(req, origin) {
|
|
|
96
96
|
if (value !== undefined) headers.set(name, value);
|
|
97
97
|
}
|
|
98
98
|
headers.set('host', new URL(origin).host);
|
|
99
|
+
for (const name of ['user-agent', 'x-vercel-ip-country', 'cf-ipcountry']) {
|
|
100
|
+
const value = req.headers[name];
|
|
101
|
+
// Optional analytics must not turn malformed metadata into an access failure.
|
|
102
|
+
if (typeof value === 'string' && value.length <= (name === 'user-agent' ? 1024 : 2)) {
|
|
103
|
+
try { headers.set(name, value); } catch { /* Leave unsupported metadata unknown. */ }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
99
106
|
return headers;
|
|
100
107
|
}
|
|
101
108
|
|
package/dist/server.js
CHANGED
|
@@ -15,6 +15,9 @@ export function optionsFromEnv(env = process.env) {
|
|
|
15
15
|
additionalDecisionPublicKeys: parseAdditionalDecisionPublicKeys(env.STOPNETIC_DECISION_ADDITIONAL_PUBLIC_KEYS),
|
|
16
16
|
cookieSecret: env.STOPNETIC_COOKIE_SECRET,
|
|
17
17
|
guardMode: 'site',
|
|
18
|
+
trafficInsights: env.STOPNETIC_TRAFFIC_INSIGHTS === 'true',
|
|
19
|
+
countryBlocking: env.STOPNETIC_COUNTRY_BLOCKING === 'true',
|
|
20
|
+
countrySource: env.STOPNETIC_COUNTRY_SOURCE || 'none',
|
|
18
21
|
verifyPath: `${accessBase}/verify`,
|
|
19
22
|
};
|
|
20
23
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Only broad categories leave the customer's server. No IP or raw user-agent.
|
|
2
|
+
export const countryCodes = 'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW'.split(' ');
|
|
3
|
+
const countries = new Set(countryCodes);
|
|
4
|
+
|
|
5
|
+
function deviceCategory(value) {
|
|
6
|
+
if (!value || value.length > 1024 || /bot|crawler|spider|curl|wget|python|headless/i.test(value)) return 'unknown';
|
|
7
|
+
if (/ipad|tablet|kindle|silk\/|playbook/i.test(value) || (/android/i.test(value) && !/mobile/i.test(value))) return 'tablet';
|
|
8
|
+
if (/mobi|iphone|ipod|windows phone/i.test(value)) return 'mobile';
|
|
9
|
+
if (/mozilla\//i.test(value) && /windows nt|macintosh|x11|cros/i.test(value)) return 'desktop';
|
|
10
|
+
return 'unknown';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function trafficContext(request, enabled, source) {
|
|
14
|
+
if (!enabled) return undefined;
|
|
15
|
+
try {
|
|
16
|
+
// Selecting a source is an explicit deployment trust decision. Never auto-detect
|
|
17
|
+
// providers from visitor-supplied headers. Origins behind Cloudflare must reject
|
|
18
|
+
// direct traffic or strip/rewrite these headers at their trusted ingress.
|
|
19
|
+
const value = source === 'vercel' ? request.headers.get('x-vercel-ip-country')
|
|
20
|
+
: source === 'cloudflare' ? request.headers.get('cf-ipcountry') : null;
|
|
21
|
+
const country = typeof value === 'string' && value.length === 2 && countries.has(value.toUpperCase()) ? value.toUpperCase() : null;
|
|
22
|
+
return { country, device: deviceCategory(request.headers.get('user-agent')), country_source: country ? source : 'unknown' };
|
|
23
|
+
} catch {
|
|
24
|
+
return { country: null, device: 'unknown', country_source: 'unknown' };
|
|
25
|
+
}
|
|
26
|
+
}
|