@blamejs/core 0.7.4 → 0.7.18
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/CHANGELOG.md +423 -395
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -284
- package/lib/api-key.js +660 -663
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +213 -10
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -215
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +184 -4
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -319
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/forms.js
CHANGED
|
@@ -1,422 +1,422 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Forms — CSRF tokens, HTML rendering, server-side validation.
|
|
4
|
-
*
|
|
5
|
-
* Three concerns the framework owns at the form layer:
|
|
6
|
-
*
|
|
7
|
-
* 1. CSRF tokens. Generation is a 32-byte random hex string;
|
|
8
|
-
* verification is constant-time. The middleware in
|
|
9
|
-
* lib/middleware/csrf-protect.js does the actual gating —
|
|
10
|
-
* this module provides the primitives the operator (or
|
|
11
|
-
* template) calls to issue a token and embed it.
|
|
12
|
-
*
|
|
13
|
-
* 2. Form HTML rendering. forms.render({ action, fields, csrfToken })
|
|
14
|
-
* produces a complete <form> element with auto-escaped attributes,
|
|
15
|
-
* a hidden CSRF input, and field-type dispatch (text / email /
|
|
16
|
-
* password / number / checkbox / radio / textarea / select /
|
|
17
|
-
* hidden / submit). All values pass through escapeAttribute so
|
|
18
|
-
* the form can't be hijacked by user-supplied attribute payloads.
|
|
19
|
-
*
|
|
20
|
-
* 3. Server-side validation. forms.validate(spec, body) walks the
|
|
21
|
-
* same field spec the renderer accepts and returns
|
|
22
|
-
* { valid, errors, values } — coerced types, required-field
|
|
23
|
-
* checks, length bounds, regex pattern, enum membership.
|
|
24
|
-
*
|
|
25
|
-
* The renderer + validator share their field spec on purpose: an
|
|
26
|
-
* operator's "this is what the form looks like" is also "this is
|
|
27
|
-
* what the form expects." A change to the spec adjusts both.
|
|
28
|
-
*
|
|
29
|
-
* Public API:
|
|
30
|
-
*
|
|
31
|
-
* forms.generateCsrfToken() → "<64 hex chars>"
|
|
32
|
-
* forms.verifyCsrfToken(submitted, expected) → boolean (timing-safe)
|
|
33
|
-
* forms.render(spec) → string (complete <form>…</form>)
|
|
34
|
-
* forms.validate(spec, body) → { valid, errors, values }
|
|
35
|
-
* forms.escapeAttribute(value) → string (double-quoted-attr context)
|
|
36
|
-
* forms.escapeHtml = template.escapeHtml (re-export for convenience)
|
|
37
|
-
*/
|
|
38
|
-
var C = require("./constants");
|
|
39
|
-
var { generateToken, timingSafeEqual } = require("./crypto");
|
|
40
|
-
var safeSchema = require("./safe-schema");
|
|
41
|
-
var safeUrl = require("./safe-url");
|
|
42
|
-
var template = require("./template");
|
|
43
|
-
|
|
44
|
-
// ============================================================
|
|
45
|
-
// CSRF token primitives
|
|
46
|
-
// ============================================================
|
|
47
|
-
|
|
48
|
-
var CSRF_TOKEN_BYTES = C.BYTES.bytes(32);
|
|
49
|
-
|
|
50
|
-
// RFC 5321 §4.5.3.1.3 caps an email address at 254 chars; bound the
|
|
51
|
-
// regex test on form submissions so a hostile caller can't stage a
|
|
52
|
-
// pathological-length input against the engine.
|
|
53
|
-
var MAX_EMAIL_LENGTH = 254;
|
|
54
|
-
// RFC 7230 §3.1.1 doesn't fix a URL length but the conventional cap
|
|
55
|
-
// (and what most servers / proxies enforce) is 8 KiB.
|
|
56
|
-
var MAX_URL_LENGTH = C.BYTES.kib(8);
|
|
57
|
-
|
|
58
|
-
function generateCsrfToken() {
|
|
59
|
-
return generateToken(CSRF_TOKEN_BYTES);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function verifyCsrfToken(submitted, expected) {
|
|
63
|
-
if (typeof submitted !== "string" || typeof expected !== "string") return false;
|
|
64
|
-
if (submitted.length === 0 || submitted.length !== expected.length) return false;
|
|
65
|
-
return timingSafeEqual(submitted, expected);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ============================================================
|
|
69
|
-
// Attribute escaping
|
|
70
|
-
// ============================================================
|
|
71
|
-
//
|
|
72
|
-
// More strict than template.escapeHtml: escapes the same set plus
|
|
73
|
-
// backtick (some browsers treat ` as an attribute delimiter under
|
|
74
|
-
// quirks-mode parsing) and = (defense-in-depth for unquoted-attribute
|
|
75
|
-
// contexts the renderer happens to produce). Forms render attributes
|
|
76
|
-
// in double-quoted contexts only, so this is belt-and-suspenders.
|
|
77
|
-
|
|
78
|
-
var ATTR_ESCAPE_MAP = {
|
|
79
|
-
"&": "&", "<": "<", ">": ">",
|
|
80
|
-
'"': """, "'": "'",
|
|
81
|
-
"`": "`", "=": "=",
|
|
82
|
-
};
|
|
83
|
-
var ATTR_ESCAPE_RE = /[&<>"'`=]/g;
|
|
84
|
-
|
|
85
|
-
function escapeAttribute(value) {
|
|
86
|
-
if (value === null || value === undefined) return "";
|
|
87
|
-
var s = typeof value === "string" ? value : String(value);
|
|
88
|
-
return s.replace(ATTR_ESCAPE_RE, function (c) { return ATTR_ESCAPE_MAP[c]; });
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ============================================================
|
|
92
|
-
// Form rendering
|
|
93
|
-
// ============================================================
|
|
94
|
-
//
|
|
95
|
-
// spec = {
|
|
96
|
-
// action: string — form action URL (required)
|
|
97
|
-
// method: "POST" (default) | "GET" | …
|
|
98
|
-
// csrfToken: string — embedded as <input name="_csrf">
|
|
99
|
-
// csrfFieldName: string (default "_csrf") — match what csrfProtect expects
|
|
100
|
-
// id, className: string — top-level form attributes
|
|
101
|
-
// fields: FieldSpec[]
|
|
102
|
-
// submitLabel: string (default "Submit") — auto-appended submit button
|
|
103
|
-
// if no field of type=submit present
|
|
104
|
-
// enctype: string — multipart/form-data etc.
|
|
105
|
-
// autocomplete: string — "off" / "on"
|
|
106
|
-
// }
|
|
107
|
-
//
|
|
108
|
-
// FieldSpec common keys:
|
|
109
|
-
// name: string (required)
|
|
110
|
-
// type: "text" | "email" | "password" | "number" | "tel" | "url" |
|
|
111
|
-
// "search" | "date" | "time" | "datetime-local" | "checkbox" |
|
|
112
|
-
// "radio" | "textarea" | "select" | "hidden" | "submit"
|
|
113
|
-
// value: initial value (auto-escaped)
|
|
114
|
-
// label: string — wraps a <label>
|
|
115
|
-
// placeholder, required, readonly, disabled, autocomplete
|
|
116
|
-
// min, max, step, pattern, minlength, maxlength
|
|
117
|
-
// options: [{ value, label, selected? }] — for select / radio
|
|
118
|
-
// rows, cols — for textarea
|
|
119
|
-
|
|
120
|
-
var INPUT_TYPES = {
|
|
121
|
-
"text": 1, "email": 1, "password": 1, "number": 1, "tel": 1, "url": 1,
|
|
122
|
-
"search": 1, "date": 1, "time": 1, "datetime-local": 1, "month": 1, "week": 1,
|
|
123
|
-
"checkbox": 1, "radio": 1, "hidden": 1, "submit": 1, "color": 1,
|
|
124
|
-
"file": 1, "range": 1, "image": 1, "reset": 1, "button": 1,
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
function _renderInput(field) {
|
|
128
|
-
var attrs = [
|
|
129
|
-
'type="' + escapeAttribute(field.type) + '"',
|
|
130
|
-
'name="' + escapeAttribute(field.name) + '"',
|
|
131
|
-
];
|
|
132
|
-
if (field.value !== undefined && field.value !== null) {
|
|
133
|
-
attrs.push('value="' + escapeAttribute(field.value) + '"');
|
|
134
|
-
}
|
|
135
|
-
if (field.placeholder) attrs.push('placeholder="' + escapeAttribute(field.placeholder) + '"');
|
|
136
|
-
if (field.required) attrs.push("required");
|
|
137
|
-
if (field.readonly) attrs.push("readonly");
|
|
138
|
-
if (field.disabled) attrs.push("disabled");
|
|
139
|
-
if (field.checked) attrs.push("checked");
|
|
140
|
-
if (field.autocomplete) attrs.push('autocomplete="' + escapeAttribute(field.autocomplete) + '"');
|
|
141
|
-
if (field.pattern) {
|
|
142
|
-
// Pattern is a RegExp at every boundary; render its source for the
|
|
143
|
-
// HTML attribute so client + server agree on the regex. String
|
|
144
|
-
// patterns are rejected at config time because validate() refuses
|
|
145
|
-
// them — symmetric handling avoids a silent server-validation gap.
|
|
146
|
-
if (!(field.pattern instanceof RegExp)) {
|
|
147
|
-
throw new Error("forms.render: field '" + field.name +
|
|
148
|
-
"'.pattern must be a pre-compiled RegExp; got " +
|
|
149
|
-
(typeof field.pattern) + ". Wrap the source string with `RegExp` at config time.");
|
|
150
|
-
}
|
|
151
|
-
attrs.push('pattern="' + escapeAttribute(field.pattern.source) + '"');
|
|
152
|
-
}
|
|
153
|
-
if (field.min !== undefined) attrs.push('min="' + escapeAttribute(field.min) + '"');
|
|
154
|
-
if (field.max !== undefined) attrs.push('max="' + escapeAttribute(field.max) + '"');
|
|
155
|
-
if (field.step !== undefined) attrs.push('step="' + escapeAttribute(field.step) + '"');
|
|
156
|
-
if (field.minlength !== undefined) attrs.push('minlength="' + escapeAttribute(field.minlength) + '"');
|
|
157
|
-
if (field.maxlength !== undefined) attrs.push('maxlength="' + escapeAttribute(field.maxlength) + '"');
|
|
158
|
-
if (field.id) attrs.push('id="' + escapeAttribute(field.id) + '"');
|
|
159
|
-
if (field.className) attrs.push('class="' + escapeAttribute(field.className) + '"');
|
|
160
|
-
return "<input " + attrs.join(" ") + ">";
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function _renderTextarea(field) {
|
|
164
|
-
var attrs = ['name="' + escapeAttribute(field.name) + '"'];
|
|
165
|
-
if (field.placeholder) attrs.push('placeholder="' + escapeAttribute(field.placeholder) + '"');
|
|
166
|
-
if (field.required) attrs.push("required");
|
|
167
|
-
if (field.readonly) attrs.push("readonly");
|
|
168
|
-
if (field.disabled) attrs.push("disabled");
|
|
169
|
-
if (field.rows !== undefined) attrs.push('rows="' + escapeAttribute(field.rows) + '"');
|
|
170
|
-
if (field.cols !== undefined) attrs.push('cols="' + escapeAttribute(field.cols) + '"');
|
|
171
|
-
if (field.minlength !== undefined) attrs.push('minlength="' + escapeAttribute(field.minlength) + '"');
|
|
172
|
-
if (field.maxlength !== undefined) attrs.push('maxlength="' + escapeAttribute(field.maxlength) + '"');
|
|
173
|
-
if (field.id) attrs.push('id="' + escapeAttribute(field.id) + '"');
|
|
174
|
-
if (field.className) attrs.push('class="' + escapeAttribute(field.className) + '"');
|
|
175
|
-
var body = field.value !== undefined && field.value !== null ? template.escapeHtml(field.value) : "";
|
|
176
|
-
return "<textarea " + attrs.join(" ") + ">" + body + "</textarea>";
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function _renderSelect(field) {
|
|
180
|
-
var attrs = ['name="' + escapeAttribute(field.name) + '"'];
|
|
181
|
-
if (field.required) attrs.push("required");
|
|
182
|
-
if (field.disabled) attrs.push("disabled");
|
|
183
|
-
if (field.multiple) attrs.push("multiple");
|
|
184
|
-
if (field.id) attrs.push('id="' + escapeAttribute(field.id) + '"');
|
|
185
|
-
if (field.className) attrs.push('class="' + escapeAttribute(field.className) + '"');
|
|
186
|
-
var options = field.options || [];
|
|
187
|
-
var optionsHtml = options.map(function (o) {
|
|
188
|
-
var optAttrs = ['value="' + escapeAttribute(o.value) + '"'];
|
|
189
|
-
var selected = (o.selected) || (field.value !== undefined && String(field.value) === String(o.value));
|
|
190
|
-
if (selected) optAttrs.push("selected");
|
|
191
|
-
if (o.disabled) optAttrs.push("disabled");
|
|
192
|
-
return "<option " + optAttrs.join(" ") + ">" + template.escapeHtml(o.label === undefined ? o.value : o.label) + "</option>";
|
|
193
|
-
}).join("");
|
|
194
|
-
return "<select " + attrs.join(" ") + ">" + optionsHtml + "</select>";
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function _renderField(field) {
|
|
198
|
-
if (!field || !field.name || typeof field.name !== "string") {
|
|
199
|
-
throw new Error("forms.render: each field requires a name");
|
|
200
|
-
}
|
|
201
|
-
var type = field.type || "text";
|
|
202
|
-
var control;
|
|
203
|
-
if (type === "textarea") control = _renderTextarea(field);
|
|
204
|
-
else if (type === "select") control = _renderSelect(field);
|
|
205
|
-
else if (INPUT_TYPES[type]) control = _renderInput(field);
|
|
206
|
-
else throw new Error("forms.render: unsupported field type: " + type);
|
|
207
|
-
|
|
208
|
-
// Hidden + submit fields don't need a label wrapper
|
|
209
|
-
if (type === "hidden" || type === "submit") return control;
|
|
210
|
-
|
|
211
|
-
if (field.label) {
|
|
212
|
-
return "<label>" + template.escapeHtml(field.label) + " " + control + "</label>";
|
|
213
|
-
}
|
|
214
|
-
return control;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function render(spec) {
|
|
218
|
-
if (!spec || typeof spec.action !== "string" || spec.action.length === 0) {
|
|
219
|
-
throw new Error("forms.render: spec.action is required");
|
|
220
|
-
}
|
|
221
|
-
if (!Array.isArray(spec.fields)) {
|
|
222
|
-
throw new Error("forms.render: spec.fields must be an array");
|
|
223
|
-
}
|
|
224
|
-
var method = (spec.method || "POST").toUpperCase();
|
|
225
|
-
var attrs = [
|
|
226
|
-
'method="' + escapeAttribute(method) + '"',
|
|
227
|
-
'action="' + escapeAttribute(spec.action) + '"',
|
|
228
|
-
];
|
|
229
|
-
if (spec.id) attrs.push('id="' + escapeAttribute(spec.id) + '"');
|
|
230
|
-
if (spec.className) attrs.push('class="' + escapeAttribute(spec.className) + '"');
|
|
231
|
-
if (spec.enctype) attrs.push('enctype="' + escapeAttribute(spec.enctype) + '"');
|
|
232
|
-
if (spec.autocomplete) attrs.push('autocomplete="' + escapeAttribute(spec.autocomplete) + '"');
|
|
233
|
-
if (spec.target) attrs.push('target="' + escapeAttribute(spec.target) + '"');
|
|
234
|
-
|
|
235
|
-
var inner = "";
|
|
236
|
-
|
|
237
|
-
// CSRF hidden input. State-changing methods need it; safe methods
|
|
238
|
-
// (GET/HEAD) don't. We still render it on POST etc. when csrfToken
|
|
239
|
-
// is provided; the middleware decides whether it's required.
|
|
240
|
-
if (spec.csrfToken && method !== "GET" && method !== "HEAD") {
|
|
241
|
-
var csrfFieldName = spec.csrfFieldName || "_csrf";
|
|
242
|
-
inner += '<input type="hidden" name="' + escapeAttribute(csrfFieldName) +
|
|
243
|
-
'" value="' + escapeAttribute(spec.csrfToken) + '">';
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
for (var i = 0; i < spec.fields.length; i++) {
|
|
247
|
-
inner += _renderField(spec.fields[i]);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Auto-submit button if the operator didn't include one
|
|
251
|
-
var hasSubmit = spec.fields.some(function (f) { return f.type === "submit"; });
|
|
252
|
-
if (!hasSubmit) {
|
|
253
|
-
inner += '<button type="submit">' + template.escapeHtml(spec.submitLabel || "Submit") + "</button>";
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return "<form " + attrs.join(" ") + ">" + inner + "</form>";
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// ============================================================
|
|
260
|
-
// Server-side validation
|
|
261
|
-
// ============================================================
|
|
262
|
-
//
|
|
263
|
-
// validate(spec, body) → { valid, errors, values }
|
|
264
|
-
//
|
|
265
|
-
// errors is a map fieldName → message (only failing fields). values is
|
|
266
|
-
// the coerced data: number/checkbox/etc. converted to their canonical
|
|
267
|
-
// types. body is whatever the operator passes — typically an object
|
|
268
|
-
// from URLSearchParams or JSON parse.
|
|
269
|
-
|
|
270
|
-
function _coerce(field, raw) {
|
|
271
|
-
// Checkbox special-case: browsers omit unchecked boxes from the
|
|
272
|
-
// submission entirely, so `raw === undefined` means "unchecked"
|
|
273
|
-
// (false), not "not provided." Coerce to false BEFORE the generic
|
|
274
|
-
// undefined-passthrough below.
|
|
275
|
-
if (field.type === "checkbox") {
|
|
276
|
-
if (raw === undefined || raw === null || raw === "" || raw === "false" || raw === "0") return false;
|
|
277
|
-
return true;
|
|
278
|
-
}
|
|
279
|
-
if (raw === undefined) return undefined;
|
|
280
|
-
switch (field.type) {
|
|
281
|
-
case "number":
|
|
282
|
-
case "range":
|
|
283
|
-
if (raw === "" || raw === null) return null;
|
|
284
|
-
var n = Number(raw);
|
|
285
|
-
return Number.isFinite(n) ? n : NaN;
|
|
286
|
-
case "date":
|
|
287
|
-
case "datetime-local":
|
|
288
|
-
case "time":
|
|
289
|
-
return typeof raw === "string" ? raw : String(raw);
|
|
290
|
-
default:
|
|
291
|
-
return typeof raw === "string" ? raw : String(raw);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function _isEmpty(v) {
|
|
296
|
-
return v === undefined || v === null || v === "";
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function validate(spec, body) {
|
|
300
|
-
if (!spec || !Array.isArray(spec.fields)) {
|
|
301
|
-
throw new Error("forms.validate: spec.fields must be an array");
|
|
302
|
-
}
|
|
303
|
-
body = body || {};
|
|
304
|
-
var errors = {};
|
|
305
|
-
var values = {};
|
|
306
|
-
|
|
307
|
-
for (var i = 0; i < spec.fields.length; i++) {
|
|
308
|
-
var f = spec.fields[i];
|
|
309
|
-
if (!f.name) continue;
|
|
310
|
-
if (f.type === "submit") continue; // submit buttons aren't data
|
|
311
|
-
|
|
312
|
-
var raw = body[f.name];
|
|
313
|
-
var coerced = _coerce(f, raw);
|
|
314
|
-
values[f.name] = coerced;
|
|
315
|
-
|
|
316
|
-
if (f.required && _isEmpty(coerced) && coerced !== false) {
|
|
317
|
-
errors[f.name] = f.errorMessages && f.errorMessages.required
|
|
318
|
-
? f.errorMessages.required
|
|
319
|
-
: (f.label || f.name) + " is required";
|
|
320
|
-
continue;
|
|
321
|
-
}
|
|
322
|
-
if (_isEmpty(coerced)) continue;
|
|
323
|
-
|
|
324
|
-
// Type-specific checks
|
|
325
|
-
if (f.type === "number" || f.type === "range") {
|
|
326
|
-
if (Number.isNaN(coerced)) {
|
|
327
|
-
errors[f.name] = (f.label || f.name) + " must be a number";
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
if (f.min !== undefined && coerced < Number(f.min)) {
|
|
331
|
-
errors[f.name] = (f.label || f.name) + " must be ≥ " + f.min;
|
|
332
|
-
continue;
|
|
333
|
-
}
|
|
334
|
-
if (f.max !== undefined && coerced > Number(f.max)) {
|
|
335
|
-
errors[f.name] = (f.label || f.name) + " must be ≤ " + f.max;
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
if (f.type === "email" && typeof coerced === "string") {
|
|
340
|
-
// Length cap before the regex test (RFC 5321 §4.5.3.1.3 = 254
|
|
341
|
-
// chars). Any longer is rejected without engaging the regex.
|
|
342
|
-
// Same pragmatic check the rest of the framework uses
|
|
343
|
-
// (safeSchema.EMAIL_RE — shared so we don't carry parallel
|
|
344
|
-
// copies of the same /^[^\s@]+@[^\s@]+\.[^\s@]+$/ regex).
|
|
345
|
-
if (coerced.length > MAX_EMAIL_LENGTH || !safeSchema.EMAIL_RE.test(coerced)) {
|
|
346
|
-
errors[f.name] = (f.label || f.name) + " must be a valid email address";
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
if (f.type === "url" && typeof coerced === "string") {
|
|
351
|
-
// Form `url` fields come from the request body — operator/external
|
|
352
|
-
// input. Length cap (RFC 7230 §3.1.1 has no fixed limit; the
|
|
353
|
-
// conventional cap is 8 KiB) bounds the work before safeUrl.parse
|
|
354
|
-
// engages. Route through safeUrl so the scheme allowlist is
|
|
355
|
-
// honored (https-only by default; operator opts in to http via
|
|
356
|
-
// field meta).
|
|
357
|
-
if (coerced.length > MAX_URL_LENGTH) {
|
|
358
|
-
errors[f.name] = (f.label || f.name) + " must be a valid URL";
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
try {
|
|
362
|
-
safeUrl.parse(coerced, {
|
|
363
|
-
allowedProtocols: f.allowHttp ? safeUrl.ALLOW_HTTP_ALL : safeUrl.ALLOW_HTTP_TLS,
|
|
364
|
-
});
|
|
365
|
-
} catch (_e) {
|
|
366
|
-
errors[f.name] = (f.label || f.name) + " must be a valid URL";
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
if (typeof coerced === "string") {
|
|
371
|
-
if (f.minlength !== undefined && coerced.length < Number(f.minlength)) {
|
|
372
|
-
errors[f.name] = (f.label || f.name) + " must be at least " + f.minlength + " characters";
|
|
373
|
-
continue;
|
|
374
|
-
}
|
|
375
|
-
if (f.maxlength !== undefined && coerced.length > Number(f.maxlength)) {
|
|
376
|
-
errors[f.name] = (f.label || f.name) + " must be at most " + f.maxlength + " characters";
|
|
377
|
-
continue;
|
|
378
|
-
}
|
|
379
|
-
if (f.pattern) {
|
|
380
|
-
// Pattern accepts a pre-compiled RegExp only — taking a string
|
|
381
|
-
// and compiling it here would be a ReDoS surface against the
|
|
382
|
-
// form-render engine. Operators construct the RegExp at config
|
|
383
|
-
// time so the framework never compiles operator-supplied source
|
|
384
|
-
// on the request path.
|
|
385
|
-
if (!(f.pattern instanceof RegExp)) {
|
|
386
|
-
throw new Error("forms.validate: field '" + f.name +
|
|
387
|
-
"'.pattern must be a pre-compiled RegExp; got " +
|
|
388
|
-
(typeof f.pattern) + ". Wrap the source string with `RegExp` at config time.");
|
|
389
|
-
}
|
|
390
|
-
if (!f.pattern.test(coerced)) {
|
|
391
|
-
errors[f.name] = f.errorMessages && f.errorMessages.pattern
|
|
392
|
-
? f.errorMessages.pattern
|
|
393
|
-
: (f.label || f.name) + " has an invalid format";
|
|
394
|
-
continue;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
if ((f.type === "select" || f.type === "radio") && Array.isArray(f.options)) {
|
|
399
|
-
var allowed = f.options.map(function (o) { return String(o.value); });
|
|
400
|
-
if (allowed.indexOf(String(coerced)) === -1) {
|
|
401
|
-
errors[f.name] = (f.label || f.name) + " has an invalid value";
|
|
402
|
-
continue;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
return {
|
|
408
|
-
valid: Object.keys(errors).length === 0,
|
|
409
|
-
errors: errors,
|
|
410
|
-
values: values,
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
module.exports = {
|
|
415
|
-
generateCsrfToken: generateCsrfToken,
|
|
416
|
-
verifyCsrfToken: verifyCsrfToken,
|
|
417
|
-
render: render,
|
|
418
|
-
validate: validate,
|
|
419
|
-
escapeAttribute: escapeAttribute,
|
|
420
|
-
escapeHtml: template.escapeHtml,
|
|
421
|
-
CSRF_TOKEN_BYTES: CSRF_TOKEN_BYTES,
|
|
422
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Forms — CSRF tokens, HTML rendering, server-side validation.
|
|
4
|
+
*
|
|
5
|
+
* Three concerns the framework owns at the form layer:
|
|
6
|
+
*
|
|
7
|
+
* 1. CSRF tokens. Generation is a 32-byte random hex string;
|
|
8
|
+
* verification is constant-time. The middleware in
|
|
9
|
+
* lib/middleware/csrf-protect.js does the actual gating —
|
|
10
|
+
* this module provides the primitives the operator (or
|
|
11
|
+
* template) calls to issue a token and embed it.
|
|
12
|
+
*
|
|
13
|
+
* 2. Form HTML rendering. forms.render({ action, fields, csrfToken })
|
|
14
|
+
* produces a complete <form> element with auto-escaped attributes,
|
|
15
|
+
* a hidden CSRF input, and field-type dispatch (text / email /
|
|
16
|
+
* password / number / checkbox / radio / textarea / select /
|
|
17
|
+
* hidden / submit). All values pass through escapeAttribute so
|
|
18
|
+
* the form can't be hijacked by user-supplied attribute payloads.
|
|
19
|
+
*
|
|
20
|
+
* 3. Server-side validation. forms.validate(spec, body) walks the
|
|
21
|
+
* same field spec the renderer accepts and returns
|
|
22
|
+
* { valid, errors, values } — coerced types, required-field
|
|
23
|
+
* checks, length bounds, regex pattern, enum membership.
|
|
24
|
+
*
|
|
25
|
+
* The renderer + validator share their field spec on purpose: an
|
|
26
|
+
* operator's "this is what the form looks like" is also "this is
|
|
27
|
+
* what the form expects." A change to the spec adjusts both.
|
|
28
|
+
*
|
|
29
|
+
* Public API:
|
|
30
|
+
*
|
|
31
|
+
* forms.generateCsrfToken() → "<64 hex chars>"
|
|
32
|
+
* forms.verifyCsrfToken(submitted, expected) → boolean (timing-safe)
|
|
33
|
+
* forms.render(spec) → string (complete <form>…</form>)
|
|
34
|
+
* forms.validate(spec, body) → { valid, errors, values }
|
|
35
|
+
* forms.escapeAttribute(value) → string (double-quoted-attr context)
|
|
36
|
+
* forms.escapeHtml = template.escapeHtml (re-export for convenience)
|
|
37
|
+
*/
|
|
38
|
+
var C = require("./constants");
|
|
39
|
+
var { generateToken, timingSafeEqual } = require("./crypto");
|
|
40
|
+
var safeSchema = require("./safe-schema");
|
|
41
|
+
var safeUrl = require("./safe-url");
|
|
42
|
+
var template = require("./template");
|
|
43
|
+
|
|
44
|
+
// ============================================================
|
|
45
|
+
// CSRF token primitives
|
|
46
|
+
// ============================================================
|
|
47
|
+
|
|
48
|
+
var CSRF_TOKEN_BYTES = C.BYTES.bytes(32);
|
|
49
|
+
|
|
50
|
+
// RFC 5321 §4.5.3.1.3 caps an email address at 254 chars; bound the
|
|
51
|
+
// regex test on form submissions so a hostile caller can't stage a
|
|
52
|
+
// pathological-length input against the engine.
|
|
53
|
+
var MAX_EMAIL_LENGTH = 254;
|
|
54
|
+
// RFC 7230 §3.1.1 doesn't fix a URL length but the conventional cap
|
|
55
|
+
// (and what most servers / proxies enforce) is 8 KiB.
|
|
56
|
+
var MAX_URL_LENGTH = C.BYTES.kib(8);
|
|
57
|
+
|
|
58
|
+
function generateCsrfToken() {
|
|
59
|
+
return generateToken(CSRF_TOKEN_BYTES);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function verifyCsrfToken(submitted, expected) {
|
|
63
|
+
if (typeof submitted !== "string" || typeof expected !== "string") return false;
|
|
64
|
+
if (submitted.length === 0 || submitted.length !== expected.length) return false;
|
|
65
|
+
return timingSafeEqual(submitted, expected);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ============================================================
|
|
69
|
+
// Attribute escaping
|
|
70
|
+
// ============================================================
|
|
71
|
+
//
|
|
72
|
+
// More strict than template.escapeHtml: escapes the same set plus
|
|
73
|
+
// backtick (some browsers treat ` as an attribute delimiter under
|
|
74
|
+
// quirks-mode parsing) and = (defense-in-depth for unquoted-attribute
|
|
75
|
+
// contexts the renderer happens to produce). Forms render attributes
|
|
76
|
+
// in double-quoted contexts only, so this is belt-and-suspenders.
|
|
77
|
+
|
|
78
|
+
var ATTR_ESCAPE_MAP = {
|
|
79
|
+
"&": "&", "<": "<", ">": ">",
|
|
80
|
+
'"': """, "'": "'",
|
|
81
|
+
"`": "`", "=": "=",
|
|
82
|
+
};
|
|
83
|
+
var ATTR_ESCAPE_RE = /[&<>"'`=]/g;
|
|
84
|
+
|
|
85
|
+
function escapeAttribute(value) {
|
|
86
|
+
if (value === null || value === undefined) return "";
|
|
87
|
+
var s = typeof value === "string" ? value : String(value);
|
|
88
|
+
return s.replace(ATTR_ESCAPE_RE, function (c) { return ATTR_ESCAPE_MAP[c]; });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============================================================
|
|
92
|
+
// Form rendering
|
|
93
|
+
// ============================================================
|
|
94
|
+
//
|
|
95
|
+
// spec = {
|
|
96
|
+
// action: string — form action URL (required)
|
|
97
|
+
// method: "POST" (default) | "GET" | …
|
|
98
|
+
// csrfToken: string — embedded as <input name="_csrf">
|
|
99
|
+
// csrfFieldName: string (default "_csrf") — match what csrfProtect expects
|
|
100
|
+
// id, className: string — top-level form attributes
|
|
101
|
+
// fields: FieldSpec[]
|
|
102
|
+
// submitLabel: string (default "Submit") — auto-appended submit button
|
|
103
|
+
// if no field of type=submit present
|
|
104
|
+
// enctype: string — multipart/form-data etc.
|
|
105
|
+
// autocomplete: string — "off" / "on"
|
|
106
|
+
// }
|
|
107
|
+
//
|
|
108
|
+
// FieldSpec common keys:
|
|
109
|
+
// name: string (required)
|
|
110
|
+
// type: "text" | "email" | "password" | "number" | "tel" | "url" |
|
|
111
|
+
// "search" | "date" | "time" | "datetime-local" | "checkbox" |
|
|
112
|
+
// "radio" | "textarea" | "select" | "hidden" | "submit"
|
|
113
|
+
// value: initial value (auto-escaped)
|
|
114
|
+
// label: string — wraps a <label>
|
|
115
|
+
// placeholder, required, readonly, disabled, autocomplete
|
|
116
|
+
// min, max, step, pattern, minlength, maxlength
|
|
117
|
+
// options: [{ value, label, selected? }] — for select / radio
|
|
118
|
+
// rows, cols — for textarea
|
|
119
|
+
|
|
120
|
+
var INPUT_TYPES = {
|
|
121
|
+
"text": 1, "email": 1, "password": 1, "number": 1, "tel": 1, "url": 1,
|
|
122
|
+
"search": 1, "date": 1, "time": 1, "datetime-local": 1, "month": 1, "week": 1,
|
|
123
|
+
"checkbox": 1, "radio": 1, "hidden": 1, "submit": 1, "color": 1,
|
|
124
|
+
"file": 1, "range": 1, "image": 1, "reset": 1, "button": 1,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function _renderInput(field) {
|
|
128
|
+
var attrs = [
|
|
129
|
+
'type="' + escapeAttribute(field.type) + '"',
|
|
130
|
+
'name="' + escapeAttribute(field.name) + '"',
|
|
131
|
+
];
|
|
132
|
+
if (field.value !== undefined && field.value !== null) {
|
|
133
|
+
attrs.push('value="' + escapeAttribute(field.value) + '"');
|
|
134
|
+
}
|
|
135
|
+
if (field.placeholder) attrs.push('placeholder="' + escapeAttribute(field.placeholder) + '"');
|
|
136
|
+
if (field.required) attrs.push("required");
|
|
137
|
+
if (field.readonly) attrs.push("readonly");
|
|
138
|
+
if (field.disabled) attrs.push("disabled");
|
|
139
|
+
if (field.checked) attrs.push("checked");
|
|
140
|
+
if (field.autocomplete) attrs.push('autocomplete="' + escapeAttribute(field.autocomplete) + '"');
|
|
141
|
+
if (field.pattern) {
|
|
142
|
+
// Pattern is a RegExp at every boundary; render its source for the
|
|
143
|
+
// HTML attribute so client + server agree on the regex. String
|
|
144
|
+
// patterns are rejected at config time because validate() refuses
|
|
145
|
+
// them — symmetric handling avoids a silent server-validation gap.
|
|
146
|
+
if (!(field.pattern instanceof RegExp)) {
|
|
147
|
+
throw new Error("forms.render: field '" + field.name +
|
|
148
|
+
"'.pattern must be a pre-compiled RegExp; got " +
|
|
149
|
+
(typeof field.pattern) + ". Wrap the source string with `RegExp` at config time.");
|
|
150
|
+
}
|
|
151
|
+
attrs.push('pattern="' + escapeAttribute(field.pattern.source) + '"');
|
|
152
|
+
}
|
|
153
|
+
if (field.min !== undefined) attrs.push('min="' + escapeAttribute(field.min) + '"');
|
|
154
|
+
if (field.max !== undefined) attrs.push('max="' + escapeAttribute(field.max) + '"');
|
|
155
|
+
if (field.step !== undefined) attrs.push('step="' + escapeAttribute(field.step) + '"');
|
|
156
|
+
if (field.minlength !== undefined) attrs.push('minlength="' + escapeAttribute(field.minlength) + '"');
|
|
157
|
+
if (field.maxlength !== undefined) attrs.push('maxlength="' + escapeAttribute(field.maxlength) + '"');
|
|
158
|
+
if (field.id) attrs.push('id="' + escapeAttribute(field.id) + '"');
|
|
159
|
+
if (field.className) attrs.push('class="' + escapeAttribute(field.className) + '"');
|
|
160
|
+
return "<input " + attrs.join(" ") + ">";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _renderTextarea(field) {
|
|
164
|
+
var attrs = ['name="' + escapeAttribute(field.name) + '"'];
|
|
165
|
+
if (field.placeholder) attrs.push('placeholder="' + escapeAttribute(field.placeholder) + '"');
|
|
166
|
+
if (field.required) attrs.push("required");
|
|
167
|
+
if (field.readonly) attrs.push("readonly");
|
|
168
|
+
if (field.disabled) attrs.push("disabled");
|
|
169
|
+
if (field.rows !== undefined) attrs.push('rows="' + escapeAttribute(field.rows) + '"');
|
|
170
|
+
if (field.cols !== undefined) attrs.push('cols="' + escapeAttribute(field.cols) + '"');
|
|
171
|
+
if (field.minlength !== undefined) attrs.push('minlength="' + escapeAttribute(field.minlength) + '"');
|
|
172
|
+
if (field.maxlength !== undefined) attrs.push('maxlength="' + escapeAttribute(field.maxlength) + '"');
|
|
173
|
+
if (field.id) attrs.push('id="' + escapeAttribute(field.id) + '"');
|
|
174
|
+
if (field.className) attrs.push('class="' + escapeAttribute(field.className) + '"');
|
|
175
|
+
var body = field.value !== undefined && field.value !== null ? template.escapeHtml(field.value) : "";
|
|
176
|
+
return "<textarea " + attrs.join(" ") + ">" + body + "</textarea>";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _renderSelect(field) {
|
|
180
|
+
var attrs = ['name="' + escapeAttribute(field.name) + '"'];
|
|
181
|
+
if (field.required) attrs.push("required");
|
|
182
|
+
if (field.disabled) attrs.push("disabled");
|
|
183
|
+
if (field.multiple) attrs.push("multiple");
|
|
184
|
+
if (field.id) attrs.push('id="' + escapeAttribute(field.id) + '"');
|
|
185
|
+
if (field.className) attrs.push('class="' + escapeAttribute(field.className) + '"');
|
|
186
|
+
var options = field.options || [];
|
|
187
|
+
var optionsHtml = options.map(function (o) {
|
|
188
|
+
var optAttrs = ['value="' + escapeAttribute(o.value) + '"'];
|
|
189
|
+
var selected = (o.selected) || (field.value !== undefined && String(field.value) === String(o.value));
|
|
190
|
+
if (selected) optAttrs.push("selected");
|
|
191
|
+
if (o.disabled) optAttrs.push("disabled");
|
|
192
|
+
return "<option " + optAttrs.join(" ") + ">" + template.escapeHtml(o.label === undefined ? o.value : o.label) + "</option>";
|
|
193
|
+
}).join("");
|
|
194
|
+
return "<select " + attrs.join(" ") + ">" + optionsHtml + "</select>";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _renderField(field) {
|
|
198
|
+
if (!field || !field.name || typeof field.name !== "string") {
|
|
199
|
+
throw new Error("forms.render: each field requires a name");
|
|
200
|
+
}
|
|
201
|
+
var type = field.type || "text";
|
|
202
|
+
var control;
|
|
203
|
+
if (type === "textarea") control = _renderTextarea(field);
|
|
204
|
+
else if (type === "select") control = _renderSelect(field);
|
|
205
|
+
else if (INPUT_TYPES[type]) control = _renderInput(field);
|
|
206
|
+
else throw new Error("forms.render: unsupported field type: " + type);
|
|
207
|
+
|
|
208
|
+
// Hidden + submit fields don't need a label wrapper
|
|
209
|
+
if (type === "hidden" || type === "submit") return control;
|
|
210
|
+
|
|
211
|
+
if (field.label) {
|
|
212
|
+
return "<label>" + template.escapeHtml(field.label) + " " + control + "</label>";
|
|
213
|
+
}
|
|
214
|
+
return control;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function render(spec) {
|
|
218
|
+
if (!spec || typeof spec.action !== "string" || spec.action.length === 0) {
|
|
219
|
+
throw new Error("forms.render: spec.action is required");
|
|
220
|
+
}
|
|
221
|
+
if (!Array.isArray(spec.fields)) {
|
|
222
|
+
throw new Error("forms.render: spec.fields must be an array");
|
|
223
|
+
}
|
|
224
|
+
var method = (spec.method || "POST").toUpperCase();
|
|
225
|
+
var attrs = [
|
|
226
|
+
'method="' + escapeAttribute(method) + '"',
|
|
227
|
+
'action="' + escapeAttribute(spec.action) + '"',
|
|
228
|
+
];
|
|
229
|
+
if (spec.id) attrs.push('id="' + escapeAttribute(spec.id) + '"');
|
|
230
|
+
if (spec.className) attrs.push('class="' + escapeAttribute(spec.className) + '"');
|
|
231
|
+
if (spec.enctype) attrs.push('enctype="' + escapeAttribute(spec.enctype) + '"');
|
|
232
|
+
if (spec.autocomplete) attrs.push('autocomplete="' + escapeAttribute(spec.autocomplete) + '"');
|
|
233
|
+
if (spec.target) attrs.push('target="' + escapeAttribute(spec.target) + '"');
|
|
234
|
+
|
|
235
|
+
var inner = "";
|
|
236
|
+
|
|
237
|
+
// CSRF hidden input. State-changing methods need it; safe methods
|
|
238
|
+
// (GET/HEAD) don't. We still render it on POST etc. when csrfToken
|
|
239
|
+
// is provided; the middleware decides whether it's required.
|
|
240
|
+
if (spec.csrfToken && method !== "GET" && method !== "HEAD") {
|
|
241
|
+
var csrfFieldName = spec.csrfFieldName || "_csrf";
|
|
242
|
+
inner += '<input type="hidden" name="' + escapeAttribute(csrfFieldName) +
|
|
243
|
+
'" value="' + escapeAttribute(spec.csrfToken) + '">';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (var i = 0; i < spec.fields.length; i++) {
|
|
247
|
+
inner += _renderField(spec.fields[i]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Auto-submit button if the operator didn't include one
|
|
251
|
+
var hasSubmit = spec.fields.some(function (f) { return f.type === "submit"; });
|
|
252
|
+
if (!hasSubmit) {
|
|
253
|
+
inner += '<button type="submit">' + template.escapeHtml(spec.submitLabel || "Submit") + "</button>";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return "<form " + attrs.join(" ") + ">" + inner + "</form>";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ============================================================
|
|
260
|
+
// Server-side validation
|
|
261
|
+
// ============================================================
|
|
262
|
+
//
|
|
263
|
+
// validate(spec, body) → { valid, errors, values }
|
|
264
|
+
//
|
|
265
|
+
// errors is a map fieldName → message (only failing fields). values is
|
|
266
|
+
// the coerced data: number/checkbox/etc. converted to their canonical
|
|
267
|
+
// types. body is whatever the operator passes — typically an object
|
|
268
|
+
// from URLSearchParams or JSON parse.
|
|
269
|
+
|
|
270
|
+
function _coerce(field, raw) {
|
|
271
|
+
// Checkbox special-case: browsers omit unchecked boxes from the
|
|
272
|
+
// submission entirely, so `raw === undefined` means "unchecked"
|
|
273
|
+
// (false), not "not provided." Coerce to false BEFORE the generic
|
|
274
|
+
// undefined-passthrough below.
|
|
275
|
+
if (field.type === "checkbox") {
|
|
276
|
+
if (raw === undefined || raw === null || raw === "" || raw === "false" || raw === "0") return false;
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
if (raw === undefined) return undefined;
|
|
280
|
+
switch (field.type) {
|
|
281
|
+
case "number":
|
|
282
|
+
case "range":
|
|
283
|
+
if (raw === "" || raw === null) return null;
|
|
284
|
+
var n = Number(raw);
|
|
285
|
+
return Number.isFinite(n) ? n : NaN;
|
|
286
|
+
case "date":
|
|
287
|
+
case "datetime-local":
|
|
288
|
+
case "time":
|
|
289
|
+
return typeof raw === "string" ? raw : String(raw);
|
|
290
|
+
default:
|
|
291
|
+
return typeof raw === "string" ? raw : String(raw);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function _isEmpty(v) {
|
|
296
|
+
return v === undefined || v === null || v === "";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function validate(spec, body) {
|
|
300
|
+
if (!spec || !Array.isArray(spec.fields)) {
|
|
301
|
+
throw new Error("forms.validate: spec.fields must be an array");
|
|
302
|
+
}
|
|
303
|
+
body = body || {};
|
|
304
|
+
var errors = {};
|
|
305
|
+
var values = {};
|
|
306
|
+
|
|
307
|
+
for (var i = 0; i < spec.fields.length; i++) {
|
|
308
|
+
var f = spec.fields[i];
|
|
309
|
+
if (!f.name) continue;
|
|
310
|
+
if (f.type === "submit") continue; // submit buttons aren't data
|
|
311
|
+
|
|
312
|
+
var raw = body[f.name];
|
|
313
|
+
var coerced = _coerce(f, raw);
|
|
314
|
+
values[f.name] = coerced;
|
|
315
|
+
|
|
316
|
+
if (f.required && _isEmpty(coerced) && coerced !== false) {
|
|
317
|
+
errors[f.name] = f.errorMessages && f.errorMessages.required
|
|
318
|
+
? f.errorMessages.required
|
|
319
|
+
: (f.label || f.name) + " is required";
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (_isEmpty(coerced)) continue;
|
|
323
|
+
|
|
324
|
+
// Type-specific checks
|
|
325
|
+
if (f.type === "number" || f.type === "range") {
|
|
326
|
+
if (Number.isNaN(coerced)) {
|
|
327
|
+
errors[f.name] = (f.label || f.name) + " must be a number";
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (f.min !== undefined && coerced < Number(f.min)) {
|
|
331
|
+
errors[f.name] = (f.label || f.name) + " must be ≥ " + f.min;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (f.max !== undefined && coerced > Number(f.max)) {
|
|
335
|
+
errors[f.name] = (f.label || f.name) + " must be ≤ " + f.max;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (f.type === "email" && typeof coerced === "string") {
|
|
340
|
+
// Length cap before the regex test (RFC 5321 §4.5.3.1.3 = 254
|
|
341
|
+
// chars). Any longer is rejected without engaging the regex.
|
|
342
|
+
// Same pragmatic check the rest of the framework uses
|
|
343
|
+
// (safeSchema.EMAIL_RE — shared so we don't carry parallel
|
|
344
|
+
// copies of the same /^[^\s@]+@[^\s@]+\.[^\s@]+$/ regex).
|
|
345
|
+
if (coerced.length > MAX_EMAIL_LENGTH || !safeSchema.EMAIL_RE.test(coerced)) {
|
|
346
|
+
errors[f.name] = (f.label || f.name) + " must be a valid email address";
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (f.type === "url" && typeof coerced === "string") {
|
|
351
|
+
// Form `url` fields come from the request body — operator/external
|
|
352
|
+
// input. Length cap (RFC 7230 §3.1.1 has no fixed limit; the
|
|
353
|
+
// conventional cap is 8 KiB) bounds the work before safeUrl.parse
|
|
354
|
+
// engages. Route through safeUrl so the scheme allowlist is
|
|
355
|
+
// honored (https-only by default; operator opts in to http via
|
|
356
|
+
// field meta).
|
|
357
|
+
if (coerced.length > MAX_URL_LENGTH) {
|
|
358
|
+
errors[f.name] = (f.label || f.name) + " must be a valid URL";
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
safeUrl.parse(coerced, {
|
|
363
|
+
allowedProtocols: f.allowHttp ? safeUrl.ALLOW_HTTP_ALL : safeUrl.ALLOW_HTTP_TLS,
|
|
364
|
+
});
|
|
365
|
+
} catch (_e) {
|
|
366
|
+
errors[f.name] = (f.label || f.name) + " must be a valid URL";
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (typeof coerced === "string") {
|
|
371
|
+
if (f.minlength !== undefined && coerced.length < Number(f.minlength)) {
|
|
372
|
+
errors[f.name] = (f.label || f.name) + " must be at least " + f.minlength + " characters";
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (f.maxlength !== undefined && coerced.length > Number(f.maxlength)) {
|
|
376
|
+
errors[f.name] = (f.label || f.name) + " must be at most " + f.maxlength + " characters";
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (f.pattern) {
|
|
380
|
+
// Pattern accepts a pre-compiled RegExp only — taking a string
|
|
381
|
+
// and compiling it here would be a ReDoS surface against the
|
|
382
|
+
// form-render engine. Operators construct the RegExp at config
|
|
383
|
+
// time so the framework never compiles operator-supplied source
|
|
384
|
+
// on the request path.
|
|
385
|
+
if (!(f.pattern instanceof RegExp)) {
|
|
386
|
+
throw new Error("forms.validate: field '" + f.name +
|
|
387
|
+
"'.pattern must be a pre-compiled RegExp; got " +
|
|
388
|
+
(typeof f.pattern) + ". Wrap the source string with `RegExp` at config time.");
|
|
389
|
+
}
|
|
390
|
+
if (!f.pattern.test(coerced)) {
|
|
391
|
+
errors[f.name] = f.errorMessages && f.errorMessages.pattern
|
|
392
|
+
? f.errorMessages.pattern
|
|
393
|
+
: (f.label || f.name) + " has an invalid format";
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if ((f.type === "select" || f.type === "radio") && Array.isArray(f.options)) {
|
|
399
|
+
var allowed = f.options.map(function (o) { return String(o.value); });
|
|
400
|
+
if (allowed.indexOf(String(coerced)) === -1) {
|
|
401
|
+
errors[f.name] = (f.label || f.name) + " has an invalid value";
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
valid: Object.keys(errors).length === 0,
|
|
409
|
+
errors: errors,
|
|
410
|
+
values: values,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
module.exports = {
|
|
415
|
+
generateCsrfToken: generateCsrfToken,
|
|
416
|
+
verifyCsrfToken: verifyCsrfToken,
|
|
417
|
+
render: render,
|
|
418
|
+
validate: validate,
|
|
419
|
+
escapeAttribute: escapeAttribute,
|
|
420
|
+
escapeHtml: template.escapeHtml,
|
|
421
|
+
CSRF_TOKEN_BYTES: CSRF_TOKEN_BYTES,
|
|
422
|
+
};
|