secure-ui-components 0.2.2 → 0.2.4

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.
Files changed (28) hide show
  1. package/dist/components/secure-card/secure-card.js +1 -766
  2. package/dist/components/secure-datetime/secure-datetime.js +1 -570
  3. package/dist/components/secure-file-upload/secure-file-upload.js +1 -868
  4. package/dist/components/secure-form/secure-form.js +1 -797
  5. package/dist/components/secure-input/secure-input.css +67 -1
  6. package/dist/components/secure-input/secure-input.d.ts +14 -0
  7. package/dist/components/secure-input/secure-input.d.ts.map +1 -1
  8. package/dist/components/secure-input/secure-input.js +1 -805
  9. package/dist/components/secure-input/secure-input.js.map +1 -1
  10. package/dist/components/secure-password-confirm/secure-password-confirm.js +1 -329
  11. package/dist/components/secure-select/secure-select.js +1 -589
  12. package/dist/components/secure-submit-button/secure-submit-button.js +1 -378
  13. package/dist/components/secure-table/secure-table.js +33 -528
  14. package/dist/components/secure-telemetry-provider/secure-telemetry-provider.js +1 -201
  15. package/dist/components/secure-textarea/secure-textarea.css +66 -1
  16. package/dist/components/secure-textarea/secure-textarea.d.ts +11 -0
  17. package/dist/components/secure-textarea/secure-textarea.d.ts.map +1 -1
  18. package/dist/components/secure-textarea/secure-textarea.js +1 -436
  19. package/dist/components/secure-textarea/secure-textarea.js.map +1 -1
  20. package/dist/core/base-component.d.ts +18 -0
  21. package/dist/core/base-component.d.ts.map +1 -1
  22. package/dist/core/base-component.js +1 -455
  23. package/dist/core/base-component.js.map +1 -1
  24. package/dist/core/security-config.js +1 -242
  25. package/dist/core/types.js +0 -2
  26. package/dist/index.js +1 -17
  27. package/dist/package.json +4 -2
  28. package/package.json +4 -2
@@ -30,800 +30,4 @@
30
30
  *
31
31
  * @module secure-form
32
32
  * @license MIT
