@next-buildr/payload 2.0.0 → 3.0.0-rc.0
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 +7 -0
- package/UPGRADING.md +322 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/plugin/access.d.ts +7 -0
- package/dist/plugin/access.d.ts.map +1 -1
- package/dist/plugin/access.js +19 -0
- package/dist/plugin/collections/templates.d.ts.map +1 -1
- package/dist/plugin/collections/templates.js +9 -3
- package/dist/plugin/endpoints/context.d.ts +8 -0
- package/dist/plugin/endpoints/context.d.ts.map +1 -1
- package/dist/plugin/endpoints/forms-captcha.test.d.ts +2 -0
- package/dist/plugin/endpoints/forms-captcha.test.d.ts.map +1 -0
- package/dist/plugin/endpoints/forms-captcha.test.js +676 -0
- package/dist/plugin/endpoints/forms.d.ts.map +1 -1
- package/dist/plugin/endpoints/forms.js +110 -12
- package/dist/plugin/endpoints/publish.d.ts.map +1 -1
- package/dist/plugin/endpoints/publish.js +4 -0
- package/dist/plugin/endpoints/save.d.ts.map +1 -1
- package/dist/plugin/endpoints/save.js +3 -0
- package/dist/plugin/forms/captcha.d.ts +45 -0
- package/dist/plugin/forms/captcha.d.ts.map +1 -0
- package/dist/plugin/forms/captcha.js +195 -0
- package/dist/plugin/forms/captcha.test.d.ts +2 -0
- package/dist/plugin/forms/captcha.test.d.ts.map +1 -0
- package/dist/plugin/forms/captcha.test.js +262 -0
- package/dist/plugin/forms/client-ip.d.ts +8 -0
- package/dist/plugin/forms/client-ip.d.ts.map +1 -0
- package/dist/plugin/forms/client-ip.js +41 -0
- package/dist/plugin/forms/client-ip.test.d.ts +2 -0
- package/dist/plugin/forms/client-ip.test.d.ts.map +1 -0
- package/dist/plugin/forms/client-ip.test.js +45 -0
- package/dist/plugin/forms/rate-limit.d.ts +2 -0
- package/dist/plugin/forms/rate-limit.d.ts.map +1 -1
- package/dist/plugin/forms/rate-limit.js +10 -1
- package/dist/plugin/hooks/author-code.test.d.ts +2 -0
- package/dist/plugin/hooks/author-code.test.d.ts.map +1 -0
- package/dist/plugin/hooks/author-code.test.js +285 -0
- package/dist/plugin/hooks/layout-hook.d.ts +1 -1
- package/dist/plugin/hooks/layout-hook.d.ts.map +1 -1
- package/dist/plugin/hooks/layout-hook.js +12 -2
- package/dist/plugin/hooks/process-layout.d.ts +10 -0
- package/dist/plugin/hooks/process-layout.d.ts.map +1 -1
- package/dist/plugin/hooks/process-layout.js +94 -1
- package/dist/plugin/hooks/read-layout.d.ts +1 -1
- package/dist/plugin/hooks/read-layout.d.ts.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +20 -1
- package/dist/plugin/options.d.ts +11 -0
- package/dist/plugin/options.d.ts.map +1 -1
- package/dist/plugin/options.js +49 -1
- package/dist/plugin/plugin.test.js +81 -2
- package/package.json +13 -11
|
@@ -2,6 +2,8 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { deriveFormSchema, MAX_FORM_FIELDS } from '@next-buildr/core';
|
|
3
3
|
import { resolveLayout, TEMPLATES_COLLECTION } from '../../data/resolve-layout.js';
|
|
4
4
|
import { FORM_SUBMISSIONS_COLLECTION } from '../collections/form-submissions.js';
|
|
5
|
+
import { ancestorFormHasCaptcha, CAPTCHA_ACTION, CAPTCHA_FIELD, captchaCData, findFormCaptchas, formSubtreeNodes, isCaptchaToken, } from '../forms/captcha.js';
|
|
6
|
+
import { clientIpOf } from '../forms/client-ip.js';
|
|
5
7
|
import { isAllowedRecipient } from '../forms/notify.js';
|
|
6
8
|
import { validateSubmission } from '../forms/validate.js';
|
|
7
9
|
import { readLayout } from '../hooks/read-layout.js';
|
|
@@ -53,9 +55,8 @@ async function readBody(req) {
|
|
|
53
55
|
return { ok: false, response: fail(415, 'The content type must be a form or JSON.') };
|
|
54
56
|
}
|
|
55
57
|
/** The visitor's address, hashed with the server secret: enough to rate-limit, not to identify. */
|
|
56
|
-
function ipHashOf(req) {
|
|
57
|
-
const
|
|
58
|
-
const ip = forwarded || req.headers.get('x-real-ip') || 'unknown';
|
|
58
|
+
function ipHashOf(req, source) {
|
|
59
|
+
const ip = clientIpOf(req, source);
|
|
59
60
|
return createHash('sha256').update(`${req.payload.secret}:${ip}`).digest('hex').slice(0, 32);
|
|
60
61
|
}
|
|
61
62
|
/** Back to the page the form was on (same origin only, so the redirect can never leave the site). */
|
|
@@ -101,7 +102,7 @@ export const formsEndpoint = (env) => ({
|
|
|
101
102
|
const locale = chooseLocale(req, req.searchParams?.get('locale'));
|
|
102
103
|
if (!locale.ok)
|
|
103
104
|
return locale.response;
|
|
104
|
-
const ipHash = ipHashOf(req);
|
|
105
|
+
const ipHash = ipHashOf(req, env.options.forms.clientIp);
|
|
105
106
|
const limited = await env.rateLimiter?.hit(`${ipHash}:${collection}:${id}:${nodeId}`);
|
|
106
107
|
if (limited !== undefined && !limited.allowed) {
|
|
107
108
|
const response = fail(429, 'Too many submissions. Try again later.');
|
|
@@ -119,7 +120,7 @@ export const formsEndpoint = (env) => ({
|
|
|
119
120
|
if (trap !== undefined && !(typeof trap === 'string' && trap === '')) {
|
|
120
121
|
return done('sent') ?? json({ ok: true });
|
|
121
122
|
}
|
|
122
|
-
if (Object.keys(values).length > MAX_FORM_FIELDS)
|
|
123
|
+
if (Object.keys(values).filter((name) => name !== CAPTCHA_FIELD).length > MAX_FORM_FIELDS)
|
|
123
124
|
return fail(422, 'Too many fields.');
|
|
124
125
|
// Only the published state is public: a draft, or a document never published, has no form.
|
|
125
126
|
let stored;
|
|
@@ -137,17 +138,20 @@ export const formsEndpoint = (env) => ({
|
|
|
137
138
|
}
|
|
138
139
|
if (stored['_status'] !== 'published')
|
|
139
140
|
return fail(404, 'The form was not found.');
|
|
140
|
-
const
|
|
141
|
-
?
|
|
142
|
-
|
|
141
|
+
const resolved = isTemplate
|
|
142
|
+
? {
|
|
143
|
+
layout: stored['layout'],
|
|
144
|
+
layoutRef: stored['id'] === undefined ? null : `${TEMPLATES_COLLECTION}:${String(stored['id'])}`,
|
|
145
|
+
}
|
|
146
|
+
: await resolveLayout({
|
|
143
147
|
payload: req.payload,
|
|
144
148
|
collection,
|
|
145
149
|
doc: stored,
|
|
146
150
|
contextName: own?.context ?? collection,
|
|
147
151
|
draft: false,
|
|
148
152
|
overrideAccess: true,
|
|
149
|
-
})
|
|
150
|
-
const read = readLayout(layout, env.options);
|
|
153
|
+
});
|
|
154
|
+
const read = readLayout(resolved.layout, env.options);
|
|
151
155
|
const form = read.ok && Object.hasOwn(read.doc.nodes, nodeId) ? read.doc.nodes[nodeId] : undefined;
|
|
152
156
|
if (!read.ok ||
|
|
153
157
|
form === undefined ||
|
|
@@ -155,8 +159,102 @@ export const formsEndpoint = (env) => ({
|
|
|
155
159
|
env.options.registry === undefined) {
|
|
156
160
|
return fail(404, 'The form was not found.');
|
|
157
161
|
}
|
|
158
|
-
const
|
|
159
|
-
const
|
|
162
|
+
const snapshot = read.doc;
|
|
163
|
+
const canonicalRef = resolved.layoutRef;
|
|
164
|
+
if (canonicalRef === null)
|
|
165
|
+
return fail(404, 'The form was not found.');
|
|
166
|
+
const captchaFailure = (unavailable) => done('invalid') ??
|
|
167
|
+
(unavailable
|
|
168
|
+
? json({ error: 'Verification is unavailable. Try again later.', code: 'captcha-unavailable' }, 503)
|
|
169
|
+
: json({
|
|
170
|
+
errors: { [CAPTCHA_FIELD]: 'The verification failed. Try again.' },
|
|
171
|
+
codes: { [CAPTCHA_FIELD]: 'captcha' },
|
|
172
|
+
}, 422));
|
|
173
|
+
const configFailure = () => {
|
|
174
|
+
req.payload.logger.error({
|
|
175
|
+
msg: 'buildr: captcha configuration error',
|
|
176
|
+
reason: 'configuration',
|
|
177
|
+
});
|
|
178
|
+
return captchaFailure(true);
|
|
179
|
+
};
|
|
180
|
+
// Check raw author fields too: invalid metadata or the field limit must not mask this name.
|
|
181
|
+
const reservedName = formSubtreeNodes(snapshot, nodeId).some((node) => {
|
|
182
|
+
const field = env.options.registry?.meta.get(node.type)?.formField;
|
|
183
|
+
const name = field !== undefined && node.props !== undefined && Object.hasOwn(node.props, field.nameProp)
|
|
184
|
+
? node.props[field.nameProp]
|
|
185
|
+
: undefined;
|
|
186
|
+
return name?.kind === 'static' && name.value === CAPTCHA_FIELD;
|
|
187
|
+
});
|
|
188
|
+
const captchas = findFormCaptchas(snapshot, nodeId);
|
|
189
|
+
if (reservedName || ancestorFormHasCaptcha(snapshot, nodeId) || captchas.length > 1)
|
|
190
|
+
return configFailure();
|
|
191
|
+
const { schema } = deriveFormSchema(snapshot, env.options.registry.meta, nodeId);
|
|
192
|
+
let submitted = values;
|
|
193
|
+
if (captchas.length === 1) {
|
|
194
|
+
const captcha = env.captcha;
|
|
195
|
+
if (captcha === undefined)
|
|
196
|
+
return configFailure();
|
|
197
|
+
const token = Object.hasOwn(values, CAPTCHA_FIELD) ? values[CAPTCHA_FIELD] : undefined;
|
|
198
|
+
if (!isCaptchaToken(token))
|
|
199
|
+
return captchaFailure(false);
|
|
200
|
+
const limited = await captcha.rateLimiter.hit(ipHash);
|
|
201
|
+
if (limited !== undefined && !limited.allowed) {
|
|
202
|
+
const response = fail(429, 'Too many submissions. Try again later.');
|
|
203
|
+
response.headers.set('retry-after', String(limited.retryAfterSeconds));
|
|
204
|
+
return response;
|
|
205
|
+
}
|
|
206
|
+
const release = captcha.gate.tryAcquire();
|
|
207
|
+
if (release === null)
|
|
208
|
+
return captchaFailure(true);
|
|
209
|
+
let verification;
|
|
210
|
+
let onAbort = () => { };
|
|
211
|
+
const signal = AbortSignal.timeout(5000);
|
|
212
|
+
try {
|
|
213
|
+
const deadline = new Promise((resolve) => {
|
|
214
|
+
onAbort = () => {
|
|
215
|
+
release();
|
|
216
|
+
resolve({ ok: false, reason: 'unavailable' });
|
|
217
|
+
};
|
|
218
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
219
|
+
if (signal.aborted)
|
|
220
|
+
onAbort();
|
|
221
|
+
});
|
|
222
|
+
verification = await Promise.race([captcha.verifier.verify({ token, signal }), deadline]);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
verification = { ok: false, reason: 'unavailable' };
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
signal.removeEventListener('abort', onAbort);
|
|
229
|
+
release();
|
|
230
|
+
}
|
|
231
|
+
if (verification?.ok !== true) {
|
|
232
|
+
const unavailable = !verification ||
|
|
233
|
+
(verification.reason !== 'invalid' && verification.reason !== 'expired-or-reused');
|
|
234
|
+
const entry = {
|
|
235
|
+
msg: 'buildr: captcha verification',
|
|
236
|
+
reason: unavailable ? 'unavailable' : verification.reason,
|
|
237
|
+
...verification?.log,
|
|
238
|
+
};
|
|
239
|
+
if (unavailable)
|
|
240
|
+
req.payload.logger.error(entry);
|
|
241
|
+
else
|
|
242
|
+
req.payload.logger.warn(entry);
|
|
243
|
+
return captchaFailure(unavailable);
|
|
244
|
+
}
|
|
245
|
+
if (typeof verification.hostname !== 'string' ||
|
|
246
|
+
typeof verification.action !== 'string' ||
|
|
247
|
+
typeof verification.cdata !== 'string' ||
|
|
248
|
+
!captcha.hostnames.includes(verification.hostname.toLowerCase()) ||
|
|
249
|
+
verification.action !== CAPTCHA_ACTION ||
|
|
250
|
+
verification.cdata !== captchaCData(canonicalRef, nodeId)) {
|
|
251
|
+
req.payload.logger.warn({ msg: 'buildr: captcha verification', reason: 'mismatch' });
|
|
252
|
+
return captchaFailure(false);
|
|
253
|
+
}
|
|
254
|
+
const { [CAPTCHA_FIELD]: _token, ...clean } = values;
|
|
255
|
+
submitted = clean;
|
|
256
|
+
}
|
|
257
|
+
const result = validateSubmission(schema, submitted);
|
|
160
258
|
if (!result.ok) {
|
|
161
259
|
if (!wantsJson)
|
|
162
260
|
return backTo(req, 'invalid');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"publish.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/publish.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"publish.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/publish.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAKxC,OAAO,EAGL,KAAK,WAAW,EAOjB,MAAM,cAAc,CAAC;AAItB;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAS,WAAW,KAAG,QAoEjD,CAAC"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { runA11y } from '@next-buildr/core';
|
|
2
2
|
import { publishRequestSchema, publishResponseSchema } from '../../contract.js';
|
|
3
|
+
import { authorCodePermitted } from '../access.js';
|
|
3
4
|
import { processLayout } from '../hooks/process-layout.js';
|
|
4
5
|
import { configuredLocales } from '../locales.js';
|
|
5
6
|
import { allowed, bodyOf, latestOf, revisionOf, targetOf, updatedBy, writeContext, writeLimit, } from './context.js';
|
|
@@ -38,9 +39,12 @@ export const publishEndpoint = (env) => ({
|
|
|
38
39
|
const current = revisionOf(found.value);
|
|
39
40
|
if (parsed.data.baseRevision !== current)
|
|
40
41
|
return conflict(current);
|
|
42
|
+
// Publishing writes no layout: what is stored passes as it is (a save already vetted its author code).
|
|
41
43
|
const layout = processLayout(found.value['layout'], {
|
|
42
44
|
...env.options,
|
|
43
45
|
locales: configuredLocales(req.payload.config),
|
|
46
|
+
allowAuthorCode: await authorCodePermitted(env.options, req),
|
|
47
|
+
previous: found.value['layout'],
|
|
44
48
|
});
|
|
45
49
|
if (!layout.ok)
|
|
46
50
|
return invalid(layout.diagnostics);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"save.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/save.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"save.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/save.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAKxC,OAAO,EAGL,KAAK,WAAW,EAOjB,MAAM,cAAc,CAAC;AAItB;;;;GAIG;AACH,eAAO,MAAM,YAAY,QAAS,WAAW,KAAG,QA4C9C,CAAC"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { saveRequestSchema, saveResponseSchema } from '../../contract.js';
|
|
2
|
+
import { authorCodePermitted } from '../access.js';
|
|
2
3
|
import { processLayout } from '../hooks/process-layout.js';
|
|
3
4
|
import { configuredLocales } from '../locales.js';
|
|
4
5
|
import { allowed, bodyOf, latestOf, revisionOf, targetOf, updatedBy, writeContext, writeLimit, } from './context.js';
|
|
@@ -39,6 +40,8 @@ export const saveEndpoint = (env) => ({
|
|
|
39
40
|
const layout = processLayout(parsed.data.document, {
|
|
40
41
|
...env.options,
|
|
41
42
|
locales: configuredLocales(req.payload.config),
|
|
43
|
+
allowAuthorCode: await authorCodePermitted(env.options, req),
|
|
44
|
+
previous: found.value['layout'],
|
|
42
45
|
});
|
|
43
46
|
if (!layout.ok)
|
|
44
47
|
return invalid(layout.diagnostics);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { BuilderDocument, PageNode } from '@next-buildr/core';
|
|
2
|
+
export declare const CAPTCHA_FIELD = "buildr-captcha";
|
|
3
|
+
export declare const CAPTCHA_ACTION = "buildr_form";
|
|
4
|
+
export declare const CAPTCHA_TYPE = "buildr/captcha";
|
|
5
|
+
export type CaptchaVerification = {
|
|
6
|
+
ok: true;
|
|
7
|
+
hostname: string;
|
|
8
|
+
action: string;
|
|
9
|
+
cdata: string;
|
|
10
|
+
} | {
|
|
11
|
+
ok: false;
|
|
12
|
+
reason: 'invalid' | 'expired-or-reused' | 'unavailable';
|
|
13
|
+
log?: CaptchaLog;
|
|
14
|
+
};
|
|
15
|
+
export interface CaptchaVerifier {
|
|
16
|
+
verify(input: {
|
|
17
|
+
token: string;
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
}): Promise<CaptchaVerification>;
|
|
20
|
+
}
|
|
21
|
+
export declare const isCaptchaToken: (token: unknown) => token is string;
|
|
22
|
+
/** Shared with the widget: hash the UTF-8 JSON tuple, never a request-derived layout reference. */
|
|
23
|
+
export declare function captchaCData(layoutRef: string, formNodeId: string): string;
|
|
24
|
+
export interface CaptchaGate {
|
|
25
|
+
tryAcquire(): (() => void) | null;
|
|
26
|
+
}
|
|
27
|
+
/** Each plugin instance owns its gate. Releases are idempotent (deadline and finally may race). */
|
|
28
|
+
export declare function createCaptchaGate(maxInFlight: number): CaptchaGate;
|
|
29
|
+
type CaptchaLog = {
|
|
30
|
+
reason: 'configuration' | 'invalid' | 'expired-or-reused' | 'unavailable';
|
|
31
|
+
codes: readonly string[];
|
|
32
|
+
};
|
|
33
|
+
/** A fixed origin and bounded, abortable response reader keep secret-bearing requests contained. */
|
|
34
|
+
export declare function createTurnstileVerifier(options: {
|
|
35
|
+
secretKey: string;
|
|
36
|
+
fetch?: typeof globalThis.fetch;
|
|
37
|
+
log?: (entry: CaptchaLog) => void;
|
|
38
|
+
}): CaptchaVerifier;
|
|
39
|
+
/** Literal graph traversal: registry metadata and render-time conditions cannot waive a challenge. */
|
|
40
|
+
export declare function formSubtreeNodes(snapshot: BuilderDocument, formNodeId: string): PageNode[];
|
|
41
|
+
export declare function findFormCaptchas(snapshot: BuilderDocument, formNodeId: string): PageNode[];
|
|
42
|
+
/** All reverse edges count, including corrupt multiple-parent graphs; cycles cannot hide an ancestor. */
|
|
43
|
+
export declare function ancestorFormHasCaptcha(snapshot: BuilderDocument, formNodeId: string): boolean;
|
|
44
|
+
export {};
|
|
45
|
+
//# sourceMappingURL=captcha.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"captcha.d.ts","sourceRoot":"","sources":["../../../src/plugin/forms/captcha.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAGnE,eAAO,MAAM,aAAa,mBAAmB,CAAC;AAC9C,eAAO,MAAM,cAAc,gBAAgB,CAAC;AAC5C,eAAO,MAAM,YAAY,mBAAmB,CAAC;AAO7C,MAAM,MAAM,mBAAmB,GAC3B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC7D;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,SAAS,GAAG,mBAAmB,GAAG,aAAa,CAAC;IAAC,GAAG,CAAC,EAAE,UAAU,CAAA;CAAE,CAAC;AAE7F,MAAM,WAAW,eAAe;IAC9B,MAAM,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,WAAW,CAAA;KAAE,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;CACrF;AAED,eAAO,MAAM,cAAc,UAAW,OAAO,KAAG,KAAK,IAAI,MACM,CAAC;AAEhE,mGAAmG;AACnG,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAI1E;AAED,MAAM,WAAW,WAAW;IAC1B,UAAU,IAAI,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAC;CACnC;AAED,mGAAmG;AACnG,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,WAAW,CAclE;AASD,KAAK,UAAU,GAAG;IAChB,MAAM,EAAE,eAAe,GAAG,SAAS,GAAG,mBAAmB,GAAG,aAAa,CAAC;IAC1E,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1B,CAAC;AAGF,oGAAoG;AACpG,wBAAgB,uBAAuB,CAAC,OAAO,EAAE;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC,GAAG,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;CACnC,GAAG,eAAe,CAgGlB;AAED,sGAAsG;AACtG,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,GAAG,QAAQ,EAAE,CAgB1F;AAED,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,GAAG,QAAQ,EAAE,CAE1F;AAED,yGAAyG;AACzG,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAsB7F"}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export const CAPTCHA_FIELD = 'buildr-captcha';
|
|
4
|
+
export const CAPTCHA_ACTION = 'buildr_form';
|
|
5
|
+
export const CAPTCHA_TYPE = 'buildr/captcha';
|
|
6
|
+
const FORM_TYPE = 'buildr/form';
|
|
7
|
+
const SITEVERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
|
8
|
+
const TOKEN = /^[A-Za-z0-9._-]{1,2048}$/;
|
|
9
|
+
const CODE = /^[a-z0-9-]{1,64}$/;
|
|
10
|
+
const MAX_RESPONSE_BYTES = 16 * 1024;
|
|
11
|
+
export const isCaptchaToken = (token) => typeof token === 'string' && TOKEN.exec(token)?.[0] === token;
|
|
12
|
+
/** Shared with the widget: hash the UTF-8 JSON tuple, never a request-derived layout reference. */
|
|
13
|
+
export function captchaCData(layoutRef, formNodeId) {
|
|
14
|
+
return createHash('sha256')
|
|
15
|
+
.update(JSON.stringify([CAPTCHA_TYPE, 1, layoutRef, formNodeId]), 'utf8')
|
|
16
|
+
.digest('hex');
|
|
17
|
+
}
|
|
18
|
+
/** Each plugin instance owns its gate. Releases are idempotent (deadline and finally may race). */
|
|
19
|
+
export function createCaptchaGate(maxInFlight) {
|
|
20
|
+
let active = 0;
|
|
21
|
+
return {
|
|
22
|
+
tryAcquire() {
|
|
23
|
+
if (active >= maxInFlight)
|
|
24
|
+
return null;
|
|
25
|
+
active += 1;
|
|
26
|
+
let released = false;
|
|
27
|
+
return () => {
|
|
28
|
+
if (released)
|
|
29
|
+
return;
|
|
30
|
+
released = true;
|
|
31
|
+
active -= 1;
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const responseSchema = z.object({
|
|
37
|
+
success: z.boolean(),
|
|
38
|
+
'error-codes': z.array(z.string()).optional(),
|
|
39
|
+
hostname: z.string().optional(),
|
|
40
|
+
action: z.string().optional(),
|
|
41
|
+
cdata: z.string().optional(),
|
|
42
|
+
});
|
|
43
|
+
const unavailable = () => ({ ok: false, reason: 'unavailable' });
|
|
44
|
+
/** A fixed origin and bounded, abortable response reader keep secret-bearing requests contained. */
|
|
45
|
+
export function createTurnstileVerifier(options) {
|
|
46
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
47
|
+
return {
|
|
48
|
+
async verify({ token, signal }) {
|
|
49
|
+
if (!isCaptchaToken(token))
|
|
50
|
+
return { ok: false, reason: 'invalid' };
|
|
51
|
+
if (signal.aborted)
|
|
52
|
+
return unavailable();
|
|
53
|
+
let reader;
|
|
54
|
+
let onAbort = () => { };
|
|
55
|
+
const cancelled = new Promise((resolve) => {
|
|
56
|
+
onAbort = () => {
|
|
57
|
+
void reader?.cancel().catch(() => { });
|
|
58
|
+
resolve(unavailable());
|
|
59
|
+
};
|
|
60
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
61
|
+
});
|
|
62
|
+
const run = async () => {
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetcher(SITEVERIFY, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
body: new URLSearchParams({ secret: options.secretKey, response: token }),
|
|
67
|
+
redirect: 'error',
|
|
68
|
+
cache: 'no-store',
|
|
69
|
+
signal,
|
|
70
|
+
});
|
|
71
|
+
if (!response.ok || response.body === null || signal.aborted) {
|
|
72
|
+
void response.body?.cancel().catch(() => { });
|
|
73
|
+
return unavailable();
|
|
74
|
+
}
|
|
75
|
+
reader = response.body.getReader();
|
|
76
|
+
const chunks = [];
|
|
77
|
+
let size = 0;
|
|
78
|
+
while (true) {
|
|
79
|
+
const chunk = await reader.read();
|
|
80
|
+
if (signal.aborted)
|
|
81
|
+
return unavailable();
|
|
82
|
+
if (chunk.done)
|
|
83
|
+
break;
|
|
84
|
+
size += chunk.value.byteLength;
|
|
85
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
86
|
+
void reader.cancel().catch(() => { });
|
|
87
|
+
return unavailable();
|
|
88
|
+
}
|
|
89
|
+
chunks.push(chunk.value);
|
|
90
|
+
}
|
|
91
|
+
const bytes = new Uint8Array(size);
|
|
92
|
+
let offset = 0;
|
|
93
|
+
for (const chunk of chunks) {
|
|
94
|
+
bytes.set(chunk, offset);
|
|
95
|
+
offset += chunk.byteLength;
|
|
96
|
+
}
|
|
97
|
+
const parsed = responseSchema.safeParse(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)));
|
|
98
|
+
if (!parsed.success)
|
|
99
|
+
return unavailable();
|
|
100
|
+
const body = parsed.data;
|
|
101
|
+
if (body.success) {
|
|
102
|
+
if (typeof body.hostname !== 'string' || typeof body.action !== 'string')
|
|
103
|
+
return unavailable();
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
hostname: body.hostname,
|
|
107
|
+
action: body.action,
|
|
108
|
+
cdata: body.cdata ?? '',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const codes = body['error-codes'] ?? [];
|
|
112
|
+
const config = codes.includes('missing-input-secret') || codes.includes('invalid-input-secret');
|
|
113
|
+
const reason = config || codes.includes('internal-error')
|
|
114
|
+
? 'unavailable'
|
|
115
|
+
: codes.includes('timeout-or-duplicate')
|
|
116
|
+
? 'expired-or-reused'
|
|
117
|
+
: 'invalid';
|
|
118
|
+
const log = {
|
|
119
|
+
reason: config ? 'configuration' : reason,
|
|
120
|
+
codes: codes
|
|
121
|
+
.filter((code) => CODE.exec(code)?.[0] === code &&
|
|
122
|
+
!code.includes(token) &&
|
|
123
|
+
!code.includes(options.secretKey))
|
|
124
|
+
.slice(0, 10),
|
|
125
|
+
};
|
|
126
|
+
options.log?.(log);
|
|
127
|
+
return { ok: false, reason, ...(options.log === undefined ? { log } : {}) };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return unavailable();
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
return await Promise.race([run(), cancelled]);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
signal.removeEventListener('abort', onAbort);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** Literal graph traversal: registry metadata and render-time conditions cannot waive a challenge. */
|
|
143
|
+
export function formSubtreeNodes(snapshot, formNodeId) {
|
|
144
|
+
const visited = new Set();
|
|
145
|
+
const stack = [formNodeId];
|
|
146
|
+
const nodes = [];
|
|
147
|
+
while (stack.length > 0) {
|
|
148
|
+
const id = stack.pop();
|
|
149
|
+
if (id === undefined || visited.has(id))
|
|
150
|
+
continue;
|
|
151
|
+
visited.add(id);
|
|
152
|
+
const node = Object.hasOwn(snapshot.nodes, id) ? snapshot.nodes[id] : undefined;
|
|
153
|
+
if (node === undefined || (id !== formNodeId && node.type === FORM_TYPE))
|
|
154
|
+
continue;
|
|
155
|
+
if (id !== formNodeId)
|
|
156
|
+
nodes.push(node);
|
|
157
|
+
for (const children of Object.values(node.slots ?? {})) {
|
|
158
|
+
for (const child of children)
|
|
159
|
+
stack.push(child);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return nodes;
|
|
163
|
+
}
|
|
164
|
+
export function findFormCaptchas(snapshot, formNodeId) {
|
|
165
|
+
return formSubtreeNodes(snapshot, formNodeId).filter((node) => node.type === CAPTCHA_TYPE);
|
|
166
|
+
}
|
|
167
|
+
/** All reverse edges count, including corrupt multiple-parent graphs; cycles cannot hide an ancestor. */
|
|
168
|
+
export function ancestorFormHasCaptcha(snapshot, formNodeId) {
|
|
169
|
+
const parents = new Map();
|
|
170
|
+
for (const [id, node] of Object.entries(snapshot.nodes)) {
|
|
171
|
+
for (const children of Object.values(node.slots ?? {})) {
|
|
172
|
+
for (const child of children) {
|
|
173
|
+
const list = parents.get(child);
|
|
174
|
+
if (list === undefined)
|
|
175
|
+
parents.set(child, [id]);
|
|
176
|
+
else
|
|
177
|
+
list.push(id);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const visited = new Set([formNodeId]);
|
|
182
|
+
const stack = [...(parents.get(formNodeId) ?? [])];
|
|
183
|
+
while (stack.length > 0) {
|
|
184
|
+
const id = stack.pop();
|
|
185
|
+
if (id === undefined || visited.has(id))
|
|
186
|
+
continue;
|
|
187
|
+
visited.add(id);
|
|
188
|
+
const node = Object.hasOwn(snapshot.nodes, id) ? snapshot.nodes[id] : undefined;
|
|
189
|
+
if (node?.type === FORM_TYPE && findFormCaptchas(snapshot, id).length > 0)
|
|
190
|
+
return true;
|
|
191
|
+
for (const parent of parents.get(id) ?? [])
|
|
192
|
+
stack.push(parent);
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"captcha.test.d.ts","sourceRoot":"","sources":["../../../src/plugin/forms/captcha.test.ts"],"names":[],"mappings":""}
|