@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.
- package/LICENSE +21 -21
- package/README.md +322 -199
- package/bin/stopnetic-js.js +34 -0
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +21 -0
- package/dist/guard.d.ts +23 -21
- package/dist/guard.js +213 -200
- package/dist/index.d.ts +15 -15
- package/dist/index.js +172 -172
- package/dist/react.d.ts +4 -0
- package/dist/react.js +24 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.js +107 -0
- package/dist/setup.js +165 -0
- package/dist/signing-keys.js +37 -26
- package/package.json +54 -47
package/dist/index.js
CHANGED
|
@@ -1,172 +1,172 @@
|
|
|
1
|
-
import { createStopnetic } from './guard.js';
|
|
2
|
-
export { createStopnetic, parseAdditionalDecisionPublicKeys } from './guard.js';
|
|
3
|
-
|
|
4
|
-
const denied = () => new Response(null, { status: 404, headers: { 'cache-control': 'no-store' } });
|
|
5
|
-
const unavailable = () => new Response(null, { status: 503, headers: { 'cache-control': 'no-store' } });
|
|
6
|
-
|
|
7
|
-
function canonicalOrigin(value) {
|
|
8
|
-
let url;
|
|
9
|
-
try { url = new URL(value); } catch { throw new Error('origin must be an HTTPS origin (HTTP only on localhost)'); }
|
|
10
|
-
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash
|
|
11
|
-
|| !(url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
|
|
12
|
-
throw new Error('origin must be an HTTPS origin (HTTP only on localhost)');
|
|
13
|
-
}
|
|
14
|
-
return url.origin;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function target(raw, origin) {
|
|
18
|
-
if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//') || /[\\\s#]/.test(raw)) throw new Error('Invalid request target');
|
|
19
|
-
const url = new URL(raw, origin);
|
|
20
|
-
// Do not let URL normalization or downstream decoding change the checked path.
|
|
21
|
-
if (url.origin !== origin || url.pathname + url.search !== raw) throw new Error('Invalid request target');
|
|
22
|
-
const decoded = decodeURIComponent(url.pathname);
|
|
23
|
-
if (/[\\\s?#%]/.test(decoded) || /[\x00-\x1f\x7f]/.test(decoded)
|
|
24
|
-
|| decoded.split('/').some(part => part === '.' || part === '..')
|
|
25
|
-
|| /%[0-7][0-9a-f]/i.test(url.pathname) || url.pathname.includes('//')) throw new Error('Ambiguous request path');
|
|
26
|
-
return url;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function optionsForGuard(options, origin) {
|
|
30
|
-
const local = origin.startsWith('http:');
|
|
31
|
-
if (!local && options.secureCookie === false) throw new Error('HTTPS sites require secure cookies');
|
|
32
|
-
// A verification request must reach the verification handler, not a bypass.
|
|
33
|
-
return { ...options, secureCookie: !local };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function boundedWebBody(request) {
|
|
37
|
-
if (Number(request.headers.get('content-length')) > 4096) throw new Error('Body too large');
|
|
38
|
-
const reader = request.body?.getReader();
|
|
39
|
-
if (!reader) throw new Error('Missing body');
|
|
40
|
-
const chunks = [];
|
|
41
|
-
let size = 0;
|
|
42
|
-
let timer;
|
|
43
|
-
let finished = false;
|
|
44
|
-
const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Body timeout')), 5000); });
|
|
45
|
-
try {
|
|
46
|
-
for (;;) {
|
|
47
|
-
const { value, done } = await Promise.race([reader.read(), deadline]);
|
|
48
|
-
if (done) { finished = true; return Buffer.concat(chunks, size); }
|
|
49
|
-
size += value.byteLength;
|
|
50
|
-
if (size > 4096) throw new Error('Body too large');
|
|
51
|
-
chunks.push(Buffer.from(value));
|
|
52
|
-
}
|
|
53
|
-
} finally {
|
|
54
|
-
clearTimeout(timer);
|
|
55
|
-
if (!finished) void reader.cancel().catch(() => {});
|
|
56
|
-
reader.releaseLock();
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Fetch-style adapter for Node-hosted frameworks. Always handles verification. */
|
|
61
|
-
export function createStopneticHandler(options) {
|
|
62
|
-
const origin = canonicalOrigin(options.origin);
|
|
63
|
-
const guard = createStopnetic(optionsForGuard(options, origin));
|
|
64
|
-
const verifyPath = options.verifyPath || '/__stopnetic/verify';
|
|
65
|
-
return async function handle(request, next) {
|
|
66
|
-
let response;
|
|
67
|
-
try {
|
|
68
|
-
const url = new URL(request.url);
|
|
69
|
-
if (url.origin !== origin || (request.headers.has('host') && request.headers.get('host').toLowerCase() !== url.host)) return denied();
|
|
70
|
-
target(url.pathname + url.search, origin);
|
|
71
|
-
if (url.pathname === verifyPath) {
|
|
72
|
-
if (request.method !== 'POST' || request.headers.get('origin') !== origin
|
|
73
|
-
|| !(request.headers.get('content-type') || '').toLowerCase().startsWith('application/json')) {
|
|
74
|
-
return request.method === 'POST' ? denied() : new Response(null, { status: 405, headers: { allow: 'POST', 'cache-control': 'no-store' } });
|
|
75
|
-
}
|
|
76
|
-
try {
|
|
77
|
-
const body = await boundedWebBody(request);
|
|
78
|
-
response = await guard.verify(new Request(request.url, { method: 'POST', headers: request.headers, body }));
|
|
79
|
-
} catch { return denied(); }
|
|
80
|
-
} else response = await guard.check(request);
|
|
81
|
-
} catch { return unavailable(); }
|
|
82
|
-
if (response) return response;
|
|
83
|
-
const result = await next(request);
|
|
84
|
-
// Copy headers, including individual cookies, without buffering the body.
|
|
85
|
-
const headers = new Headers(result.headers);
|
|
86
|
-
headers.set('cache-control', 'private, no-store');
|
|
87
|
-
return new Response(result.body, { status: result.status, statusText: result.statusText, headers });
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function requestHeaders(req, origin) {
|
|
92
|
-
const headers = new Headers();
|
|
93
|
-
for (const name of ['accept', 'cookie', 'origin', 'content-type', 'content-length']) {
|
|
94
|
-
const value = req.headers[name];
|
|
95
|
-
if (Array.isArray(value)) throw new Error('Ambiguous request headers');
|
|
96
|
-
if (value !== undefined) headers.set(name, value);
|
|
97
|
-
}
|
|
98
|
-
headers.set('host', new URL(origin).host);
|
|
99
|
-
return headers;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function verificationBody(req) {
|
|
103
|
-
return new Promise((resolve, reject) => {
|
|
104
|
-
if (req.readableEnded || req.destroyed) { reject(new Error('Verification body unavailable')); return; }
|
|
105
|
-
const chunks = [];
|
|
106
|
-
let size = 0;
|
|
107
|
-
const cleanup = () => {
|
|
108
|
-
clearTimeout(timer);
|
|
109
|
-
req.off('data', data); req.off('end', end); req.off('error', fail); req.off('aborted', fail);
|
|
110
|
-
};
|
|
111
|
-
const fail = () => { cleanup(); req.pause(); reject(new Error('Invalid verification body')); };
|
|
112
|
-
const data = chunk => {
|
|
113
|
-
const bytes = Buffer.from(chunk);
|
|
114
|
-
size += bytes.length;
|
|
115
|
-
if (size > 4096) { fail(); return; }
|
|
116
|
-
chunks.push(bytes);
|
|
117
|
-
};
|
|
118
|
-
const end = () => { cleanup(); resolve(Buffer.concat(chunks, size)); };
|
|
119
|
-
const timer = setTimeout(fail, 5000);
|
|
120
|
-
req.on('data', data); req.once('end', end); req.once('error', fail); req.once('aborted', fail);
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async function send(response, req, res, close = false) {
|
|
125
|
-
res.statusCode = response.status;
|
|
126
|
-
response.headers.forEach((value, name) => { if (name !== 'set-cookie') res.setHeader(name, value); });
|
|
127
|
-
const cookies = response.headers.getSetCookie();
|
|
128
|
-
if (cookies.length) res.setHeader('set-cookie', cookies);
|
|
129
|
-
if (close) res.setHeader('connection', 'close');
|
|
130
|
-
const body = req.method === 'HEAD' ? undefined : Buffer.from(await response.arrayBuffer());
|
|
131
|
-
res.end(body);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Use first, at the application root, before parsers, static files and routes. */
|
|
135
|
-
export function createStopneticMiddleware(options) {
|
|
136
|
-
const origin = canonicalOrigin(options.origin);
|
|
137
|
-
const guard = createStopnetic(optionsForGuard(options, origin));
|
|
138
|
-
const verifyPath = options.verifyPath || '/__stopnetic/verify';
|
|
139
|
-
return async function stopnetic(req, res, next) {
|
|
140
|
-
let response;
|
|
141
|
-
let close = false;
|
|
142
|
-
try {
|
|
143
|
-
const host = req.headers.host;
|
|
144
|
-
if (typeof host !== 'string' || host.toLowerCase() !== new URL(origin).host) response = denied();
|
|
145
|
-
else {
|
|
146
|
-
const url = target(req.originalUrl || req.url, origin);
|
|
147
|
-
const headers = requestHeaders(req, origin);
|
|
148
|
-
const method = req.method || 'GET';
|
|
149
|
-
if (url.pathname === verifyPath) {
|
|
150
|
-
if (method !== 'POST') response = new Response(null, { status: 405, headers: { allow: 'POST', 'cache-control': 'no-store' } });
|
|
151
|
-
else if (headers.get('origin') !== origin || !(headers.get('content-type') || '').toLowerCase().startsWith('application/json')
|
|
152
|
-
|| Number(headers.get('content-length')) > 4096) { response = denied(); close = true; }
|
|
153
|
-
else {
|
|
154
|
-
try {
|
|
155
|
-
const body = await verificationBody(req);
|
|
156
|
-
response = await guard.verify(new Request(url, { method, headers, body }));
|
|
157
|
-
} catch { response = denied(); close = true; }
|
|
158
|
-
}
|
|
159
|
-
} else {
|
|
160
|
-
// Checking ordinary requests must leave their bodies for the application.
|
|
161
|
-
response = await guard.check(new Request(url, { method, headers }));
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
} catch { response = unavailable(); close = true; }
|
|
165
|
-
if (response) {
|
|
166
|
-
try { await send(response, req, res, close); } catch { res.destroy(); }
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
res.setHeader('cache-control', 'private, no-store');
|
|
170
|
-
return next();
|
|
171
|
-
};
|
|
172
|
-
}
|
|
1
|
+
import { createStopnetic } from './guard.js';
|
|
2
|
+
export { createStopnetic, parseAdditionalDecisionPublicKeys } from './guard.js';
|
|
3
|
+
|
|
4
|
+
const denied = () => new Response(null, { status: 404, headers: { 'cache-control': 'no-store' } });
|
|
5
|
+
const unavailable = () => new Response(null, { status: 503, headers: { 'cache-control': 'no-store' } });
|
|
6
|
+
|
|
7
|
+
function canonicalOrigin(value) {
|
|
8
|
+
let url;
|
|
9
|
+
try { url = new URL(value); } catch { throw new Error('origin must be an HTTPS origin (HTTP only on localhost)'); }
|
|
10
|
+
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash
|
|
11
|
+
|| !(url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
|
|
12
|
+
throw new Error('origin must be an HTTPS origin (HTTP only on localhost)');
|
|
13
|
+
}
|
|
14
|
+
return url.origin;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function target(raw, origin) {
|
|
18
|
+
if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//') || /[\\\s#]/.test(raw)) throw new Error('Invalid request target');
|
|
19
|
+
const url = new URL(raw, origin);
|
|
20
|
+
// Do not let URL normalization or downstream decoding change the checked path.
|
|
21
|
+
if (url.origin !== origin || url.pathname + url.search !== raw) throw new Error('Invalid request target');
|
|
22
|
+
const decoded = decodeURIComponent(url.pathname);
|
|
23
|
+
if (/[\\\s?#%]/.test(decoded) || /[\x00-\x1f\x7f]/.test(decoded)
|
|
24
|
+
|| decoded.split('/').some(part => part === '.' || part === '..')
|
|
25
|
+
|| /%[0-7][0-9a-f]/i.test(url.pathname) || url.pathname.includes('//')) throw new Error('Ambiguous request path');
|
|
26
|
+
return url;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function optionsForGuard(options, origin) {
|
|
30
|
+
const local = origin.startsWith('http:');
|
|
31
|
+
if (!local && options.secureCookie === false) throw new Error('HTTPS sites require secure cookies');
|
|
32
|
+
// A verification request must reach the verification handler, not a bypass.
|
|
33
|
+
return { ...options, secureCookie: !local };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function boundedWebBody(request) {
|
|
37
|
+
if (Number(request.headers.get('content-length')) > 4096) throw new Error('Body too large');
|
|
38
|
+
const reader = request.body?.getReader();
|
|
39
|
+
if (!reader) throw new Error('Missing body');
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let size = 0;
|
|
42
|
+
let timer;
|
|
43
|
+
let finished = false;
|
|
44
|
+
const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Body timeout')), 5000); });
|
|
45
|
+
try {
|
|
46
|
+
for (;;) {
|
|
47
|
+
const { value, done } = await Promise.race([reader.read(), deadline]);
|
|
48
|
+
if (done) { finished = true; return Buffer.concat(chunks, size); }
|
|
49
|
+
size += value.byteLength;
|
|
50
|
+
if (size > 4096) throw new Error('Body too large');
|
|
51
|
+
chunks.push(Buffer.from(value));
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
if (!finished) void reader.cancel().catch(() => {});
|
|
56
|
+
reader.releaseLock();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Fetch-style adapter for Node-hosted frameworks. Always handles verification. */
|
|
61
|
+
export function createStopneticHandler(options) {
|
|
62
|
+
const origin = canonicalOrigin(options.origin);
|
|
63
|
+
const guard = createStopnetic(optionsForGuard(options, origin));
|
|
64
|
+
const verifyPath = options.verifyPath || '/__stopnetic/verify';
|
|
65
|
+
return async function handle(request, next) {
|
|
66
|
+
let response;
|
|
67
|
+
try {
|
|
68
|
+
const url = new URL(request.url);
|
|
69
|
+
if (url.origin !== origin || (request.headers.has('host') && request.headers.get('host').toLowerCase() !== url.host)) return denied();
|
|
70
|
+
target(url.pathname + url.search, origin);
|
|
71
|
+
if (url.pathname === verifyPath) {
|
|
72
|
+
if (request.method !== 'POST' || request.headers.get('origin') !== origin
|
|
73
|
+
|| !(request.headers.get('content-type') || '').toLowerCase().startsWith('application/json')) {
|
|
74
|
+
return request.method === 'POST' ? denied() : new Response(null, { status: 405, headers: { allow: 'POST', 'cache-control': 'no-store' } });
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const body = await boundedWebBody(request);
|
|
78
|
+
response = await guard.verify(new Request(request.url, { method: 'POST', headers: request.headers, body }));
|
|
79
|
+
} catch { return denied(); }
|
|
80
|
+
} else response = await guard.check(request);
|
|
81
|
+
} catch { return unavailable(); }
|
|
82
|
+
if (response) return response;
|
|
83
|
+
const result = await next(request);
|
|
84
|
+
// Copy headers, including individual cookies, without buffering the body.
|
|
85
|
+
const headers = new Headers(result.headers);
|
|
86
|
+
headers.set('cache-control', 'private, no-store');
|
|
87
|
+
return new Response(result.body, { status: result.status, statusText: result.statusText, headers });
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function requestHeaders(req, origin) {
|
|
92
|
+
const headers = new Headers();
|
|
93
|
+
for (const name of ['accept', 'cookie', 'origin', 'content-type', 'content-length']) {
|
|
94
|
+
const value = req.headers[name];
|
|
95
|
+
if (Array.isArray(value)) throw new Error('Ambiguous request headers');
|
|
96
|
+
if (value !== undefined) headers.set(name, value);
|
|
97
|
+
}
|
|
98
|
+
headers.set('host', new URL(origin).host);
|
|
99
|
+
return headers;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function verificationBody(req) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
if (req.readableEnded || req.destroyed) { reject(new Error('Verification body unavailable')); return; }
|
|
105
|
+
const chunks = [];
|
|
106
|
+
let size = 0;
|
|
107
|
+
const cleanup = () => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
req.off('data', data); req.off('end', end); req.off('error', fail); req.off('aborted', fail);
|
|
110
|
+
};
|
|
111
|
+
const fail = () => { cleanup(); req.pause(); reject(new Error('Invalid verification body')); };
|
|
112
|
+
const data = chunk => {
|
|
113
|
+
const bytes = Buffer.from(chunk);
|
|
114
|
+
size += bytes.length;
|
|
115
|
+
if (size > 4096) { fail(); return; }
|
|
116
|
+
chunks.push(bytes);
|
|
117
|
+
};
|
|
118
|
+
const end = () => { cleanup(); resolve(Buffer.concat(chunks, size)); };
|
|
119
|
+
const timer = setTimeout(fail, 5000);
|
|
120
|
+
req.on('data', data); req.once('end', end); req.once('error', fail); req.once('aborted', fail);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function send(response, req, res, close = false) {
|
|
125
|
+
res.statusCode = response.status;
|
|
126
|
+
response.headers.forEach((value, name) => { if (name !== 'set-cookie') res.setHeader(name, value); });
|
|
127
|
+
const cookies = response.headers.getSetCookie();
|
|
128
|
+
if (cookies.length) res.setHeader('set-cookie', cookies);
|
|
129
|
+
if (close) res.setHeader('connection', 'close');
|
|
130
|
+
const body = req.method === 'HEAD' ? undefined : Buffer.from(await response.arrayBuffer());
|
|
131
|
+
res.end(body);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Use first, at the application root, before parsers, static files and routes. */
|
|
135
|
+
export function createStopneticMiddleware(options) {
|
|
136
|
+
const origin = canonicalOrigin(options.origin);
|
|
137
|
+
const guard = createStopnetic(optionsForGuard(options, origin));
|
|
138
|
+
const verifyPath = options.verifyPath || '/__stopnetic/verify';
|
|
139
|
+
return async function stopnetic(req, res, next) {
|
|
140
|
+
let response;
|
|
141
|
+
let close = false;
|
|
142
|
+
try {
|
|
143
|
+
const host = req.headers.host;
|
|
144
|
+
if (typeof host !== 'string' || host.toLowerCase() !== new URL(origin).host) response = denied();
|
|
145
|
+
else {
|
|
146
|
+
const url = target(req.originalUrl || req.url, origin);
|
|
147
|
+
const headers = requestHeaders(req, origin);
|
|
148
|
+
const method = req.method || 'GET';
|
|
149
|
+
if (url.pathname === verifyPath) {
|
|
150
|
+
if (method !== 'POST') response = new Response(null, { status: 405, headers: { allow: 'POST', 'cache-control': 'no-store' } });
|
|
151
|
+
else if (headers.get('origin') !== origin || !(headers.get('content-type') || '').toLowerCase().startsWith('application/json')
|
|
152
|
+
|| Number(headers.get('content-length')) > 4096) { response = denied(); close = true; }
|
|
153
|
+
else {
|
|
154
|
+
try {
|
|
155
|
+
const body = await verificationBody(req);
|
|
156
|
+
response = await guard.verify(new Request(url, { method, headers, body }));
|
|
157
|
+
} catch { response = denied(); close = true; }
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
// Checking ordinary requests must leave their bodies for the application.
|
|
161
|
+
response = await guard.check(new Request(url, { method, headers }));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch { response = unavailable(); close = true; }
|
|
165
|
+
if (response) {
|
|
166
|
+
try { await send(response, req, res, close); } catch { res.destroy(); }
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
res.setHeader('cache-control', 'private, no-store');
|
|
170
|
+
return next();
|
|
171
|
+
};
|
|
172
|
+
}
|
package/dist/react.d.ts
ADDED
package/dist/react.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createElement, useEffect, useState } from 'react';
|
|
2
|
+
import { checkAccess } from './browser.js';
|
|
3
|
+
|
|
4
|
+
/** UI boundary only. Keep private data in independently guarded server endpoints. */
|
|
5
|
+
export function StopneticBoundary(props) {
|
|
6
|
+
return createElement(CheckedBoundary, { ...props, key: props.path });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function CheckedBoundary({ path, children, pending = 'Checking access…', blocked = 'Access unavailable.', redirect = true }) {
|
|
10
|
+
const [result, setResult] = useState(null);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
checkAccess(path, { signal: controller.signal }).then(value => {
|
|
14
|
+
if (!controller.signal.aborted) {
|
|
15
|
+
setResult({ ...value, path });
|
|
16
|
+
if (redirect && value.reason === 'verification_required') window.location.assign(value.verificationUrl);
|
|
17
|
+
}
|
|
18
|
+
}).catch(() => { if (!controller.signal.aborted) setResult({ allowed: false, reason: 'unavailable', path }); });
|
|
19
|
+
return () => controller.abort();
|
|
20
|
+
}, [path, redirect]);
|
|
21
|
+
if (!result || result.path !== path) return createElement('div', { role: 'status' }, pending);
|
|
22
|
+
if (!result.allowed) return createElement('div', { role: 'alert' }, blocked);
|
|
23
|
+
return children;
|
|
24
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { StopneticServerOptions } from './index.js';
|
|
3
|
+
export const accessBase: '/api/stopnetic';
|
|
4
|
+
export function optionsFromEnv(env?: Record<string, string | undefined>): StopneticServerOptions;
|
|
5
|
+
export function createStopneticAccessHandler(options?: StopneticServerOptions): (request: Request) => Promise<Response>;
|
|
6
|
+
export function withStopnetic<Context extends unknown[]>(handler: (request: Request, ...context: Context) => Response | Promise<Response>,
|
|
7
|
+
options?: StopneticServerOptions): (request: Request, ...context: Context) => Promise<Response>;
|
|
8
|
+
export function createStopneticAccessMiddleware(options?: StopneticServerOptions): (
|
|
9
|
+
request: IncomingMessage & { originalUrl?: string }, response: ServerResponse, next: () => unknown,
|
|
10
|
+
) => Promise<unknown>;
|
|
11
|
+
export function createStopneticServer(options?: StopneticServerOptions): {
|
|
12
|
+
access: ReturnType<typeof createStopneticAccessMiddleware>;
|
|
13
|
+
guard: ReturnType<typeof import('./index.js').createStopneticMiddleware>;
|
|
14
|
+
};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createStopneticHandler, createStopneticMiddleware, parseAdditionalDecisionPublicKeys } from './index.js';
|
|
2
|
+
|
|
3
|
+
export const accessBase = '/api/stopnetic';
|
|
4
|
+
|
|
5
|
+
/** Server environment only. Never import this module into browser code. */
|
|
6
|
+
export function optionsFromEnv(env = process.env) {
|
|
7
|
+
return {
|
|
8
|
+
origin: env.STOPNETIC_WEBSITE_ORIGIN,
|
|
9
|
+
siteId: env.STOPNETIC_SITE_ID,
|
|
10
|
+
apiUrl: env.STOPNETIC_API_URL,
|
|
11
|
+
apiToken: env.STOPNETIC_API_TOKEN,
|
|
12
|
+
decisionPublicKey: env.STOPNETIC_DECISION_PUBLIC_KEY?.replaceAll('\\n', '\n'),
|
|
13
|
+
additionalDecisionPublicKeys: parseAdditionalDecisionPublicKeys(env.STOPNETIC_DECISION_ADDITIONAL_PUBLIC_KEYS),
|
|
14
|
+
cookieSecret: env.STOPNETIC_COOKIE_SECRET,
|
|
15
|
+
guardMode: 'site',
|
|
16
|
+
verifyPath: `${accessBase}/verify`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const empty = status => new Response(null, { status, headers: { 'cache-control': 'private, no-store' } });
|
|
21
|
+
function checkedPath(path, origin) {
|
|
22
|
+
if (typeof path !== 'string' || path.length > 2048 || !path.startsWith('/') || path.startsWith('//')
|
|
23
|
+
|| /[\\\s#]/.test(path)) throw new Error('Invalid page path');
|
|
24
|
+
const url = new URL(path, origin);
|
|
25
|
+
if (url.origin !== origin || url.pathname + url.search !== path
|
|
26
|
+
|| url.pathname === accessBase || url.pathname.startsWith(`${accessBase}/`)) throw new Error('Invalid page path');
|
|
27
|
+
return url;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Same-origin UI checks. These never authorize a separate data handler. */
|
|
31
|
+
export function createStopneticAccessHandler(options = optionsFromEnv()) {
|
|
32
|
+
const origin = new URL(options.origin).origin;
|
|
33
|
+
const verifyPath = `${accessBase}/verify`;
|
|
34
|
+
const protect = createStopneticHandler({ ...options, verifyPath });
|
|
35
|
+
return async function access(request) {
|
|
36
|
+
const url = new URL(request.url);
|
|
37
|
+
if (url.origin !== origin || (request.headers.has('host') && request.headers.get('host').toLowerCase() !== url.host)) return empty(404);
|
|
38
|
+
if (url.pathname === verifyPath) return protect(request, () => empty(404));
|
|
39
|
+
if (url.pathname !== `${accessBase}/access`) return empty(404);
|
|
40
|
+
if (request.method !== 'GET') return new Response(null, { status: 405, headers: { allow: 'GET', 'cache-control': 'private, no-store' } });
|
|
41
|
+
let target;
|
|
42
|
+
try { target = checkedPath(url.searchParams.get('path'), origin); } catch { return empty(400); }
|
|
43
|
+
if (url.searchParams.getAll('path').length !== 1 || url.searchParams.getAll('mode').length > 1
|
|
44
|
+
|| [...url.searchParams.keys()].some(key => !['path', 'mode'].includes(key))
|
|
45
|
+
|| (url.searchParams.has('mode') && url.searchParams.get('mode') !== 'check')) return empty(400);
|
|
46
|
+
// Navigation checks use the real page path and cookie, not the control URL.
|
|
47
|
+
const headers = new Headers(request.headers);
|
|
48
|
+
headers.set('accept', 'text/html');
|
|
49
|
+
const check = url.searchParams.get('mode') === 'check';
|
|
50
|
+
const response = await protect(new Request(target, { headers }), () => check
|
|
51
|
+
? Response.json({ allowed: true })
|
|
52
|
+
: new Response(null, { status: 303, headers: { location: target.pathname + target.search } }));
|
|
53
|
+
if (!check || response.ok) return response;
|
|
54
|
+
const challenge = response.status === 404 && response.headers.get('content-type')?.startsWith('text/html');
|
|
55
|
+
const resultHeaders = new Headers({ 'cache-control': 'private, no-store' });
|
|
56
|
+
for (const cookie of response.headers.getSetCookie()) resultHeaders.append('set-cookie', cookie);
|
|
57
|
+
await response.body?.cancel();
|
|
58
|
+
return Response.json({ allowed: false, reason: challenge ? 'verification_required' : response.status === 404 ? 'blocked' : 'unavailable' },
|
|
59
|
+
{ status: response.status, headers: resultHeaders });
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Wrap EACH protected Fetch-style API; the UI's allowed flag is never trusted. */
|
|
64
|
+
export function withStopnetic(handler, options = optionsFromEnv()) {
|
|
65
|
+
const protect = createStopneticHandler(options);
|
|
66
|
+
return (request, ...context) => {
|
|
67
|
+
// Netlify's default function alias must not bypass a custom API path's rule.
|
|
68
|
+
// Declare a custom config.path for these functions and use it in Policies.
|
|
69
|
+
if (new URL(request.url).pathname.startsWith('/.netlify/functions/')) return Promise.resolve(empty(404));
|
|
70
|
+
return protect(request, checked => handler(checked, ...context));
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Node/Express: mount first, then the root guard, then parsers/static/API handlers. */
|
|
75
|
+
export function createStopneticAccessMiddleware(options = optionsFromEnv()) {
|
|
76
|
+
const access = createStopneticAccessHandler(options);
|
|
77
|
+
const verification = createStopneticMiddleware({ ...options, verifyPath: `${accessBase}/verify` });
|
|
78
|
+
return async (req, res, next) => {
|
|
79
|
+
const path = (req.originalUrl || req.url || '').split('?')[0];
|
|
80
|
+
if (path !== `${accessBase}/access` && path !== `${accessBase}/verify`) return next();
|
|
81
|
+
if (path === `${accessBase}/verify`) return verification(req, res, next);
|
|
82
|
+
try {
|
|
83
|
+
const headers = new Headers();
|
|
84
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
85
|
+
if (Array.isArray(value)) { res.writeHead(400, { 'cache-control': 'no-store' }); res.end(); return; }
|
|
86
|
+
if (value !== undefined) headers.set(name, value);
|
|
87
|
+
}
|
|
88
|
+
const request = new Request(new URL(req.originalUrl || req.url, options.origin), {
|
|
89
|
+
method: req.method, headers,
|
|
90
|
+
...(['GET', 'HEAD'].includes(req.method) ? {} : { body: req, duplex: 'half' }),
|
|
91
|
+
});
|
|
92
|
+
const response = await access(request);
|
|
93
|
+
res.statusCode = response.status;
|
|
94
|
+
response.headers.forEach((value, name) => { if (name !== 'set-cookie') res.setHeader(name, value); });
|
|
95
|
+
const cookies = response.headers.getSetCookie();
|
|
96
|
+
if (cookies.length) res.setHeader('set-cookie', cookies);
|
|
97
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
98
|
+
} catch { res.writeHead(503, { 'cache-control': 'private, no-store' }); res.end(); }
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createStopneticServer(options = optionsFromEnv()) {
|
|
103
|
+
return {
|
|
104
|
+
access: createStopneticAccessMiddleware(options),
|
|
105
|
+
guard: createStopneticMiddleware({ ...options, verifyPath: `${accessBase}/verify` }),
|
|
106
|
+
};
|
|
107
|
+
}
|