@stopnetic/js 0.0.1-pilot → 0.0.2-pilot

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.
@@ -0,0 +1,21 @@
1
+ // Browser-only entry: no Node imports, installation keys or cookie secrets.
2
+ export async function checkAccess(path, { signal, fetchImpl = globalThis.fetch } = {}) {
3
+ if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || /[\\\s#]/.test(path)) throw new Error('Use a same-origin page path');
4
+ const url = `/api/stopnetic/access?${new URLSearchParams({ path, mode: 'check' })}`;
5
+ const controller = new AbortController();
6
+ const abort = () => controller.abort();
7
+ if (signal?.aborted) abort();
8
+ signal?.addEventListener('abort', abort, { once: true });
9
+ const timer = setTimeout(abort, 10000);
10
+ let response, result;
11
+ try {
12
+ response = await fetchImpl(url, { credentials: 'same-origin', cache: 'no-store', signal: controller.signal, headers: { Accept: 'application/json' } });
13
+ result = await response.json();
14
+ } catch (error) { if (signal?.aborted) throw error; return { allowed: false, reason: 'unavailable' }; }
15
+ finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); }
16
+ if (response.status === 200 && result?.allowed === true) return { allowed: true };
17
+ if (response.status === 404 && result?.reason === 'verification_required') {
18
+ return { allowed: false, reason: 'verification_required', verificationUrl: `/api/stopnetic/access?${new URLSearchParams({ path })}` };
19
+ }
20
+ return { allowed: false, reason: response.status === 404 ? 'blocked' : 'unavailable' };
21
+ }
package/dist/guard.d.ts CHANGED
@@ -1,21 +1,23 @@
1
- export type StopneticOptions = {
2
- siteId: string;
3
- apiUrl: string;
4
- apiToken: string;
5
- decisionPublicKey: string;
6
- /** Explicitly trusted replacement/retiring public PEMs, at most two. */
7
- additionalDecisionPublicKeys?: string[];
8
- cookieSecret: string;
9
- verifyPath?: string;
10
- cookieName?: string;
11
- secureCookie?: boolean;
12
- fetchImpl?: typeof fetch;
13
- timeoutMs?: number;
14
- publicPaths?: string[];
15
- reportEvents?: boolean;
16
- };
17
- export function parseAdditionalDecisionPublicKeys(value?: string): string[];
18
- export function createStopnetic(options: StopneticOptions): {
19
- check(request: Request): Promise<Response | null>;
20
- verify(request: Request): Promise<Response>;
21
- };
1
+ export type StopneticOptions = {
2
+ siteId: string;
3
+ apiUrl: string;
4
+ apiToken: string;
5
+ decisionPublicKey: string;
6
+ /** Explicitly trusted replacement/retiring public PEMs, at most two. */
7
+ additionalDecisionPublicKeys?: string[];
8
+ cookieSecret: string;
9
+ verifyPath?: string;
10
+ cookieName?: string;
11
+ secureCookie?: boolean;
12
+ fetchImpl?: typeof fetch;
13
+ timeoutMs?: number;
14
+ publicPaths?: string[];
15
+ reportEvents?: boolean;
16
+ /** Whole-site mode: unmatched routes public; requires the updated service. */
17
+ guardMode?: 'selected' | 'site';
18
+ };
19
+ export function parseAdditionalDecisionPublicKeys(value?: string): string[];
20
+ export function createStopnetic(options: StopneticOptions): {
21
+ check(request: Request): Promise<Response | null>;
22
+ verify(request: Request): Promise<Response>;
23
+ };
package/dist/guard.js CHANGED
@@ -1,203 +1,216 @@
1
- import { createHash, createHmac, randomBytes, timingSafeEqual, verify as verifySignature } from 'node:crypto';
2
- import { trustedSigningKeys } from './signing-keys.js';
3
- export { parseAdditionalDecisionPublicKeys } from './signing-keys.js';
4
-
5
- const unavailable = retryAfter => new Response(null, { status: 503, headers: {
6
- 'cache-control': 'no-store', ...(retryAfter ? { 'retry-after': String(retryAfter) } : {}),
7
- } });
8
- const concealed = () => new Response(null, { status: 404, headers: { 'cache-control': 'no-store' } });
9
- const hash = value => createHash('sha256').update(value).digest('hex');
10
-
11
- function signed(payload, secret) {
12
- const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
13
- const mac = createHmac('sha256', secret).update(data).digest('base64url');
14
- return `${data}.${mac}`;
15
- }
16
-
17
- function verified(token, secret) {
18
- if (!token || typeof token !== 'string' || token.length > 8192) return null;
19
- const [data, mac, extra] = token.split('.');
20
- if (!data || !mac || extra) return null;
21
- const expected = createHmac('sha256', secret).update(data).digest();
22
- let supplied;
23
- try { supplied = Buffer.from(mac, 'base64url'); } catch { return null; }
24
- if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) return null;
25
- try {
26
- const payload = JSON.parse(Buffer.from(data, 'base64url').toString('utf8'));
27
- return payload?.v === 1 && Number.isSafeInteger(payload.exp) && payload.exp > Date.now() ? payload : null;
28
- } catch { return null; }
29
- }
30
-
31
- function cookieValue(request, name) {
32
- const found = (request.headers.get('cookie') || '').split(';').map(part => part.trim())
33
- .find(part => part.startsWith(`${name}=`));
34
- return found ? found.slice(name.length + 1) : null;
35
- }
36
-
37
- async function limitedBody(request, maxBytes) {
38
- const reader = request.body?.getReader();
39
- if (!reader) throw new Error('Missing body');
40
- const chunks = [];
41
- let length = 0;
42
- for (;;) {
43
- const { value, done } = await reader.read();
44
- if (done) break;
45
- length += value.byteLength;
46
- if (length > maxBytes) { await reader.cancel(); throw new Error('Body too large'); }
47
- chunks.push(Buffer.from(value));
48
- }
49
- return Buffer.concat(chunks, length).toString('utf8');
50
- }
51
-
52
- function bootstrap(ticket, verifyPath) {
53
- const nonce = randomBytes(16).toString('base64');
54
- const script = `fetch(${JSON.stringify(verifyPath)}, {method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body:JSON.stringify({ticket:${JSON.stringify(ticket)}})}).then(r=>{if(r.ok)location.reload()})`;
55
- const html = `<!doctype html><html><head><meta charset="utf-8"><title>Checking access</title></head><body><p>Checking access...</p><script nonce="${nonce}">${script}</script></body></html>`;
56
- return new Response(html, { status: 404, headers: {
57
- 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'vary': 'Cookie',
58
- 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'`,
59
- 'x-content-type-options': 'nosniff',
60
- } });
61
- }
62
-
63
- /** Pilot guard for server-side Next.js Proxy and Route Handlers. */
64
- 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 }) {
66
- if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(siteId || '')) throw new Error('Invalid siteId');
67
- const credential = typeof apiToken === 'string' ? apiToken.match(/^[a-z]{1,32}_([a-f0-9]{32})\.[A-Za-z0-9_-]{43}$/) : null;
68
- if (!credential) throw new Error('apiToken must be an issued installation key');
69
- const installationId = credential[1];
70
- if (typeof cookieSecret !== 'string' || cookieSecret.length < 32) throw new Error('cookieSecret must be at least 32 characters');
71
- const endpoint = new URL(apiUrl);
72
- if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error('Invalid apiUrl');
73
- if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(endpoint.hostname))) {
74
- throw new Error('apiUrl must use HTTPS outside localhost');
75
- }
76
- if (!/^\/[a-zA-Z0-9/_-]+$/.test(verifyPath)) throw new Error('Invalid verifyPath');
77
- if (!/^[a-zA-Z0-9_-]+$/.test(cookieName)) throw new Error('Invalid cookieName');
78
- if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000) throw new Error('Invalid timeoutMs');
79
- if (typeof reportEvents !== 'boolean') throw new Error('Invalid reportEvents');
80
- if (!Array.isArray(publicPaths) || publicPaths.some(path => typeof path !== 'string' || !/^\/[a-zA-Z0-9_\-/.]*(?:\*)?$/.test(path))) throw new Error('Invalid publicPaths');
81
- const publicKeys = trustedSigningKeys(decisionPublicKey, additionalDecisionPublicKeys);
82
- const verifyEndpoint = new URL('./verify', endpoint);
83
- const eventsEndpoint = new URL('./events', endpoint);
84
- const browserCookieName = `${cookieName}_browser`;
85
- const installation = hash(apiToken);
86
-
87
- function browserBinding(request) {
88
- const payload = verified(cookieValue(request, browserCookieName), cookieSecret);
89
- return payload?.kind === 'browser' && payload.site === siteId && payload.installation === installation
90
- && /^[A-Za-z0-9_-]{43}$/.test(payload.binding || '') ? payload.binding : null;
91
- }
92
-
93
- function cookie(name, value, age) {
94
- return `${name}=${value}; Max-Age=${age}; Path=/; HttpOnly; SameSite=Lax${secureCookie ? '; Secure' : ''}`;
95
- }
96
-
97
- async function remote(purpose, input) {
98
- const context = { site_id: siteId, path: input.path, method: input.method, request_id: randomBytes(16).toString('base64url') };
99
- const response = await fetchImpl(purpose === 'decision' ? endpoint : verifyEndpoint, {
100
- method: 'POST', signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
101
- headers: { authorization: `Bearer ${apiToken}`, 'content-type': 'application/json' },
102
- body: JSON.stringify({ ...input, ...context }), cache: 'no-store',
103
- });
104
- if (!response.ok) {
105
- const retry = response.headers.get('retry-after') || '';
106
- const retryAfter = response.status === 429 && /^[1-9][0-9]?$/.test(retry) && Number(retry) <= 60 ? Number(retry) : undefined;
107
- await response.body?.cancel();
108
- throw Object.assign(new Error('Policy service unavailable'), { retryAfter });
109
- }
110
- const envelope = JSON.parse(await limitedBody(response, 16_384));
111
- if (typeof envelope?.decision !== 'string' || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{86}$/.test(envelope.decision)) throw new Error('Invalid signature envelope');
112
- const [data, signature] = envelope.decision.split('.');
113
- if (!publicKeys.some(key => verifySignature(null, Buffer.from(data), key, Buffer.from(signature, 'base64url')))) throw new Error('Invalid signature');
114
- const payload = JSON.parse(Buffer.from(data, 'base64url').toString('utf8'));
115
- const now = Date.now();
116
- if (payload?.v !== 1 || payload.purpose !== purpose || payload.key_id !== installationId || Object.entries(context).some(([name, value]) => payload[name] !== value)
117
- || !Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)
118
- || payload.issued_at > now + 5000 || payload.expires_at <= now || payload.expires_at <= payload.issued_at
119
- || payload.expires_at - payload.issued_at > 30_000) throw new Error('Invalid decision context or expiry');
120
- return { ...payload, receipt: envelope.decision };
121
- }
122
-
123
- async function report(policy, outcome, session = false) {
124
- if (!reportEvents) return;
125
- try {
126
- const response = await fetchImpl(eventsEndpoint, {
127
- method: 'POST', signal: AbortSignal.timeout(Math.min(timeoutMs, 500)), redirect: 'error', cache: 'no-store',
128
- headers: { authorization: `Bearer ${apiToken}`, 'content-type': 'application/json' },
129
- body: JSON.stringify({ receipt: policy.receipt, outcome, session_verified: !!session }),
130
- });
131
- await response.body?.cancel();
132
- } catch { /* Reporting cannot change a validated access decision. */ }
133
- }
134
-
135
- function validSession(request) {
136
- const value = cookieValue(request, cookieName);
137
- const payload = verified(value, cookieSecret);
138
- const binding = browserBinding(request);
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual, verify as verifySignature } from 'node:crypto';
2
+ import { trustedSigningKeys } from './signing-keys.js';
3
+ export { parseAdditionalDecisionPublicKeys } from './signing-keys.js';
4
+
5
+ const unavailable = retryAfter => new Response(null, { status: 503, headers: {
6
+ 'cache-control': 'no-store', ...(retryAfter ? { 'retry-after': String(retryAfter) } : {}),
7
+ } });
8
+ const concealed = () => new Response(null, { status: 404, headers: { 'cache-control': 'no-store' } });
9
+ const hash = value => createHash('sha256').update(value).digest('hex');
10
+
11
+ function signed(payload, secret) {
12
+ const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
13
+ const mac = createHmac('sha256', secret).update(data).digest('base64url');
14
+ return `${data}.${mac}`;
15
+ }
16
+
17
+ function verified(token, secret) {
18
+ if (!token || typeof token !== 'string' || token.length > 8192) return null;
19
+ const [data, mac, extra] = token.split('.');
20
+ if (!data || !mac || extra) return null;
21
+ const expected = createHmac('sha256', secret).update(data).digest();
22
+ let supplied;
23
+ try { supplied = Buffer.from(mac, 'base64url'); } catch { return null; }
24
+ if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) return null;
25
+ try {
26
+ const payload = JSON.parse(Buffer.from(data, 'base64url').toString('utf8'));
27
+ return payload?.v === 1 && Number.isSafeInteger(payload.exp) && payload.exp > Date.now() ? payload : null;
28
+ } catch { return null; }
29
+ }
30
+
31
+ function cookieValue(request, name) {
32
+ const found = (request.headers.get('cookie') || '').split(';').map(part => part.trim())
33
+ .find(part => part.startsWith(`${name}=`));
34
+ return found ? found.slice(name.length + 1) : null;
35
+ }
36
+
37
+ async function limitedBody(request, maxBytes) {
38
+ const reader = request.body?.getReader();
39
+ if (!reader) throw new Error('Missing body');
40
+ const chunks = [];
41
+ let length = 0;
42
+ for (;;) {
43
+ const { value, done } = await reader.read();
44
+ if (done) break;
45
+ length += value.byteLength;
46
+ if (length > maxBytes) { await reader.cancel(); throw new Error('Body too large'); }
47
+ chunks.push(Buffer.from(value));
48
+ }
49
+ return Buffer.concat(chunks, length).toString('utf8');
50
+ }
51
+
52
+ function bootstrap(ticket, verifyPath) {
53
+ const nonce = randomBytes(16).toString('base64');
54
+ const script = `fetch(${JSON.stringify(verifyPath)}, {method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body:JSON.stringify({ticket:${JSON.stringify(ticket)}})}).then(r=>{if(r.ok)location.reload()})`;
55
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>Checking access</title></head><body><p>Checking access...</p><script nonce="${nonce}">${script}</script></body></html>`;
56
+ return new Response(html, { status: 404, headers: {
57
+ 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'vary': 'Cookie',
58
+ 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'`,
59
+ 'x-content-type-options': 'nosniff',
60
+ } });
61
+ }
62
+
63
+ /** Pilot guard for server-side Next.js Proxy and Route Handlers. */
64
+ 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
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(siteId || '')) throw new Error('Invalid siteId');
67
+ const credential = typeof apiToken === 'string' ? apiToken.match(/^[a-z]{1,32}_([a-f0-9]{32})\.[A-Za-z0-9_-]{43}$/) : null;
68
+ if (!credential) throw new Error('apiToken must be an issued installation key');
69
+ 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');
73
+ const endpoint = new URL(apiUrl);
74
+ if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new Error('Invalid apiUrl');
75
+ if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(endpoint.hostname))) {
76
+ throw new Error('apiUrl must use HTTPS outside localhost');
77
+ }
78
+ if (!/^\/[a-zA-Z0-9/_-]+$/.test(verifyPath)) throw new Error('Invalid verifyPath');
79
+ if (!/^[a-zA-Z0-9_-]+$/.test(cookieName)) throw new Error('Invalid cookieName');
80
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000) throw new Error('Invalid timeoutMs');
81
+ if (typeof reportEvents !== 'boolean') throw new Error('Invalid reportEvents');
82
+ if (!Array.isArray(publicPaths) || publicPaths.some(path => typeof path !== 'string' || !/^\/[a-zA-Z0-9_\-/.]*(?:\*)?$/.test(path))) throw new Error('Invalid publicPaths');
83
+ if (!['selected', 'site'].includes(guardMode)) throw new Error('Invalid guardMode');
84
+ const publicKeys = trustedSigningKeys(decisionPublicKey, additionalDecisionPublicKeys);
85
+ const verifyEndpoint = new URL('./verify', endpoint);
86
+ const eventsEndpoint = new URL('./events', endpoint);
87
+ const browserCookieName = `${cookieName}_browser`;
88
+ const installation = hash(apiToken);
89
+
90
+ function browserBinding(request) {
91
+ const payload = verified(cookieValue(request, browserCookieName), cookieSecret);
92
+ return payload?.kind === 'browser' && payload.site === siteId && payload.installation === installation
93
+ && /^[A-Za-z0-9_-]{43}$/.test(payload.binding || '') ? payload.binding : null;
94
+ }
95
+
96
+ function cookie(name, value, age) {
97
+ return `${name}=${value}; Max-Age=${age}; Path=/; HttpOnly; SameSite=Lax${secureCookie ? '; Secure' : ''}`;
98
+ }
99
+
100
+ async function remote(purpose, input) {
101
+ const context = { site_id: siteId, path: input.path, method: input.method, request_id: randomBytes(16).toString('base64url') };
102
+ const response = await fetchImpl(purpose === 'decision' ? endpoint : verifyEndpoint, {
103
+ method: 'POST', signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
104
+ headers: { authorization: `Bearer ${apiToken}`, 'content-type': 'application/json' },
105
+ body: JSON.stringify({ ...input, ...context }), cache: 'no-store',
106
+ });
107
+ if (!response.ok) {
108
+ const retry = response.headers.get('retry-after') || '';
109
+ const retryAfter = response.status === 429 && /^[1-9][0-9]?$/.test(retry) && Number(retry) <= 60 ? Number(retry) : undefined;
110
+ await response.body?.cancel();
111
+ throw Object.assign(new Error('Policy service unavailable'), { retryAfter });
112
+ }
113
+ const envelope = JSON.parse(await limitedBody(response, 16_384));
114
+ if (typeof envelope?.decision !== 'string' || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{86}$/.test(envelope.decision)) throw new Error('Invalid signature envelope');
115
+ const [data, signature] = envelope.decision.split('.');
116
+ if (!publicKeys.some(key => verifySignature(null, Buffer.from(data), key, Buffer.from(signature, 'base64url')))) throw new Error('Invalid signature');
117
+ const payload = JSON.parse(Buffer.from(data, 'base64url').toString('utf8'));
118
+ const now = Date.now();
119
+ if (payload?.v !== 1 || payload.purpose !== purpose || payload.key_id !== installationId || Object.entries(context).some(([name, value]) => payload[name] !== value)
120
+ || !Number.isSafeInteger(payload.issued_at) || !Number.isSafeInteger(payload.expires_at)
121
+ || payload.issued_at > now + 5000 || payload.expires_at <= now || payload.expires_at <= payload.issued_at
122
+ || payload.expires_at - payload.issued_at > 30_000) throw new Error('Invalid decision context or expiry');
123
+ return { ...payload, receipt: envelope.decision };
124
+ }
125
+
126
+ async function report(policy, outcome, session = false) {
127
+ if (!reportEvents) return;
128
+ try {
129
+ const response = await fetchImpl(eventsEndpoint, {
130
+ method: 'POST', signal: AbortSignal.timeout(Math.min(timeoutMs, 500)), redirect: 'error', cache: 'no-store',
131
+ headers: { authorization: `Bearer ${apiToken}`, 'content-type': 'application/json' },
132
+ body: JSON.stringify({ receipt: policy.receipt, outcome, session_verified: !!session }),
133
+ });
134
+ await response.body?.cancel();
135
+ } catch { /* Reporting cannot change a validated access decision. */ }
136
+ }
137
+
138
+ function validSession(request) {
139
+ const value = cookieValue(request, cookieName);
140
+ const payload = verified(value, cookieSecret);
141
+ const binding = browserBinding(request);
139
142
  return payload?.kind === 'session' && payload.site === siteId && payload.installation === installation
140
- && binding && payload.browser_hash === hash(binding);
141
- }
142
-
143
- async function check(request) {
144
- const path = new URL(request.url).pathname;
145
- if (path === verifyPath) return null;
146
- if (publicPaths.some(rule => rule.endsWith('*') ? path.startsWith(rule.slice(0, -1)) : path === rule)) return null;
143
+ && binding && payload.browser_hash === hash(binding) ? payload : null;
144
+ }
145
+
146
+ async function check(request) {
147
+ const path = new URL(request.url).pathname;
148
+ if (path === verifyPath) return null;
149
+ if (publicPaths.some(rule => rule.endsWith('*') ? path.startsWith(rule.slice(0, -1)) : path === rule)) return null;
147
150
  const session = validSession(request);
148
- const eligible = request.method === 'GET' && (request.headers.get('accept') || '').includes('text/html');
149
- const binding = !session && eligible ? browserBinding(request) || randomBytes(32).toString('base64url') : null;
150
- let policy;
151
- try {
152
- policy = await remote('decision', { path, method: request.method, ...(binding ? { browser_binding: binding } : {}) });
153
- } catch (error) { return unavailable(error?.retryAfter); }
154
- if (!policy || !['public', 'block', 'human', 'verified', 'entitled', 'default'].includes(policy.action)) return unavailable();
155
- if (policy.action === 'public') { await report(policy, 'ALLOW', session); return null; }
156
- if (policy.action === 'block' || policy.action === 'entitled' || policy.action === 'default') { await report(policy, 'BLOCK', session); return concealed(); }
157
- if (session) { await report(policy, 'ALLOW', true); return null; }
158
- if (policy.action === 'verified' || !eligible) { await report(policy, 'BLOCK'); return concealed(); }
159
- if (!/^[A-Za-z0-9_-]{43}$/.test(policy.challenge_id || '')) return unavailable();
160
- const ticket = signed({ v: 1, kind: 'challenge', site: siteId, path, exp: Date.now() + 60_000,
161
- challenge_id: policy.challenge_id, browser_hash: hash(binding), installation }, cookieSecret);
162
- const response = bootstrap(ticket, verifyPath);
163
- response.headers.set('set-cookie', cookie(browserCookieName, signed({ v: 1, kind: 'browser', site: siteId,
164
- binding, installation, exp: Date.now() + 600_000 }, cookieSecret), 600));
165
- await report(policy, 'CHALLENGE');
166
- return response;
167
- }
168
-
169
- async function verify(request) {
170
- if (request.method !== 'POST') return new Response(null, { status: 405, headers: { 'cache-control': 'no-store' } });
171
- const origin = request.headers.get('origin');
172
- if (!origin) return concealed();
173
- // Next.js may use an internal hostname in request.url. Host is the browser's
174
- // destination; retain the request URL's scheme to reject cross-scheme calls.
175
- const destination = new URL(request.url);
176
- const host = request.headers.get('host') || destination.host;
177
- if (origin !== `${destination.protocol}//${host}`) return concealed();
178
- if (!(request.headers.get('content-type') || '').toLowerCase().startsWith('application/json')) return concealed();
179
- if ((Number(request.headers.get('content-length')) || 0) > 4096) return concealed();
180
- let body;
181
- try { body = await limitedBody(request, 4096); } catch { return concealed(); }
182
- let ticket;
183
- try { ticket = JSON.parse(body).ticket; } catch { return concealed(); }
184
- const payload = verified(ticket, cookieSecret);
185
- const binding = browserBinding(request);
186
- if (payload?.kind !== 'challenge' || payload.site !== siteId || payload.installation !== installation
187
- || typeof payload.path !== 'string' || !/^\/[^\s?#]*$/.test(payload.path) || !binding
188
- || payload.browser_hash !== hash(binding) || !/^[A-Za-z0-9_-]{43}$/.test(payload.challenge_id || '')) return concealed();
189
- try {
190
- const result = await remote('verify', { path: payload.path, method: 'POST', challenge_id: payload.challenge_id, browser_binding: binding });
191
- if (result.action !== 'verified' || result.challenge_id !== payload.challenge_id) return unavailable();
192
- await report(result, 'VERIFIED');
193
- } catch (error) { return unavailable(error?.retryAfter); }
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;
156
+ 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;
159
+ let policy;
160
+ try {
161
+ policy = await remote('decision', { path, method: request.method, ...(guardMode === 'site' ? { guard_mode: 'site' } : {}), ...(binding ? { browser_binding: binding } : {}),
162
+ ...(challenge ? { session_challenge: challenge } : {}) });
163
+ } catch (error) { return unavailable(error?.retryAfter); }
164
+ if (!policy || !['public', 'block', 'human', 'verified', 'entitled', 'default'].includes(policy.action)) return unavailable();
165
+ // An older service must not silently apply selected-path semantics here.
166
+ if (guardMode === 'site' && policy.guard_mode !== 'site') return unavailable();
167
+ if (policy.action === 'public') { await report(policy, 'ALLOW', session); return null; }
168
+ if (policy.action === 'block' || policy.action === 'entitled' || policy.action === 'default') { await report(policy, 'BLOCK', session); return concealed(); }
169
+ if (session) { await report(policy, 'ALLOW', true); return null; }
170
+ if (policy.action === 'verified' || !eligible) { await report(policy, 'BLOCK'); return concealed(); }
171
+ if (!/^[A-Za-z0-9_-]{43}$/.test(policy.challenge_id || '')) return unavailable();
172
+ const ticket = signed({ v: 1, kind: 'challenge', site: siteId, path, exp: Date.now() + 60_000,
173
+ challenge_id: policy.challenge_id, browser_hash: hash(binding), installation }, cookieSecret);
174
+ const response = bootstrap(ticket, verifyPath);
175
+ response.headers.set('set-cookie', cookie(browserCookieName, signed({ v: 1, kind: 'browser', site: siteId,
176
+ binding, installation, exp: Date.now() + 600_000 }, cookieSecret), 600));
177
+ await report(policy, 'CHALLENGE');
178
+ return response;
179
+ }
180
+
181
+ async function verify(request) {
182
+ if (request.method !== 'POST') return new Response(null, { status: 405, headers: { 'cache-control': 'no-store' } });
183
+ const origin = request.headers.get('origin');
184
+ if (!origin) return concealed();
185
+ // Next.js may use an internal hostname in request.url. Host is the browser's
186
+ // destination; retain the request URL's scheme to reject cross-scheme calls.
187
+ const destination = new URL(request.url);
188
+ const host = request.headers.get('host') || destination.host;
189
+ if (origin !== `${destination.protocol}//${host}`) return concealed();
190
+ if (!(request.headers.get('content-type') || '').toLowerCase().startsWith('application/json')) return concealed();
191
+ if ((Number(request.headers.get('content-length')) || 0) > 4096) return concealed();
192
+ let body;
193
+ try { body = await limitedBody(request, 4096); } catch { return concealed(); }
194
+ let ticket;
195
+ try { ticket = JSON.parse(body).ticket; } catch { return concealed(); }
196
+ const payload = verified(ticket, cookieSecret);
197
+ const binding = browserBinding(request);
198
+ if (payload?.kind !== 'challenge' || payload.site !== siteId || payload.installation !== installation
199
+ || typeof payload.path !== 'string' || !/^\/[^\s?#]*$/.test(payload.path) || !binding
200
+ || payload.browser_hash !== hash(binding) || !/^[A-Za-z0-9_-]{43}$/.test(payload.challenge_id || '')) return concealed();
201
+ try {
202
+ const result = await remote('verify', { path: payload.path, method: 'POST', challenge_id: payload.challenge_id, browser_binding: binding });
203
+ if (result.action !== 'verified' || result.challenge_id !== payload.challenge_id) return unavailable();
204
+ await report(result, 'VERIFIED');
205
+ } catch (error) { return unavailable(error?.retryAfter); }
194
206
  const session = signed({ v: 1, kind: 'session', site: siteId, installation,
195
- browser_hash: hash(binding), exp: Date.now() + 300_000 }, cookieSecret);
196
- return new Response(null, { status: 204, headers: {
197
- 'cache-control': 'no-store',
198
- 'set-cookie': cookie(cookieName, session, 300),
199
- } });
200
- }
201
-
202
- return { check, verify };
203
- }
207
+ browser_hash: hash(binding), challenge_id: payload.challenge_id, challenge_path: payload.path,
208
+ exp: Date.now() + 300_000 }, cookieSecret);
209
+ return new Response(null, { status: 204, headers: {
210
+ 'cache-control': 'no-store',
211
+ 'set-cookie': cookie(cookieName, session, 300),
212
+ } });
213
+ }
214
+
215
+ return { check, verify };
216
+ }
package/dist/index.d.ts CHANGED
@@ -1,15 +1,15 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import type { StopneticOptions } from './guard.js';
3
- export { createStopnetic, parseAdditionalDecisionPublicKeys } from './guard.js';
4
- export type { StopneticOptions } from './guard.js';
5
- export type StopneticServerOptions = StopneticOptions & {
6
- /** Public website origin, e.g. https://example.com. Never taken from forwarded headers. */
7
- origin: string;
8
- };
9
- export function createStopneticMiddleware(options: StopneticServerOptions): (
10
- request: IncomingMessage & { originalUrl?: string }, response: ServerResponse,
11
- next: () => unknown,
12
- ) => Promise<unknown>;
13
- export function createStopneticHandler(options: StopneticServerOptions): (
14
- request: Request, next: (request: Request) => Response | Promise<Response>,
15
- ) => Promise<Response>;
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { StopneticOptions } from './guard.js';
3
+ export { createStopnetic, parseAdditionalDecisionPublicKeys } from './guard.js';
4
+ export type { StopneticOptions } from './guard.js';
5
+ export type StopneticServerOptions = StopneticOptions & {
6
+ /** Public website origin, e.g. https://example.com. Never taken from forwarded headers. */
7
+ origin: string;
8
+ };
9
+ export function createStopneticMiddleware(options: StopneticServerOptions): (
10
+ request: IncomingMessage & { originalUrl?: string }, response: ServerResponse,
11
+ next: () => unknown,
12
+ ) => Promise<unknown>;
13
+ export function createStopneticHandler(options: StopneticServerOptions): (
14
+ request: Request, next: (request: Request) => Response | Promise<Response>,
15
+ ) => Promise<Response>;