secure-ui-components 0.2.3 → 0.2.5

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.
@@ -27,870 +27,4 @@
27
27
  *
28
28
  * @module secure-input
29
29
  * @license MIT
30
- */
31
- import { SecureBaseComponent } from '../../core/base-component.js';
32
- import { SecurityTier } from '../../core/security-config.js';
33
- /**
34
- * Secure Input Web Component
35
- *
36
- * Provides a security-hardened input field with progressive enhancement.
37
- * The component works as a standard form input without JavaScript and
38
- * enhances with security features when JavaScript is available.
39
- *
40
- * @extends SecureBaseComponent
41
- */
42
- export class SecureInput extends SecureBaseComponent {
43
- /**
44
- * Input element reference
45
- * @private
46
- */
47
- #inputElement = null;
48
- /**
49
- * Label element reference
50
- * @private
51
- */
52
- #labelElement = null;
53
- /**
54
- * Error container element reference
55
- * @private
56
- */
57
- #errorContainer = null;
58
- /**
59
- * Threat feedback container — separate from validation errors so
60
- * #clearErrors() never clobbers an active threat message.
61
- * @private
62
- */
63
- #threatContainer = null;
64
- /**
65
- * The actual unmasked value
66
- * @private
67
- */
68
- #actualValue = '';
69
- /**
70
- * Whether the input is currently masked
71
- * @private
72
- */
73
- #isMasked = false;
74
- /**
75
- * Hidden input element in light DOM for form submission
76
- * @private
77
- */
78
- #hiddenInput = null;
79
- /**
80
- * Unique ID for this input instance
81
- * @private
82
- */
83
- #instanceId = `secure-input-${Math.random().toString(36).substring(2, 11)}`;
84
- /**
85
- * Observed attributes for this component
86
- *
87
- * @static
88
- */
89
- static get observedAttributes() {
90
- return [
91
- ...super.observedAttributes,
92
- 'name',
93
- 'type',
94
- 'label',
95
- 'placeholder',
96
- 'required',
97
- 'pattern',
98
- 'minlength',
99
- 'maxlength',
100
- 'autocomplete',
101
- 'value'
102
- ];
103
- }
104
- /**
105
- * Constructor
106
- */
107
- constructor() {
108
- super();
109
- }
110
- /**
111
- * Render the input component
112
- *
113
- * Security Note: We use a native <input> element wrapped in our web component
114
- * to ensure progressive enhancement. The native input works without JavaScript,
115
- * and we enhance it with security features when JS is available.
116
- *
117
- * @protected
118
- */
119
- render() {
120
- const fragment = document.createDocumentFragment();
121
- const container = document.createElement('div');
122
- container.className = 'input-container';
123
- container.setAttribute('part', 'container');
124
- // Create label
125
- const label = this.getAttribute('label');
126
- if (label) {
127
- this.#labelElement = document.createElement('label');
128
- this.#labelElement.htmlFor = this.#instanceId;
129
- this.#labelElement.textContent = this.sanitizeValue(label);
130
- this.#labelElement.setAttribute('part', 'label');
131
- container.appendChild(this.#labelElement);
132
- }
133
- // Create input wrapper for progressive enhancement
134
- const inputWrapper = document.createElement('div');
135
- inputWrapper.className = 'input-wrapper';
136
- inputWrapper.setAttribute('part', 'wrapper');
137
- // Create the actual input element
138
- this.#inputElement = document.createElement('input');
139
- this.#inputElement.id = this.#instanceId;
140
- this.#inputElement.className = 'input-field';
141
- this.#inputElement.setAttribute('part', 'input');
142
- // Apply attributes from web component to native input
143
- this.#applyInputAttributes();
144
- // Set up event listeners
145
- this.#attachEventListeners();
146
- inputWrapper.appendChild(this.#inputElement);
147
- container.appendChild(inputWrapper);
148
- // Create error container
149
- // role="alert" already implies aria-live="assertive" — do not override with polite
150
- this.#errorContainer = document.createElement('div');
151
- this.#errorContainer.className = 'error-container hidden';
152
- this.#errorContainer.setAttribute('role', 'alert');
153
- this.#errorContainer.setAttribute('part', 'error');
154
- this.#errorContainer.id = `${this.#instanceId}-error`;
155
- container.appendChild(this.#errorContainer);
156
- // Threat feedback container — only rendered visibly when threat-feedback attribute
157
- // is present and detectInjection() fires. Kept separate from #errorContainer so
158
- // #clearErrors() (called on every input event) never clobbers a threat message.
159
- this.#threatContainer = document.createElement('div');
160
- this.#threatContainer.className = 'threat-container hidden';
161
- this.#threatContainer.setAttribute('role', 'alert');
162
- this.#threatContainer.setAttribute('part', 'threat');
163
- this.#threatContainer.id = `${this.#instanceId}-threat`;
164
- container.appendChild(this.#threatContainer);
165
- // CRITICAL: Create hidden input in light DOM for native form submission
166
- // The actual input is in Shadow DOM and can't participate in form submission
167
- this.#createHiddenInputForForm();
168
- // CRITICAL: Neutralize native fallback inputs in light DOM.
169
- // The server renders native <input> elements inside <secure-input> for no-JS
170
- // progressive enhancement. Now that JS has loaded and the shadow DOM input is
171
- // active, these native fallbacks must be neutralized:
172
- // 1. They are hidden by shadow DOM so users can't interact with them
173
- // 2. They still have 'required' attributes that trigger HTML5 constraint
174
- // validation, silently blocking form submission (browser can't show the
175
- // validation popup for a hidden element, so "nothing happens" on click)
176
- // 3. They still have 'name' attributes causing duplicate empty form fields
177
- this.#neutralizeFallbackInputs();
178
- // Add component styles (CSP-compliant via adoptedStyleSheets)
179
- this.addComponentStyles(this.#getComponentStyles());
180
- fragment.appendChild(container);
181
- return fragment;
182
- }
183
- /**
184
- * Create hidden input in light DOM for native form submission
185
- *
186
- * CRITICAL: The actual input is in Shadow DOM and can't participate in
187
- * native form submission. We create a hidden input in light DOM that syncs
188
- * with the Shadow DOM input value.
189
- *
190
- * IMPORTANT: Only create hidden input if NOT inside a <secure-form> component.
191
- * The secure-form component handles its own hidden input creation.
192
- *
193
- * @private
194
- */
195
- #createHiddenInputForForm() {
196
- const name = this.getAttribute('name');
197
- if (!name)
198
- return;
199
- // Check if this input is inside a <secure-form> component
200
- // If yes, the secure-form will handle hidden input creation
201
- const isInsideSecureForm = this.closest('secure-form');
202
- if (isInsideSecureForm) {
203
- // Don't create hidden input - secure-form will handle it
204
- return;
205
- }
206
- // Create hidden input in light DOM
207
- this.#hiddenInput = document.createElement('input');
208
- this.#hiddenInput.type = 'hidden';
209
- this.#hiddenInput.name = name;
210
- this.#hiddenInput.value = this.#actualValue || '';
211
- // Append to light DOM (this element, not shadow root)
212
- this.appendChild(this.#hiddenInput);
213
- }
214
- /**
215
- * Sync hidden input value with the actual input value
216
- *
217
- * @private
218
- */
219
- #syncHiddenInput() {
220
- if (this.#hiddenInput) {
221
- this.#hiddenInput.value = this.#actualValue || '';
222
- }
223
- }
224
- /**
225
- * Neutralize native fallback inputs in light DOM
226
- *
227
- * When the component initializes with JavaScript, the shadow DOM input takes
228
- * over. The server-rendered native fallback inputs (for no-JS progressive
229
- * enhancement) must be neutralized to prevent:
230
- * - HTML5 constraint validation blocking form submission silently
231
- * - Duplicate form field values on native form submission
232
- *
233
- * @private
234
- */
235
- #neutralizeFallbackInputs() {
236
- const fallbacks = this.querySelectorAll('input, textarea, select');
237
- fallbacks.forEach((el) => {
238
- // Skip the hidden input we created for form submission
239
- if (el === this.#hiddenInput)
240
- return;
241
- const input = el;
242
- // Remove attributes that interfere with form submission
243
- input.removeAttribute('required');
244
- input.removeAttribute('name');
245
- input.removeAttribute('minlength');
246
- input.removeAttribute('maxlength');
247
- input.removeAttribute('pattern');
248
- // Mark as inert so it's completely non-interactive
249
- input.setAttribute('tabindex', '-1');
250
- input.setAttribute('aria-hidden', 'true');
251
- });
252
- }
253
- /**
254
- * Apply attributes from the web component to the native input
255
- *
256
- * Security Note: This is where we enforce tier-specific security controls
257
- * like autocomplete, caching, and validation rules.
258
- *
259
- * @private
260
- */
261
- #applyInputAttributes() {
262
- const config = this.config;
263
- // Name attribute (required for form submission)
264
- const name = this.getAttribute('name');
265
- if (name) {
266
- this.#inputElement.name = this.sanitizeValue(name);
267
- }
268
- // Accessible name fallback: when no visible label is provided, use the name
269
- // attribute as aria-label so screen readers can identify the field
270
- if (!this.getAttribute('label') && name) {
271
- this.#inputElement.setAttribute('aria-label', this.sanitizeValue(name));
272
- }
273
- // Link input to its error container for screen readers
274
- this.#inputElement.setAttribute('aria-describedby', `${this.#instanceId}-error`);
275
- // Type attribute
276
- const type = this.getAttribute('type') || 'text';
277
- this.#inputElement.type = type;
278
- // Placeholder
279
- const placeholder = this.getAttribute('placeholder');
280
- if (placeholder) {
281
- this.#inputElement.placeholder = this.sanitizeValue(placeholder);
282
- }
283
- // Required attribute
284
- if (this.hasAttribute('required') || config.validation.required) {
285
- this.#inputElement.required = true;
286
- this.#inputElement.setAttribute('aria-required', 'true');
287
- }
288
- // Pattern validation
289
- const pattern = this.getAttribute('pattern');
290
- if (pattern) {
291
- this.#inputElement.pattern = pattern;
292
- }
293
- // Length constraints
294
- const minLength = this.getAttribute('minlength');
295
- if (minLength) {
296
- this.#inputElement.minLength = parseInt(minLength, 10);
297
- }
298
- const maxLength = this.getAttribute('maxlength') || config.validation.maxLength;
299
- if (maxLength) {
300
- this.#inputElement.maxLength = parseInt(String(maxLength), 10);
301
- }
302
- // CRITICAL SECURITY: Autocomplete control based on tier
303
- // For SENSITIVE and CRITICAL tiers, we disable autocomplete to prevent
304
- // browser storage of sensitive data
305
- if (config.storage.allowAutocomplete) {
306
- const autocomplete = this.getAttribute('autocomplete') || 'on';
307
- this.#inputElement.autocomplete = autocomplete;
308
- }
309
- else {
310
- // Explicitly disable autocomplete for sensitive data
311
- this.#inputElement.autocomplete = 'off';
312
- // Also set autocomplete="new-password" for password fields to prevent
313
- // password managers from auto-filling
314
- if (this.#inputElement.type === 'password') {
315
- this.#inputElement.autocomplete = 'new-password';
316
- }
317
- }
318
- // Disabled state
319
- if (this.hasAttribute('disabled')) {
320
- this.#inputElement.disabled = true;
321
- }
322
- // Readonly state
323
- if (this.hasAttribute('readonly')) {
324
- this.#inputElement.readOnly = true;
325
- }
326
- // Initial value
327
- const value = this.getAttribute('value');
328
- if (value) {
329
- this.#setValue(value);
330
- }
331
- // Apply masking if configured for this tier.
332
- // Never mask format-validated types (email, url, tel): the browser runs
333
- // checkValidity() against the displayed value, and users must see their
334
- // input to verify correctness. Masking is only appropriate for opaque
335
- // text fields such as account numbers or SSNs (type="text").
336
- const NON_MASKABLE_TYPES = new Set(['email', 'url', 'tel']);
337
- if (config.masking.enabled && this.#inputElement.type !== 'password' && !NON_MASKABLE_TYPES.has(this.#inputElement.type)) {
338
- this.#isMasked = true;
339
- }
340
- }
341
- /**
342
- * Attach event listeners to the input
343
- *
344
- * @private
345
- */
346
- #attachEventListeners() {
347
- // Focus event - audit logging + telemetry
348
- this.#inputElement.addEventListener('focus', () => {
349
- this.recordTelemetryFocus();
350
- this.audit('input_focused', {
351
- name: this.#inputElement.name
352
- });
353
- });
354
- // Input event - real-time validation, change tracking + telemetry
355
- this.#inputElement.addEventListener('input', (e) => {
356
- this.recordTelemetryInput(e);
357
- this.#handleInput(e);
358
- });
359
- // Blur event - final validation + telemetry
360
- this.#inputElement.addEventListener('blur', () => {
361
- this.recordTelemetryBlur();
362
- this.#validateAndShowErrors();
363
- this.audit('input_blurred', {
364
- name: this.#inputElement.name,
365
- hasValue: this.#actualValue.length > 0
366
- });
367
- });
368
- // Change event - audit logging
369
- this.#inputElement.addEventListener('change', () => {
370
- this.audit('input_changed', {
371
- name: this.#inputElement.name,
372
- valueLength: this.#actualValue.length
373
- });
374
- });
375
- }
376
- /**
377
- * Handle input events
378
- *
379
- * Security Note: This is where we implement real-time masking and validation.
380
- * We never expose the actual value in the DOM for CRITICAL tier fields.
381
- *
382
- * @private
383
- */
384
- #handleInput(event) {
385
- // For masked inputs (except password which browser handles), we need to track
386
- // the actual unmasked value separately because the input element shows masked chars
387
- if (this.#isMasked && this.#inputElement.type !== 'password') {
388
- const inputEvent = event;
389
- const inputType = inputEvent.inputType;
390
- const data = inputEvent.data || '';
391
- // Get current state before we modify
392
- const currentDisplayValue = this.#inputElement.value;
393
- const cursorPos = this.#inputElement.selectionStart || 0;
394
- // Handle different input types by reconstructing the actual value
395
- if (inputType === 'deleteContentBackward') {
396
- // Backspace: remove character before cursor
397
- if (cursorPos < this.#actualValue.length) {
398
- this.#actualValue = this.#actualValue.substring(0, cursorPos) +
399
- this.#actualValue.substring(cursorPos + 1);
400
- }
401
- }
402
- else if (inputType === 'deleteContentForward') {
403
- // Delete key: character at cursor already removed, cursor position is correct
404
- this.#actualValue = this.#actualValue.substring(0, cursorPos) +
405
- this.#actualValue.substring(cursorPos + 1);
406
- }
407
- else if (inputType === 'insertText') {
408
- // User typed a character - insert at cursor position
409
- this.#actualValue = this.#actualValue.substring(0, cursorPos - data.length) +
410
- data +
411
- this.#actualValue.substring(cursorPos - data.length);
412
- }
413
- else if (inputType === 'insertFromPaste') {
414
- // User pasted - the data might be the full pasted content
415
- if (data) {
416
- this.#actualValue = this.#actualValue.substring(0, cursorPos - data.length) +
417
- data +
418
- this.#actualValue.substring(cursorPos - data.length);
419
- }
420
- }
421
- else {
422
- // For any other input type, use a simpler approach:
423
- // The display shows masked chars, but we can infer changes by comparing lengths
424
- const oldLength = this.#actualValue.length;
425
- const newLength = currentDisplayValue.length;
426
- if (newLength > oldLength) {
427
- // Something was added
428
- const diff = newLength - oldLength;
429
- const insertPos = cursorPos - diff;
430
- this.#actualValue = this.#actualValue.substring(0, insertPos) +
431
- currentDisplayValue.substring(insertPos, cursorPos) +
432
- this.#actualValue.substring(insertPos);
433
- }
434
- else if (newLength < oldLength) {
435
- // Something was removed (fallback)
436
- this.#actualValue = this.#actualValue.substring(0, cursorPos) +
437
- this.#actualValue.substring(cursorPos + (oldLength - newLength));
438
- }
439
- }
440
- // Now apply masking to the display
441
- const maskedValue = this.#maskValue(this.#actualValue);
442
- this.#inputElement.value = maskedValue;
443
- // Restore cursor position
444
- this.#inputElement.setSelectionRange(cursorPos, cursorPos);
445
- }
446
- else {
447
- // For non-masked inputs, just read the value normally
448
- this.#actualValue = this.#inputElement.value;
449
- }
450
- this.detectInjection(this.#actualValue, this.#inputElement.name);
451
- // Clear previous errors on input (improve UX)
452
- this.#clearErrors();
453
- // Sync hidden input for form submission
454
- this.#syncHiddenInput();
455
- // Dispatch custom event for parent forms
456
- this.dispatchEvent(new CustomEvent('secure-input', {
457
- detail: {
458
- name: this.#inputElement.name,
459
- value: this.#actualValue, // Parent can access actual value
460
- masked: this.#isMasked,
461
- tier: this.securityTier
462
- },
463
- bubbles: true,
464
- composed: true
465
- }));
466
- }
467
- /**
468
- * Mask a value based on tier configuration
469
- *
470
- * Security Note: For CRITICAL tier, we mask everything. For SENSITIVE tier,
471
- * we can optionally reveal last few characters (e.g., last 4 digits of phone).
472
- *
473
- * @private
474
- */
475
- #maskValue(value) {
476
- const config = this.config;
477
- const maskChar = config.masking.character;
478
- if (!config.masking.partial || this.securityTier === SecurityTier.CRITICAL) {
479
- // Mask everything
480
- return maskChar.repeat(value.length);
481
- }
482
- // Partial masking: show last 4 characters
483
- if (value.length <= 4) {
484
- return maskChar.repeat(value.length);
485
- }
486
- const maskedPart = maskChar.repeat(value.length - 4);
487
- const visiblePart = value.slice(-4);
488
- return maskedPart + visiblePart;
489
- }
490
- /**
491
- * Validate password strength based on security tier
492
- *
493
- * Tier rules:
494
- * - CRITICAL: uppercase + lowercase + digit + symbol, 8+ chars
495
- * - SENSITIVE: uppercase + lowercase + digit, 8+ chars
496
- * - AUTHENTICATED: 6+ chars
497
- * - PUBLIC: no strength requirement
498
- *
499
- * @private
500
- * @returns null if valid or not a password, error message string if invalid
501
- */
502
- #validatePasswordStrength(value) {
503
- if (!this.#inputElement || this.#inputElement.type !== 'password') {
504
- return null;
505
- }
506
- // Skip strength check on empty values — required check handles that
507
- if (!value || value.length === 0) {
508
- return null;
509
- }
510
- const tier = this.securityTier;
511
- if (tier === 'critical') {
512
- if (value.length < 8)
513
- return 'Password must be at least 8 characters';
514
- if (!/[a-z]/.test(value))
515
- return 'Password must include a lowercase letter';
516
- if (!/[A-Z]/.test(value))
517
- return 'Password must include an uppercase letter';
518
- if (!/[0-9]/.test(value))
519
- return 'Password must include a number';
520
- if (!/[^a-zA-Z0-9]/.test(value))
521
- return 'Password must include a special character';
522
- }
523
- else if (tier === 'sensitive') {
524
- if (value.length < 8)
525
- return 'Password must be at least 8 characters';
526
- if (!/[a-z]/.test(value))
527
- return 'Password must include a lowercase letter';
528
- if (!/[A-Z]/.test(value))
529
- return 'Password must include an uppercase letter';
530
- if (!/[0-9]/.test(value))
531
- return 'Password must include a number';
532
- }
533
- else if (tier === 'authenticated') {
534
- if (value.length < 6)
535
- return 'Password must be at least 6 characters';
536
- }
537
- return null;
538
- }
539
- /**
540
- * Validate number input for overflow and safe integer range
541
- *
542
- * Prevents JavaScript precision loss by checking against Number.MAX_SAFE_INTEGER.
543
- * Also enforces min/max attribute constraints.
544
- *
545
- * @private
546
- * @returns null if valid or not a number, error message string if invalid
547
- */
548
- #validateNumberOverflow(value) {
549
- if (!this.#inputElement || this.#inputElement.type !== 'number') {
550
- return null;
551
- }
552
- // Skip on empty values — required check handles that
553
- if (!value || value.length === 0) {
554
- return null;
555
- }
556
- const num = Number(value);
557
- if (!Number.isFinite(num)) {
558
- return 'Value must be a valid number';
559
- }
560
- // Check safe integer range for integer values (no decimal point)
561
- if (!value.includes('.') && !Number.isSafeInteger(num)) {
562
- return 'Value exceeds safe integer range';
563
- }
564
- // Enforce min/max attributes
565
- const minAttr = this.getAttribute('min');
566
- const maxAttr = this.getAttribute('max');
567
- if (minAttr !== null) {
568
- const min = Number(minAttr);
569
- if (Number.isFinite(min) && num < min) {
570
- return `Value must be at least ${min}`;
571
- }
572
- }
573
- if (maxAttr !== null) {
574
- const max = Number(maxAttr);
575
- if (Number.isFinite(max) && num > max) {
576
- return `Value must be at most ${max}`;
577
- }
578
- }
579
- return null;
580
- }
581
- /**
582
- * Validate the input and show error messages
583
- *
584
- * @private
585
- */
586
- #validateAndShowErrors() {
587
- // Check rate limit first
588
- const rateLimitCheck = this.checkRateLimit();
589
- if (!rateLimitCheck.allowed) {
590
- this.#showError(`Too many attempts. Please wait ${Math.ceil(rateLimitCheck.retryAfter / 1000)} seconds.`);
591
- return;
592
- }
593
- // Perform base validation
594
- const patternAttr = this.getAttribute('pattern');
595
- const minLength = this.getAttribute('minlength');
596
- const maxLength = this.getAttribute('maxlength');
597
- let compiledPattern = null;
598
- if (patternAttr) {
599
- try {
600
- // eslint-disable-next-line security/detect-non-literal-regexp
601
- compiledPattern = new RegExp(patternAttr);
602
- }
603
- catch {
604
- // Invalid regex from attribute — treat as no pattern
605
- }
606
- }
607
- const validation = this.validateInput(this.#actualValue, {
608
- required: this.hasAttribute('required') || this.config.validation.required,
609
- pattern: compiledPattern,
610
- minLength: minLength ? parseInt(minLength, 10) : 0,
611
- maxLength: maxLength ? parseInt(maxLength, 10) : this.config.validation.maxLength
612
- });
613
- if (!validation.valid) {
614
- this.#showError(validation.errors.join(', '));
615
- return;
616
- }
617
- // Type-specific validation: password strength
618
- const passwordError = this.#validatePasswordStrength(this.#actualValue);
619
- if (passwordError) {
620
- this.#showError(passwordError);
621
- return;
622
- }
623
- // Type-specific validation: number overflow
624
- const numberError = this.#validateNumberOverflow(this.#actualValue);
625
- if (numberError) {
626
- this.#showError(numberError);
627
- return;
628
- }
629
- // Native constraint validation: email, url, number format, date, etc.
630
- // For masked inputs, checkValidity() would run against the displayed mask
631
- // characters — temporarily swap in the actual value, capture the result,
632
- // then restore the mask.
633
- if (this.#inputElement && this.#actualValue) {
634
- let isValid;
635
- let validationMsg;
636
- if (this.#isMasked) {
637
- const prev = this.#inputElement.value;
638
- this.#inputElement.value = this.#actualValue;
639
- isValid = this.#inputElement.checkValidity();
640
- validationMsg = this.#inputElement.validationMessage;
641
- this.#inputElement.value = prev;
642
- }
643
- else {
644
- isValid = this.#inputElement.checkValidity();
645
- validationMsg = this.#inputElement.validationMessage;
646
- }
647
- if (!isValid) {
648
- this.#showError(validationMsg);
649
- return;
650
- }
651
- }
652
- }
653
- /**
654
- * Show error message
655
- *
656
- * @private
657
- */
658
- #showError(message) {
659
- this.#errorContainer.textContent = message;
660
- // Force reflow so browser registers the hidden state with content,
661
- // then remove hidden to trigger the CSS transition
662
- void this.#errorContainer.offsetHeight;
663
- this.#errorContainer.classList.remove('hidden');
664
- this.#inputElement.classList.add('error');
665
- this.#inputElement.setAttribute('aria-invalid', 'true');
666
- }
667
- /**
668
- * Clear error messages
669
- *
670
- * @private
671
- */
672
- #clearErrors() {
673
- // Start the hide animation first, clear text only after transition ends
674
- this.#errorContainer.classList.add('hidden');
675
- this.#errorContainer.addEventListener('transitionend', () => {
676
- if (this.#errorContainer.classList.contains('hidden')) {
677
- this.#errorContainer.textContent = '';
678
- }
679
- }, { once: true });
680
- this.#inputElement.classList.remove('error');
681
- this.#inputElement.removeAttribute('aria-invalid');
682
- }
683
- /**
684
- * Set the input value
685
- *
686
- * @private
687
- */
688
- #setValue(value) {
689
- this.#actualValue = value;
690
- if (this.#isMasked && this.#inputElement.type !== 'password') {
691
- this.#inputElement.value = this.#maskValue(value);
692
- }
693
- else {
694
- this.#inputElement.value = value;
695
- }
696
- // Sync hidden input for form submission
697
- this.#syncHiddenInput();
698
- }
699
- /**
700
- * Get component-specific styles
701
- *
702
- * @private
703
- */
704
- #getComponentStyles() {
705
- return new URL('./secure-input.css', import.meta.url).href;
706
- }
707
- /**
708
- * Handle attribute changes
709
- *
710
- * @protected
711
- */
712
- handleAttributeChange(name, _oldValue, newValue) {
713
- if (!this.#inputElement)
714
- return;
715
- switch (name) {
716
- case 'disabled':
717
- this.#inputElement.disabled = this.hasAttribute('disabled');
718
- break;
719
- case 'readonly':
720
- this.#inputElement.readOnly = this.hasAttribute('readonly');
721
- break;
722
- case 'value':
723
- if (newValue !== this.#actualValue) {
724
- this.#setValue(newValue || '');
725
- }
726
- break;
727
- }
728
- }
729
- /**
730
- * Get the current value (unmasked)
731
- *
732
- * Security Note: This exposes the actual value. Only call this when
733
- * submitting the form or when you have proper authorization.
734
- *
735
- * @public
736
- */
737
- get value() {
738
- return this.#actualValue;
739
- }
740
- /**
741
- * Set the value
742
- *
743
- * @public
744
- */
745
- set value(value) {
746
- this.#setValue(value || '');
747
- }
748
- /**
749
- * Get the input name
750
- *
751
- * @public
752
- */
753
- get name() {
754
- return this.#inputElement ? this.#inputElement.name : '';
755
- }
756
- /**
757
- * Check if the input is valid
758
- *
759
- * @public
760
- */
761
- get valid() {
762
- const patternAttr = this.getAttribute('pattern');
763
- const minLength = this.getAttribute('minlength');
764
- const maxLength = this.getAttribute('maxlength');
765
- let compiledPattern = null;
766
- if (patternAttr) {
767
- try {
768
- // eslint-disable-next-line security/detect-non-literal-regexp
769
- compiledPattern = new RegExp(patternAttr);
770
- }
771
- catch {
772
- // Invalid regex from attribute — treat as no pattern
773
- }
774
- }
775
- const validation = this.validateInput(this.#actualValue, {
776
- required: this.hasAttribute('required') || this.config.validation.required,
777
- pattern: compiledPattern,
778
- minLength: minLength ? parseInt(minLength, 10) : 0,
779
- maxLength: maxLength ? parseInt(maxLength, 10) : this.config.validation.maxLength
780
- });
781
- if (!validation.valid) {
782
- return false;
783
- }
784
- // Type-specific: password strength
785
- if (this.#validatePasswordStrength(this.#actualValue) !== null) {
786
- return false;
787
- }
788
- // Type-specific: number overflow
789
- if (this.#validateNumberOverflow(this.#actualValue) !== null) {
790
- return false;
791
- }
792
- // Delegate to the browser's native constraint validation for type-specific
793
- // format checking (email, url, number, date, etc.). This catches invalid
794
- // emails, malformed URLs, out-of-range numbers and more without duplicating
795
- // browser logic. Only relevant when there is a value to validate.
796
- // For masked inputs, validate the actual value not the displayed mask.
797
- if (this.#inputElement && this.#actualValue) {
798
- let checkValid;
799
- if (this.#isMasked) {
800
- const prev = this.#inputElement.value;
801
- this.#inputElement.value = this.#actualValue;
802
- checkValid = this.#inputElement.checkValidity();
803
- this.#inputElement.value = prev;
804
- }
805
- else {
806
- checkValid = this.#inputElement.checkValidity();
807
- }
808
- if (!checkValid) {
809
- return false;
810
- }
811
- }
812
- return true;
813
- }
814
- /**
815
- * Focus the input
816
- *
817
- * @public
818
- */
819
- focus() {
820
- if (this.#inputElement) {
821
- this.#inputElement.focus();
822
- }
823
- }
824
- /**
825
- * Blur the input
826
- *
827
- * @public
828
- */
829
- blur() {
830
- if (this.#inputElement) {
831
- this.#inputElement.blur();
832
- }
833
- }
834
- /**
835
- * Show inline threat feedback inside the component's shadow DOM.
836
- * Called by the base class when a threat is detected and threat-feedback is set.
837
- * Uses a separate container from validation errors so #clearErrors() does not
838
- * clobber an active threat message.
839
- * @protected
840
- */
841
- showThreatFeedback(patternId, tier) {
842
- if (!this.#threatContainer || !this.#inputElement)
843
- return;
844
- // Build content with DOM methods — CSP-safe, no innerHTML
845
- this.#threatContainer.textContent = '';
846
- const msg = document.createElement('span');
847
- msg.className = 'threat-message';
848
- msg.textContent = this.getThreatLabel(patternId);
849
- const patternBadge = document.createElement('span');
850
- patternBadge.className = 'threat-badge';
851
- patternBadge.textContent = patternId;
852
- const tierBadge = document.createElement('span');
853
- tierBadge.className = `threat-tier threat-tier--${tier}`;
854
- tierBadge.textContent = tier;
855
- this.#threatContainer.appendChild(msg);
856
- this.#threatContainer.appendChild(patternBadge);
857
- this.#threatContainer.appendChild(tierBadge);
858
- // Force reflow so the browser registers the hidden state before removing it,
859
- // ensuring the CSS transition fires correctly.
860
- void this.#threatContainer.offsetHeight;
861
- this.#threatContainer.classList.remove('hidden');
862
- this.#inputElement.classList.add('threat');
863
- this.#inputElement.setAttribute('aria-invalid', 'true');
864
- }
865
- /**
866
- * Clear the threat feedback container.
867
- * @protected
868
- */
869
- clearThreatFeedback() {
870
- if (!this.#threatContainer || !this.#inputElement)
871
- return;
872
- this.#threatContainer.classList.add('hidden');
873
- this.#threatContainer.addEventListener('transitionend', () => {
874
- if (this.#threatContainer.classList.contains('hidden')) {
875
- this.#threatContainer.textContent = '';
876
- }
877
- }, { once: true });
878
- this.#inputElement.classList.remove('threat');
879
- this.#inputElement.removeAttribute('aria-invalid');
880
- }
881
- /**
882
- * Cleanup on disconnect
883
- */
884
- disconnectedCallback() {
885
- super.disconnectedCallback();
886
- // Clear sensitive data from memory
887
- this.#actualValue = '';
888
- if (this.#inputElement) {
889
- this.#inputElement.value = '';
890
- }
891
- }
892
- }
893
- // Define the custom element
894
- customElements.define('secure-input', SecureInput);
895
- export default SecureInput;
896
- //# sourceMappingURL=secure-input.js.map
30
+ */import{SecureBaseComponent as d}from"../../core/base-component.js";import{SecurityTier as m}from"../../core/security-config.js";class c extends d{#t=null;#a=null;#s=null;#i=null;#e="";#n=!1;#r=null;#h=`secure-input-${Math.random().toString(36).substring(2,11)}`;static get observedAttributes(){return[...super.observedAttributes,"name","type","label","placeholder","required","pattern","minlength","maxlength","autocomplete","value"]}constructor(){super()}render(){const t=document.createDocumentFragment(),e=document.createElement("div");e.className="input-container",e.setAttribute("part","container");const i=this.getAttribute("label");i&&(this.#a=document.createElement("label"),this.#a.htmlFor=this.#h,this.#a.textContent=this.sanitizeValue(i),this.#a.setAttribute("part","label"),e.appendChild(this.#a));const s=document.createElement("div");return s.className="input-wrapper",s.setAttribute("part","wrapper"),this.#t=document.createElement("input"),this.#t.id=this.#h,this.#t.className="input-field",this.#t.setAttribute("part","input"),this.#g(),this.#f(),s.appendChild(this.#t),e.appendChild(s),this.#s=document.createElement("div"),this.#s.className="error-container hidden",this.#s.setAttribute("role","alert"),this.#s.setAttribute("part","error"),this.#s.id=`${this.#h}-error`,e.appendChild(this.#s),this.#i=document.createElement("div"),this.#i.className="threat-container hidden",this.#i.setAttribute("role","alert"),this.#i.setAttribute("part","threat"),this.#i.id=`${this.#h}-threat`,e.appendChild(this.#i),this.#p(),this.#b(),this.addComponentStyles(this.#L()),t.appendChild(e),t}#p(){const t=this.getAttribute("name");!t||this.closest("secure-form")||(this.#r=document.createElement("input"),this.#r.type="hidden",this.#r.name=t,this.#r.value=this.#e||"",this.appendChild(this.#r))}#o(){this.#r&&(this.#r.value=this.#e||"")}#b(){this.querySelectorAll("input, textarea, select").forEach(e=>{if(e===this.#r)return;const i=e;i.removeAttribute("required"),i.removeAttribute("name"),i.removeAttribute("minlength"),i.removeAttribute("maxlength"),i.removeAttribute("pattern"),i.setAttribute("tabindex","-1"),i.setAttribute("aria-hidden","true")})}#g(){const t=this.config,e=this.getAttribute("name");e&&(this.#t.name=this.sanitizeValue(e)),!this.getAttribute("label")&&e&&this.#t.setAttribute("aria-label",this.sanitizeValue(e)),this.#t.setAttribute("aria-describedby",`${this.#h}-error`);const i=this.getAttribute("type")||"text";this.#t.type=i;const s=this.getAttribute("placeholder");s&&(this.#t.placeholder=this.sanitizeValue(s)),(this.hasAttribute("required")||t.validation.required)&&(this.#t.required=!0,this.#t.setAttribute("aria-required","true"));const n=this.getAttribute("pattern");n&&(this.#t.pattern=n);const r=this.getAttribute("minlength");r&&(this.#t.minLength=parseInt(r,10));const l=this.getAttribute("maxlength")||t.validation.maxLength;if(l&&(this.#t.maxLength=parseInt(String(l),10)),t.storage.allowAutocomplete){const u=this.getAttribute("autocomplete")||"on";this.#t.autocomplete=u}else this.#t.autocomplete="off",this.#t.type==="password"&&(this.#t.autocomplete="new-password");this.hasAttribute("disabled")&&(this.#t.disabled=!0),this.hasAttribute("readonly")&&(this.#t.readOnly=!0);const a=this.getAttribute("value");a&&this.#u(a);const h=new Set(["email","url","tel"]);t.masking.enabled&&this.#t.type!=="password"&&!h.has(this.#t.type)&&(this.#n=!0)}#f(){this.#t.addEventListener("focus",()=>{this.recordTelemetryFocus(),this.audit("input_focused",{name:this.#t.name})}),this.#t.addEventListener("input",t=>{this.recordTelemetryInput(t),this.#A(t)}),this.#t.addEventListener("blur",()=>{this.recordTelemetryBlur(),this.#v(),this.audit("input_blurred",{name:this.#t.name,hasValue:this.#e.length>0})}),this.#t.addEventListener("change",()=>{this.audit("input_changed",{name:this.#t.name,valueLength:this.#e.length})})}#A(t){if(this.#n&&this.#t.type!=="password"){const e=t,i=e.inputType,s=e.data||"",n=this.#t.value,r=this.#t.selectionStart||0;if(i==="deleteContentBackward")r<this.#e.length&&(this.#e=this.#e.substring(0,r)+this.#e.substring(r+1));else if(i==="deleteContentForward")this.#e=this.#e.substring(0,r)+this.#e.substring(r+1);else if(i==="insertText")this.#e=this.#e.substring(0,r-s.length)+s+this.#e.substring(r-s.length);else if(i==="insertFromPaste")s&&(this.#e=this.#e.substring(0,r-s.length)+s+this.#e.substring(r-s.length));else{const a=this.#e.length,h=n.length;if(h>a){const u=h-a,o=r-u;this.#e=this.#e.substring(0,o)+n.substring(o,r)+this.#e.substring(o)}else h<a&&(this.#e=this.#e.substring(0,r)+this.#e.substring(r+(a-h)))}const l=this.#c(this.#e);this.#t.value=l,this.#t.setSelectionRange(r,r)}else this.#e=this.#t.value;this.detectInjection(this.#e,this.#t.name),this.#y(),this.#o(),this.dispatchEvent(new CustomEvent("secure-input",{detail:{name:this.#t.name,value:this.#e,masked:this.#n,tier:this.securityTier},bubbles:!0,composed:!0}))}#c(t){const e=this.config,i=e.masking.character;if(!e.masking.partial||this.securityTier===m.CRITICAL||t.length<=4)return i.repeat(t.length);const s=i.repeat(t.length-4),n=t.slice(-4);return s+n}#d(t){if(!this.#t||this.#t.type!=="password"||!t||t.length===0)return null;const e=this.securityTier;if(e==="critical"){if(t.length<8)return"Password must be at least 8 characters";if(!/[a-z]/.test(t))return"Password must include a lowercase letter";if(!/[A-Z]/.test(t))return"Password must include an uppercase letter";if(!/[0-9]/.test(t))return"Password must include a number";if(!/[^a-zA-Z0-9]/.test(t))return"Password must include a special character"}else if(e==="sensitive"){if(t.length<8)return"Password must be at least 8 characters";if(!/[a-z]/.test(t))return"Password must include a lowercase letter";if(!/[A-Z]/.test(t))return"Password must include an uppercase letter";if(!/[0-9]/.test(t))return"Password must include a number"}else if(e==="authenticated"&&t.length<6)return"Password must be at least 6 characters";return null}#m(t){if(!this.#t||this.#t.type!=="number"||!t||t.length===0)return null;const e=Number(t);if(!Number.isFinite(e))return"Value must be a valid number";if(!t.includes(".")&&!Number.isSafeInteger(e))return"Value exceeds safe integer range";const i=this.getAttribute("min"),s=this.getAttribute("max");if(i!==null){const n=Number(i);if(Number.isFinite(n)&&e<n)return`Value must be at least ${n}`}if(s!==null){const n=Number(s);if(Number.isFinite(n)&&e>n)return`Value must be at most ${n}`}return null}#v(){const t=this.checkRateLimit();if(!t.allowed){this.#l(`Too many attempts. Please wait ${Math.ceil(t.retryAfter/1e3)} seconds.`);return}const e=this.getAttribute("pattern"),i=this.getAttribute("minlength"),s=this.getAttribute("maxlength");let n=null;if(e)try{n=new RegExp(e)}catch{}const r=this.validateInput(this.#e,{required:this.hasAttribute("required")||this.config.validation.required,pattern:n,minLength:i?parseInt(i,10):0,maxLength:s?parseInt(s,10):this.config.validation.maxLength});if(!r.valid){this.#l(r.errors.join(", "));return}const l=this.#d(this.#e);if(l){this.#l(l);return}const a=this.#m(this.#e);if(a){this.#l(a);return}if(this.#t&&this.#e){let h,u;if(this.#n){const o=this.#t.value;this.#t.value=this.#e,h=this.#t.checkValidity(),u=this.#t.validationMessage,this.#t.value=o}else h=this.#t.checkValidity(),u=this.#t.validationMessage;if(!h){this.#l(u);return}}}#l(t){this.#s.textContent=t,this.#s.offsetHeight,this.#s.classList.remove("hidden"),this.#t.classList.add("error"),this.#t.setAttribute("aria-invalid","true")}#y(){this.#s.classList.add("hidden"),this.#s.addEventListener("transitionend",()=>{this.#s.classList.contains("hidden")&&(this.#s.textContent="")},{once:!0}),this.#t.classList.remove("error"),this.#t.removeAttribute("aria-invalid")}#u(t){this.#e=t,this.#n&&this.#t.type!=="password"?this.#t.value=this.#c(t):this.#t.value=t,this.#o()}#L(){return new URL("./secure-input.css",import.meta.url).href}handleAttributeChange(t,e,i){if(this.#t)switch(t){case"disabled":this.#t.disabled=this.hasAttribute("disabled");break;case"readonly":this.#t.readOnly=this.hasAttribute("readonly");break;case"value":i!==this.#e&&this.#u(i||"");break}}get value(){return this.#e}set value(t){this.#u(t||"")}get name(){return this.#t?this.#t.name:""}get valid(){const t=this.getAttribute("pattern"),e=this.getAttribute("minlength"),i=this.getAttribute("maxlength");let s=null;if(t)try{s=new RegExp(t)}catch{}if(!this.validateInput(this.#e,{required:this.hasAttribute("required")||this.config.validation.required,pattern:s,minLength:e?parseInt(e,10):0,maxLength:i?parseInt(i,10):this.config.validation.maxLength}).valid||this.#d(this.#e)!==null||this.#m(this.#e)!==null)return!1;if(this.#t&&this.#e){let r;if(this.#n){const l=this.#t.value;this.#t.value=this.#e,r=this.#t.checkValidity(),this.#t.value=l}else r=this.#t.checkValidity();if(!r)return!1}return!0}focus(){this.#t&&this.#t.focus()}blur(){this.#t&&this.#t.blur()}showThreatFeedback(t,e){if(!this.#i||!this.#t)return;this.#i.textContent="";const i=document.createElement("span");i.className="threat-message",i.textContent=this.getThreatLabel(t);const s=document.createElement("span");s.className="threat-badge",s.textContent=t;const n=document.createElement("span");n.className=`threat-tier threat-tier--${e}`,n.textContent=e,this.#i.appendChild(i),this.#i.appendChild(s),this.#i.appendChild(n),this.#i.offsetHeight,this.#i.classList.remove("hidden"),this.#t.classList.add("threat"),this.#t.setAttribute("aria-invalid","true")}clearThreatFeedback(){!this.#i||!this.#t||(this.#i.classList.add("hidden"),this.#i.addEventListener("transitionend",()=>{this.#i.classList.contains("hidden")&&(this.#i.textContent="")},{once:!0}),this.#t.classList.remove("threat"),this.#t.removeAttribute("aria-invalid"))}disconnectedCallback(){super.disconnectedCallback(),this.#e="",this.#t&&(this.#t.value="")}}customElements.define("secure-input",c);var f=c;export{c as SecureInput,f as default};