33
- */
34
- var _a;
35
- import { SecurityTier } from '../../core/security-config.js';
36
- /**
37
- * Secure Form Web Component
38
- *
39
- * Provides a security-hardened form with CSRF protection and validation.
40
- * The component works as a standard HTML form without JavaScript and
41
- * enhances with security features when JavaScript is available.
42
- *
43
- * IMPORTANT: This component does NOT use Shadow DOM. It creates a native
44
- * <form> element in light DOM to ensure proper form submission and
45
- * accessibility. It extends HTMLElement directly, not SecureBaseComponent.
46
- *
47
- * @extends HTMLElement
48
- */
49
- export class SecureForm extends HTMLElement {
50
- /** @private Whether component styles have been added to the document */
51
- static __stylesAdded = false;
52
- /**
53
- * Form element reference
54
- * @private
55
- */
56
- #formElement = null;
57
- /**
58
- * CSRF token hidden input reference
59
- * @private
60
- */
61
- #csrfInput = null;
62
- /**
63
- * Form status message element
64
- * @private
65
- */
66
- #statusElement = null;
67
- /**
68
- * Whether form is currently submitting
69
- * @private
70
- */
71
- #isSubmitting = false;
72
- /**
73
- * Timestamp when the form was connected to the DOM.
74
- * Used to compute session duration for telemetry.
75
- * @private
76
- */
77
- #sessionStart = Date.now();
78
- /**
79
- * Unique ID for this form instance
80
- * @private
81
- */
82
- #instanceId = `secure-form-${Math.random().toString(36).substring(2, 11)}`;
83
- /**
84
- * Security tier for this form
85
- * @private
86
- */
87
- #securityTier = SecurityTier.PUBLIC;
88
- /**
89
- * Observed attributes for this component
90
- *
91
- * @static
92
- */
93
- static get observedAttributes() {
94
- return [
95
- 'security-tier',
96
- 'action',
97
- 'method',
98
- 'enctype',
99
- 'csrf-token',
100
- 'csrf-header-name',
101
- 'csrf-field-name',
102
- 'novalidate'
103
- ];
104
- }
105
- /**
106
- * Constructor
107
- */
108
- constructor() {
109
- super();
110
- // No Shadow DOM - we work exclusively in light DOM for form compatibility
111
- }
112
- /**
113
- * Called when element is connected to DOM
114
- *
115
- * Progressive Enhancement Strategy:
116
- * - Create a native <form> in light DOM (not Shadow DOM)
117
- * - Move all children into the form
118
- * - Add CSRF token as hidden field
119
- * - Attach event listeners for validation and optional enhancement
120
- */
121
- connectedCallback() {
122
- // Only initialize once
123
- if (this.#formElement) {
124
- return;
125
- }
126
- this.#sessionStart = Date.now();
127
- // Read security tier from attribute before anything else.
128
- // attributeChangedCallback fires before connectedCallback but early-returns
129
- // when #formElement is null, so the tier needs to be read here.
130
- const tierAttr = this.getAttribute('security-tier');
131
- if (tierAttr) {
132
- this.#securityTier = tierAttr;
133
- }
134
- // Progressive enhancement: check for server-rendered <form> in light DOM
135
- const existingForm = this.querySelector('form');
136
- if (existingForm) {
137
- // Adopt the existing form element
138
- this.#formElement = existingForm;
139
- this.#formElement.id = this.#instanceId;
140
- if (!this.#formElement.classList.contains('secure-form')) {
141
- this.#formElement.classList.add('secure-form');
142
- }
143
- // Apply/override form attributes from the custom element
144
- this.#applyFormAttributes();
145
- // Check if CSRF field already exists in the server-rendered form
146
- const csrfFieldName = this.getAttribute('csrf-field-name') || 'csrf_token';
147
- const existingCsrf = existingForm.querySelector(`input[name="${csrfFieldName}"]`);
148
- if (existingCsrf) {
149
- this.#csrfInput = existingCsrf;
150
- // Update token value from attribute if it differs
151
- const csrfToken = this.getAttribute('csrf-token');
152
- if (csrfToken && existingCsrf.value !== csrfToken) {
153
- existingCsrf.value = csrfToken;
154
- }
155
- }
156
- else {
157
- this.#createCsrfField();
158
- }
159
- }
160
- else {
161
- // No server-rendered form: create one (original behavior)
162
- this.#formElement = document.createElement('form');
163
- this.#formElement.id = this.#instanceId;
164
- this.#formElement.className = 'secure-form';
165
- // Apply form attributes
166
- this.#applyFormAttributes();
167
- // Create CSRF token field
168
- this.#createCsrfField();
169
- // Move all existing children (inputs, buttons) into the form
170
- while (this.firstChild) {
171
- this.#formElement.appendChild(this.firstChild);
172
- }
173
- // Append the form to this element
174
- this.appendChild(this.#formElement);
175
- }
176
- // Create status message area
177
- this.#statusElement = document.createElement('div');
178
- this.#statusElement.className = 'form-status form-status-hidden';
179
- this.#statusElement.setAttribute('role', 'status');
180
- this.#statusElement.setAttribute('aria-live', 'polite');
181
- this.#formElement.insertBefore(this.#statusElement, this.#formElement.firstChild);
182
- // Add inline styles (since we're not using Shadow DOM)
183
- this.#addInlineStyles();
184
- // Set up event listeners
185
- this.#attachEventListeners();
186
- this.audit('form_initialized', {
187
- formId: this.#instanceId,
188
- action: this.#formElement.action,
189
- method: this.#formElement.method
190
- });
191
- }
192
- /**
193
- * Add component styles (CSP-compliant via adoptedStyleSheets on document)
194
- *
195
- * Uses constructable stylesheets instead of injecting <style> elements,
196
- * which would be blocked by strict Content Security Policy.
197
- *
198
- * @private
199
- */
200
- #addInlineStyles() {
201
- // Only inject once globally — <link> in document head, loads from 'self' (CSP-safe)
202
- if (!_a.__stylesAdded) {
203
- const link = document.createElement('link');
204
- link.rel = 'stylesheet';
205
- link.href = new URL('./secure-form.css', import.meta.url).href;
206
- document.head.appendChild(link);
207
- _a.__stylesAdded = true;
208
- }
209
- }
210
- /**
211
- * Apply attributes to the form element
212
- *
213
- * @private
214
- */
215
- #applyFormAttributes() {
216
- const action = this.getAttribute('action');
217
- if (action) {
218
- this.#formElement.action = action;
219
- }
220
- const method = this.getAttribute('method') || 'POST';
221
- this.#formElement.method = method.toUpperCase();
222
- const enctype = this.getAttribute('enctype') || 'application/x-www-form-urlencoded';
223
- this.#formElement.enctype = enctype;
224
- // Disable browser validation - we handle it ourselves
225
- const novalidate = this.hasAttribute('novalidate');
226
- if (novalidate) {
227
- this.#formElement.noValidate = true;
228
- }
229
- // Disable autocomplete for SENSITIVE and CRITICAL tiers
230
- if (this.#securityTier === SecurityTier.SENSITIVE || this.#securityTier === SecurityTier.CRITICAL) {
231
- this.#formElement.autocomplete = 'off';
232
- }
233
- }
234
- /**
235
- * Create CSRF token hidden field
236
- *
237
- * Security Note: CSRF tokens prevent Cross-Site Request Forgery attacks.
238
- * The token should be unique per session and validated server-side.
239
- *
240
- * @private
241
- */
242
- #createCsrfField() {
243
- const csrfToken = this.getAttribute('csrf-token');
244
- if (csrfToken) {
245
- this.#csrfInput = document.createElement('input');
246
- this.#csrfInput.type = 'hidden';
247
- // Use 'csrf_token' for backend compatibility (common convention)
248
- // Backends can configure via csrf-field-name attribute if needed
249
- const fieldName = this.getAttribute('csrf-field-name') || 'csrf_token';
250
- this.#csrfInput.name = fieldName;
251
- this.#csrfInput.value = csrfToken;
252
- this.#formElement.appendChild(this.#csrfInput);
253
- this.audit('csrf_token_injected', {
254
- formId: this.#instanceId,
255
- fieldName: fieldName
256
- });
257
- }
258
- else if (this.securityTier === SecurityTier.SENSITIVE ||
259
- this.securityTier === SecurityTier.CRITICAL) {
260
- console.warn('CSRF token not provided for SENSITIVE/CRITICAL tier form');
261
- }
262
- }
263
- /**
264
- * Attach event listeners
265
- *
266
- * @private
267
- */
268
- #attachEventListeners() {
269
- // Submit event - validate and enhance submission
270
- this.#formElement.addEventListener('submit', (e) => {
271
- void this.#handleSubmit(e);
272
- });
273
- // Listen for secure field events
274
- this.addEventListener('secure-input', (e) => {
275
- this.#handleFieldChange(e);
276
- });
277
- this.addEventListener('secure-textarea', (e) => {
278
- this.#handleFieldChange(e);
279
- });
280
- this.addEventListener('secure-select', (e) => {
281
- this.#handleFieldChange(e);
282
- });
283
- }
284
- /**
285
- * Handle field change events
286
- *
287
- * @private
288
- */
289
- #handleFieldChange(_event) {
290
- // Clear form-level errors when user makes changes
291
- this.#clearStatus();
292
- }
293
- /**
294
- * Handle form submission
295
- *
296
- * Progressive Enhancement Strategy:
297
- * - If 'enhance' attribute is NOT set: Allow native form submission (backend agnostic)
298
- * - If 'enhance' attribute IS set: Intercept and use Fetch API with JSON
299
- *
300
- * Security Note: This is where we perform comprehensive validation,
301
- * rate limiting, and secure data collection before submission.
302
- *
303
- * @private
304
- */
305
- async #handleSubmit(event) {
306
- // Check if we should enhance the form submission with JavaScript
307
- const shouldEnhance = this.hasAttribute('enhance');
308
- // Prevent double submission
309
- if (this.#isSubmitting) {
310
- event.preventDefault();
311
- return;
312
- }
313
- // Detect absent CSRF token on sensitive/critical tiers at submission time
314
- if ((this.#securityTier === SecurityTier.SENSITIVE || this.#securityTier === SecurityTier.CRITICAL) &&
315
- !this.#csrfInput?.value) {
316
- this.dispatchEvent(new CustomEvent('secure-threat-detected', {
317
- detail: {
318
- fieldName: this.#instanceId,
319
- threatType: 'csrf-token-absent',
320
- patternId: 'csrf-token-absent',
321
- tier: this.#securityTier,
322
- timestamp: Date.now(),
323
- },
324
- bubbles: true,
325
- composed: true,
326
- }));
327
- }
328
- // Check rate limit
329
- const rateLimitCheck = this.checkRateLimit();
330
- if (!rateLimitCheck.allowed) {
331
- event.preventDefault();
332
- this.#showStatus(`Too many submission attempts. Please wait ${Math.ceil(rateLimitCheck.retryAfter / 1000)} seconds.`, 'error');
333
- this.audit('form_rate_limited', {
334
- formId: this.#instanceId,
335
- retryAfter: rateLimitCheck.retryAfter
336
- });
337
- return;
338
- }
339
- // Discover and validate all secure fields
340
- const validation = this.#validateAllFields();
341
- if (!validation.valid) {
342
- event.preventDefault();
343
- this.#showStatus(validation.errors.join(', '), 'error');
344
- this.audit('form_validation_failed', {
345
- formId: this.#instanceId,
346
- errors: validation.errors
347
- });
348
- return;
349
- }
350
- // If not enhancing, allow native form submission
351
- if (!shouldEnhance) {
352
- // CRITICAL: Sync secure-input values to hidden fields for native submission
353
- // Secure-input components have their actual <input> in Shadow DOM,
354
- // so we need to create hidden inputs for native form submission
355
- this.#syncSecureInputsToForm();
356
- // Let the browser handle the submission normally
357
- this.audit('form_submitted_native', {
358
- formId: this.#instanceId,
359
- action: this.#formElement.action,
360
- method: this.#formElement.method
361
- });
362
- return; // Allow default behavior
363
- }
364
- // Enhanced submission with JavaScript (Fetch API)
365
- event.preventDefault();
366
- // Mark as submitting
367
- this.#isSubmitting = true;
368
- this.#showStatus('Submitting...', 'info');
369
- this.#disableForm();
370
- // Collect form data securely
371
- const formData = this.#collectFormData();
372
- // Collect behavioral telemetry from all secure fields
373
- const telemetry = this.#collectTelemetry();
374
- // Audit log submission (include risk score for server-side correlation)
375
- this.audit('form_submitted_enhanced', {
376
- formId: this.#instanceId,
377
- action: this.#formElement.action,
378
- method: this.#formElement.method,
379
- fieldCount: Object.keys(formData).length,
380
- riskScore: telemetry.riskScore,
381
- riskSignals: telemetry.riskSignals
382
- });
383
- // Dispatch pre-submit event for custom handling
384
- const preSubmitEvent = new CustomEvent('secure-form-submit', {
385
- detail: {
386
- formData,
387
- formElement: this.#formElement,
388
- telemetry,
389
- preventDefault: () => {
390
- this.#isSubmitting = false;
391
- this.#enableForm();
392
- }
393
- },
394
- bubbles: true,
395
- composed: true,
396
- cancelable: true
397
- });
398
- const shouldContinue = this.dispatchEvent(preSubmitEvent);
399
- if (!shouldContinue) {
400
- // Custom handler prevented default submission
401
- this.#isSubmitting = false;
402
- this.#enableForm();
403
- return;
404
- }
405
- // Perform secure submission via Fetch
406
- try {
407
- await this.#submitForm(formData, telemetry);
408
- }
409
- catch (error) {
410
- this.#showStatus('Submission failed. Please try again.', 'error');
411
- this.audit('form_submission_error', {
412
- formId: this.#instanceId,
413
- error: error.message
414
- });
415
- }
416
- finally {
417
- this.#isSubmitting = false;
418
- this.#enableForm();
419
- }
420
- }
421
- /**
422
- * Sync secure-input component values to hidden form inputs
423
- *
424
- * CRITICAL for native form submission: Secure-input components have their
425
- * actual <input> elements in Shadow DOM, which can't participate in native
426
- * form submission. We create/update hidden inputs in the form for each
427
- * secure-input to enable backend-agnostic form submission.
428
- *
429
- * @private
430
- */
431
- #syncSecureInputsToForm() {
432
- const secureInputs = this.#formElement.querySelectorAll('secure-input, secure-textarea, secure-select, secure-datetime, secure-file-upload');
433
- secureInputs.forEach((input) => {
434
- const name = input.getAttribute('name');
435
- if (!name)
436
- return;
437
- // CRITICAL: Disable the native fallback inputs inside the secure component
438
- // so they don't participate in native form submission (they are empty because
439
- // the user typed into the shadow DOM input). Without this, the server receives
440
- // the empty native input value first, ignoring the synced hidden input.
441
- const nativeFallbacks = input.querySelectorAll(`input[name="${name}"], textarea[name="${name}"], select[name="${name}"]`);
442
- nativeFallbacks.forEach((fallback) => {
443
- fallback.removeAttribute('name');
444
- });
445
- // Check if hidden input already exists
446
- let hiddenInput = this.#formElement.querySelector(`input[type="hidden"][data-secure-input="${name}"]`);
447
- if (!hiddenInput) {
448
- // Create hidden input for this secure-input
449
- hiddenInput = document.createElement('input');
450
- hiddenInput.type = 'hidden';
451
- hiddenInput.setAttribute('data-secure-input', name);
452
- hiddenInput.name = name;
453
- this.#formElement.appendChild(hiddenInput);
454
- }
455
- // Sync the value
456
- hiddenInput.value = input.value || '';
457
- });
458
- }
459
- /**
460
- * Validate all secure fields in the form
461
- *
462
- * @private
463
- */
464
- #validateAllFields() {
465
- const errors = [];
466
- // Find all secure input components within the form
467
- const inputs = this.#formElement.querySelectorAll('secure-input, secure-textarea, secure-select, secure-datetime, secure-file-upload');
468
- inputs.forEach((input) => {
469
- if (typeof input.valid === 'boolean' && !input.valid) {
470
- const label = input.getAttribute('label') || input.getAttribute('name') || 'Field';
471
- errors.push(`${label} is invalid`);
472
- }
473
- });
474
- return {
475
- valid: errors.length === 0,
476
- errors
477
- };
478
- }
479
- /**
480
- * Collect form data from secure fields
481
- *
482
- * Security Note: We collect data from secure components which have already
483
- * sanitized their values. We also include the CSRF token.
484
- *
485
- * @private
486
- */
487
- #collectFormData() {
488
- const formData = Object.create(null);
489
- // Collect from secure components within the form
490
- const secureInputs = this.#formElement.querySelectorAll('secure-input, secure-textarea, secure-select, secure-datetime, secure-file-upload');
491
- secureInputs.forEach((input) => {
492
- const typedInput = input;
493
- if (typedInput.name) {
494
- formData[typedInput.name] = typedInput.value;
495
- }
496
- });
497
- // Collect from standard form inputs (for non-secure fields)
498
- const standardInputs = this.#formElement.querySelectorAll('input:not([type="hidden"]), textarea:not(.textarea-field), select:not(.select-field)');
499
- standardInputs.forEach((input) => {
500
- const typedInput = input;
501
- if (typedInput.name) {
502
- formData[typedInput.name] = this.sanitizeValue(typedInput.value);
503
- }
504
- });
505
- // Include CSRF token
506
- if (this.#csrfInput) {
507
- formData[this.#csrfInput.name] = this.#csrfInput.value;
508
- }
509
- return formData;
510
- }
511
- /**
512
- * Submit form data securely
513
- *
514
- * Security Note: We use fetch API with secure headers and proper CSRF handling.
515
- * In production, ensure the server validates the CSRF token.
516
- *
517
- * @private
518
- */
519
- async #submitForm(formData, telemetry) {
520
- const action = this.#formElement.action;
521
- const method = this.#formElement.method;
522
- // Prepare headers
523
- const headers = {
524
- 'Content-Type': 'application/json'
525
- };
526
- // Add CSRF token to header if specified
527
- const csrfHeaderName = this.getAttribute('csrf-header-name');
528
- if (csrfHeaderName && this.#csrfInput) {
529
- headers[csrfHeaderName] = this.#csrfInput.value;
530
- }
531
- // Bundle telemetry alongside form data in a single request.
532
- // The server receives both the user's input and behavioral context in one
533
- // atomic payload, enabling server-side risk evaluation without a second round-trip.
534
- // Using a prefixed key (_telemetry) avoids collisions with form field names.
535
- const payload = { ...formData, _telemetry: telemetry };
536
- // Perform fetch
537
- const response = await fetch(action, {
538
- method: method,
539
- headers: headers,
540
- body: JSON.stringify(payload),
541
- credentials: 'same-origin', // Include cookies for CSRF validation
542
- mode: 'cors',
543
- cache: 'no-cache',
544
- redirect: 'follow'
545
- });
546
- if (!response.ok) {
547
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
548
- }
549
- // Success
550
- this.#showStatus('Form submitted successfully!', 'success');
551
- // Dispatch success event
552
- this.dispatchEvent(new CustomEvent('secure-form-success', {
553
- detail: {
554
- formData,
555
- response,
556
- telemetry
557
- },
558
- bubbles: true,
559
- composed: true
560
- }));
561
- return response;
562
- }
563
- /**
564
- * Disable form during submission
565
- *
566
- * @private
567
- */
568
- #disableForm() {
569
- // Disable all form controls
570
- const controls = this.#formElement.querySelectorAll('input, textarea, select, button');
571
- controls.forEach((control) => {
572
- control.disabled = true;
573
- });
574
- // Also disable secure components
575
- const secureFields = this.querySelectorAll('secure-input, secure-textarea, secure-select, secure-datetime, secure-file-upload');
576
- secureFields.forEach((field) => {
577
- field.setAttribute('disabled', '');
578
- });
579
- }
580
- /**
581
- * Enable form after submission
582
- *
583
- * @private
584
- */
585
- #enableForm() {
586
- // Enable all form controls
587
- const controls = this.#formElement.querySelectorAll('input, textarea, select, button');
588
- controls.forEach((control) => {
589
- control.disabled = false;
590
- });
591
- // Also enable secure components
592
- const secureFields = this.querySelectorAll('secure-input, secure-textarea, secure-select, secure-datetime, secure-file-upload');
593
- secureFields.forEach((field) => {
594
- field.removeAttribute('disabled');
595
- });
596
- }
597
- /**
598
- * Show status message
599
- *
600
- * @private
601
- */
602
- #showStatus(message, type = 'info') {
603
- this.#statusElement.textContent = message;
604
- this.#statusElement.className = `form-status form-status-${type}`;
605
- }
606
- /**
607
- * Clear status message
608
- *
609
- * @private
610
- */
611
- #clearStatus() {
612
- this.#statusElement.textContent = '';
613
- this.#statusElement.className = 'form-status form-status-hidden';
614
- }
615
- // ── Telemetry ──────────────────────────────────────────────────────────────
616
- /**
617
- * Collect behavioral telemetry from all secure child fields and compute
618
- * a session-level risk score.
619
- *
620
- * @private
621
- */
622
- #collectTelemetry() {
623
- const selector = 'secure-input, secure-textarea, secure-select, secure-datetime, secure-card';
624
- const secureFields = this.querySelectorAll(selector);
625
- const fields = [];
626
- secureFields.forEach((field) => {
627
- const typedField = field;
628
- if (typeof typedField.getFieldTelemetry !== 'function')
629
- return;
630
- const fieldName = field.getAttribute('name') ?? field.tagName.toLowerCase();
631
- const snapshot = {
632
- ...typedField.getFieldTelemetry(),
633
- fieldName,
634
- fieldType: field.tagName.toLowerCase(),
635
- };
636
- fields.push(snapshot);
637
- });
638
- const sessionDuration = Date.now() - this.#sessionStart;
639
- const { riskScore, riskSignals } = this.#computeRiskScore(fields, sessionDuration);
640
- return {
641
- sessionDuration,
642
- fieldCount: fields.length,
643
- fields,
644
- riskScore,
645
- riskSignals,
646
- submittedAt: new Date().toISOString(),
647
- };
648
- }
649
- /**
650
- * Compute a composite risk score 0–100 and list of contributing signals.
651
- *
652
- * Signal weights (additive, capped at 100):
653
- * - Session completed in under 3 s: +30 (inhuman speed)
654
- * - Session completed in under 8 s: +10 (very fast)
655
- * - All fields pasted (no keystrokes anywhere): +25 (credential stuffing / scripted fill)
656
- * - Any field has typing velocity > 15 ks/s: +15 (bot-like keyboard simulation)
657
- * - Any field never focused (focusCount = 0): +15 (field was filled without user interaction)
658
- * - Multiple fields probed without entry: +10 (focusCount > 1 but blurWithoutChange > 1)
659
- * - High correction count on any field (> 5): +5 (deliberate obfuscation / hesitation)
660
- * - Autofill on all non-empty fields: -10 (genuine browser autofill is low-risk)
661
- *
662
- * @private
663
- */
664
- #computeRiskScore(fields, sessionDuration) {
665
- const signals = [];
666
- let score = 0;
667
- // Session speed
668
- if (sessionDuration < 3000) {
669
- score += 30;
670
- signals.push('session_too_fast');
671
- }
672
- else if (sessionDuration < 8000) {
673
- score += 10;
674
- signals.push('session_fast');
675
- }
676
- if (fields.length === 0) {
677
- return { riskScore: Math.min(score, 100), riskSignals: signals };
678
- }
679
- // All fields pasted with zero keystrokes — scripted fill
680
- const allPasted = fields.every(f => f.pasteDetected && f.velocity === 0);
681
- if (allPasted) {
682
- score += 25;
683
- signals.push('all_fields_pasted');
684
- }
685
- // Any field with superhuman typing speed
686
- const hasHighVelocity = fields.some(f => f.velocity > 15);
687
- if (hasHighVelocity) {
688
- score += 15;
689
- signals.push('high_velocity_typing');
690
- }
691
- // Any field never touched (focusCount === 0) — programmatic fill
692
- const hasUnfocusedField = fields.some(f => f.focusCount === 0);
693
- if (hasUnfocusedField) {
694
- score += 15;
695
- signals.push('field_filled_without_focus');
696
- }
697
- // Form probing: multiple focus/blur cycles with no value entry
698
- const hasProbing = fields.some(f => f.focusCount > 1 && f.blurWithoutChange > 1);
699
- if (hasProbing) {
700
- score += 10;
701
- signals.push('form_probing');
702
- }
703
- // Excessive corrections on any field
704
- const hasHighCorrections = fields.some(f => f.corrections > 5);
705
- if (hasHighCorrections) {
706
- score += 5;
707
- signals.push('high_correction_count');
708
- }
709
- // Genuine autofill on all non-empty fields is a trust signal — reduce score
710
- const autofillFields = fields.filter(f => f.autofillDetected);
711
- if (autofillFields.length > 0 && autofillFields.length === fields.length) {
712
- score -= 10;
713
- signals.push('autofill_detected');
714
- }
715
- return { riskScore: Math.max(0, Math.min(score, 100)), riskSignals: signals };
716
- }
717
- /**
718
- * Get form data
719
- *
720
- * @public
721
- */
722
- getData() {
723
- return this.#collectFormData();
724
- }
725
- /**
726
- * Reset the form
727
- *
728
- * @public
729
- */
730
- reset() {
731
- if (this.#formElement) {
732
- this.#formElement.reset();
733
- this.#clearStatus();
734
- this.audit('form_reset', {
735
- formId: this.#instanceId
736
- });
737
- }
738
- }
739
- /**
740
- * Programmatically submit the form
741
- *
742
- * @public
743
- */
744
- submit() {
745
- if (this.#formElement) {
746
- // Trigger submit event which will run our validation
747
- this.#formElement.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
748
- }
749
- }
750
- /**
751
- * Check if form is valid
752
- *
753
- * @public
754
- */
755
- get valid() {
756
- const validation = this.#validateAllFields();
757
- return validation.valid;
758
- }
759
- /**
760
- * Cleanup on disconnect
761
- */
762
- disconnectedCallback() {
763
- // Clear any sensitive form data
764
- if (this.#formElement) {
765
- this.#formElement.reset();
766
- }
767
- }
768
- /**
769
- * Handle attribute changes
770
- */
771
- attributeChangedCallback(name, _oldValue, newValue) {
772
- if (!this.#formElement)
773
- return;
774
- switch (name) {
775
- case 'security-tier':
776
- this.#securityTier = (newValue || SecurityTier.PUBLIC);
777
- break;
778
- case 'action':
779
- this.#formElement.action = newValue;
780
- break;
781
- case 'method':
782
- this.#formElement.method = newValue;
783
- break;
784
- case 'csrf-token':
785
- if (this.#csrfInput) {
786
- this.#csrfInput.value = newValue;
787
- }
788
- break;
789
- }
790
- }
791
- /**
792
- * Get security tier
793
- */
794
- get securityTier() {
795
- return this.#securityTier;
796
- }
797
- /**
798
- * Sanitize a value to prevent XSS
799
- *
800
- * Uses the same div.textContent round-trip as SecureBaseComponent to correctly
801
- * handle all injection vectors (attribute injection, entity encoding, etc).
802
- */
803
- sanitizeValue(value) {
804
- if (typeof value !== 'string')
805
- return '';
806
- const div = document.createElement('div');
807
- div.textContent = value;
808
- return div.innerHTML;
809
- }
810
- /**
811
- * Audit log helper
812
- */
813
- audit(action, data) {
814
- if (console.debug) {
815
- console.debug(`[secure-form] ${action}`, data);
816
- }
817
- }
818
- /**
819
- * Check rate limit (stub - implement proper rate limiting in production)
820
- */
821
- checkRateLimit() {
822
- return { allowed: true, retryAfter: 0 };
823
- }
824
- }
825
- _a = SecureForm;
826
- // Define the custom element
827
- customElements.define('secure-form', SecureForm);
828
- export default SecureForm;
829
- //# sourceMappingURL=secure-form.js.map
33
+ */var d;import{SecurityTier as u}from"../../core/security-config.js";class m extends HTMLElement{static __stylesAdded=!1;#t=null;#e=null;#i=null;#n=!1;#u=Date.now();#s=`secure-form-${Math.random().toString(36).substring(2,11)}`;#r=u.PUBLIC;static get observedAttributes(){return["security-tier","action","method","enctype","csrf-token","csrf-header-name","csrf-field-name","novalidate"]}constructor(){super()}connectedCallback(){if(this.#t)return;this.#u=Date.now();const t=this.getAttribute("security-tier");t&&(this.#r=t);const s=this.querySelector("form");if(s){this.#t=s,this.#t.id=this.#s,this.#t.classList.contains("secure-form")||this.#t.classList.add("secure-form"),this.#l();const e=this.getAttribute("csrf-field-name")||"csrf_token",i=s.querySelector(`input[name="${e}"]`);if(i){this.#e=i;const r=this.getAttribute("csrf-token");r&&i.value!==r&&(i.value=r)}else this.#h()}else{for(this.#t=document.createElement("form"),this.#t.id=this.#s,this.#t.className="secure-form",this.#l(),this.#h();this.firstChild;)this.#t.appendChild(this.firstChild);this.appendChild(this.#t)}this.#i=document.createElement("div"),this.#i.className="form-status form-status-hidden",this.#i.setAttribute("role","status"),this.#i.setAttribute("aria-live","polite"),this.#t.insertBefore(this.#i,this.#t.firstChild),this.#p(),this.#b(),this.audit("form_initialized",{formId:this.#s,action:this.#t.action,method:this.#t.method})}#p(){if(!d.__stylesAdded){const t=document.createElement("link");t.rel="stylesheet",t.href=new URL("./secure-form.css",import.meta.url).href,document.head.appendChild(t),d.__stylesAdded=!0}}#l(){const t=this.getAttribute("action");t&&(this.#t.action=t);const s=this.getAttribute("method")||"POST";this.#t.method=s.toUpperCase();const e=this.getAttribute("enctype")||"application/x-www-form-urlencoded";this.#t.enctype=e,this.hasAttribute("novalidate")&&(this.#t.noValidate=!0),(this.#r===u.SENSITIVE||this.#r===u.CRITICAL)&&(this.#t.autocomplete="off")}#h(){const t=this.getAttribute("csrf-token");if(t){this.#e=document.createElement("input"),this.#e.type="hidden";const s=this.getAttribute("csrf-field-name")||"csrf_token";this.#e.name=s,this.#e.value=t,this.#t.appendChild(this.#e),this.audit("csrf_token_injected",{formId:this.#s,fieldName:s})}else(this.securityTier===u.SENSITIVE||this.securityTier===u.CRITICAL)&&console.warn("CSRF token not provided for SENSITIVE/CRITICAL tier form")}#b(){this.#t.addEventListener("submit",t=>{this.#y(t)}),this.addEventListener("secure-input",t=>{this.#o(t)}),this.addEventListener("secure-textarea",t=>{this.#o(t)}),this.addEventListener("secure-select",t=>{this.#o(t)})}#o(t){this.#f()}async#y(t){const s=this.hasAttribute("enhance");if(this.#n){t.preventDefault();return}(this.#r===u.SENSITIVE||this.#r===u.CRITICAL)&&!this.#e?.value&&this.dispatchEvent(new CustomEvent("secure-threat-detected",{detail:{fieldName:this.#s,threatType:"csrf-token-absent",patternId:"csrf-token-absent",tier:this.#r,timestamp:Date.now()},bubbles:!0,composed:!0}));const e=this.checkRateLimit();if(!e.allowed){t.preventDefault(),this.#a(`Too many submission attempts. Please wait ${Math.ceil(e.retryAfter/1e3)} seconds.`,"error"),this.audit("form_rate_limited",{formId:this.#s,retryAfter:e.retryAfter});return}const i=this.#d();if(!i.valid){t.preventDefault(),this.#a(i.errors.join(", "),"error"),this.audit("form_validation_failed",{formId:this.#s,errors:i.errors});return}if(!s){this.#g(),this.audit("form_submitted_native",{formId:this.#s,action:this.#t.action,method:this.#t.method});return}t.preventDefault(),this.#n=!0,this.#a("Submitting...","info"),this.#S();const r=this.#m(),a=this.#A();this.audit("form_submitted_enhanced",{formId:this.#s,action:this.#t.action,method:this.#t.method,fieldCount:Object.keys(r).length,riskScore:a.riskScore,riskSignals:a.riskSignals});const c=new CustomEvent("secure-form-submit",{detail:{formData:r,formElement:this.#t,telemetry:a,preventDefault:()=>{this.#n=!1,this.#c()}},bubbles:!0,composed:!0,cancelable:!0});if(!this.dispatchEvent(c)){this.#n=!1,this.#c();return}try{await this.#v(r,a)}catch(l){this.#a("Submission failed. Please try again.","error"),this.audit("form_submission_error",{formId:this.#s,error:l.message})}finally{this.#n=!1,this.#c()}}#g(){this.#t.querySelectorAll("secure-input, secure-textarea, secure-select, secure-datetime, secure-file-upload").forEach(s=>{const e=s.getAttribute("name");if(!e)return;s.querySelectorAll(`input[name="${e}"], textarea[name="${e}"], select[name="${e}"]`).forEach(a=>{a.removeAttribute("name")});let r=this.#t.querySelector(`input[type="hidden"][data-secure-input="${e}"]`);r||(r=document.createElement("input"),r.type="hidden",r.setAttribute("data-secure-input",e),r.name=e,this.#t.appendChild(r)),r.value=s.value||""})}#d(){const t=[];return this.#t.querySelectorAll("secure-input, secure-textarea, secure-select, secure-datetime, secure-file-upload").forEach(e=>{if(typeof e.valid=="boolean"&&!e.valid){const i=e.getAttribute("label")||e.getAttribute("name")||"Field";t.push(`${i} is invalid`)}}),{valid:t.length===0,errors:t}}#m(){const t=Object.create(null);return this.#t.querySelectorAll("secure-input, secure-textarea, secure-select, secure-datetime, secure-file-upload").forEach(i=>{const r=i;r.name&&(t[r.name]=r.value)}),this.#t.querySelectorAll('input:not([type="hidden"]), textarea:not(.textarea-field), select:not(.select-field)').forEach(i=>{const r=i;r.name&&(t[r.name]=this.sanitizeValue(r.value))}),this.#e&&(t[this.#e.name]=this.#e.value),t}async#v(t,s){const e=this.#t.action,i=this.#t.method,r={"Content-Type":"application/json"},a=this.getAttribute("csrf-header-name");a&&this.#e&&(r[a]=this.#e.value);const c={...t,_telemetry:s},o=await fetch(e,{method:i,headers:r,body:JSON.stringify(c),credentials:"same-origin",mode:"cors",cache:"no-cache",redirect:"follow"});if(!o.ok)throw new Error(`HTTP ${o.status}: ${o.statusText}`);return this.#a("Form submitted successfully!","success"),this.dispatchEvent(new CustomEvent("secure-form-success",{detail:{formData:t,response:o,telemetry:s},bubbles:!0,composed:!0})),o}#S(){this.#t.querySelectorAll("input, textarea, select, button").forEach(e=>{e.disabled=!0}),this.querySelectorAll("secure-input, secure-textarea, secure-select, secure-datetime, secure-file-upload").forEach(e=>{e.setAttribute("disabled","")})}#c(){this.#t.querySelectorAll("input, textarea, select, button").forEach(e=>{e.disabled=!1}),this.querySelectorAll("secure-input, secure-textarea, secure-select, secure-datetime, secure-file-upload").forEach(e=>{e.removeAttribute("disabled")})}#a(t,s="info"){this.#i.textContent=t,this.#i.className=`form-status form-status-${s}`}#f(){this.#i.textContent="",this.#i.className="form-status form-status-hidden"}#A(){const s=this.querySelectorAll("secure-input, secure-textarea, secure-select, secure-datetime, secure-card"),e=[];s.forEach(c=>{const o=c;if(typeof o.getFieldTelemetry!="function")return;const l=c.getAttribute("name")??c.tagName.toLowerCase(),h={...o.getFieldTelemetry(),fieldName:l,fieldType:c.tagName.toLowerCase()};e.push(h)});const i=Date.now()-this.#u,{riskScore:r,riskSignals:a}=this.#C(e,i);return{sessionDuration:i,fieldCount:e.length,fields:e,riskScore:r,riskSignals:a,submittedAt:new Date().toISOString()}}#C(t,s){const e=[];let i=0;if(s<3e3?(i+=30,e.push("session_too_fast")):s<8e3&&(i+=10,e.push("session_fast")),t.length===0)return{riskScore:Math.min(i,100),riskSignals:e};t.every(n=>n.pasteDetected&&n.velocity===0)&&(i+=25,e.push("all_fields_pasted")),t.some(n=>n.velocity>15)&&(i+=15,e.push("high_velocity_typing")),t.some(n=>n.focusCount===0)&&(i+=15,e.push("field_filled_without_focus")),t.some(n=>n.focusCount>1&&n.blurWithoutChange>1)&&(i+=10,e.push("form_probing")),t.some(n=>n.corrections>5)&&(i+=5,e.push("high_correction_count"));const h=t.filter(n=>n.autofillDetected);return h.length>0&&h.length===t.length&&(i-=10,e.push("autofill_detected")),{riskScore:Math.max(0,Math.min(i,100)),riskSignals:e}}getData(){return this.#m()}reset(){this.#t&&(this.#t.reset(),this.#f(),this.audit("form_reset",{formId:this.#s}))}submit(){this.#t&&this.#t.dispatchEvent(new Event("submit",{bubbles:!0,cancelable:!0}))}get valid(){return this.#d().valid}disconnectedCallback(){this.#t&&this.#t.reset()}attributeChangedCallback(t,s,e){if(this.#t)switch(t){case"security-tier":this.#r=e||u.PUBLIC;break;case"action":this.#t.action=e;break;case"method":this.#t.method=e;break;case"csrf-token":this.#e&&(this.#e.value=e);break}}get securityTier(){return this.#r}sanitizeValue(t){if(typeof t!="string")return"";const s=document.createElement("div");return s.textContent=t,s.innerHTML}audit(t,s){console.debug&&console.debug(`[secure-form] ${t}`,s)}checkRateLimit(){return{allowed:!0,retryAfter:0}}}d=m,customElements.define("secure-form",m);var b=m;export{m as SecureForm,b as default};