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.
- package/dist/components/secure-card/secure-card.js +1 -766
- package/dist/components/secure-datetime/secure-datetime.js +1 -570
- package/dist/components/secure-file-upload/secure-file-upload.js +1 -868
- package/dist/components/secure-form/secure-form.js +1 -797
- package/dist/components/secure-input/secure-input.js +1 -867
- package/dist/components/secure-password-confirm/secure-password-confirm.js +1 -329
- package/dist/components/secure-select/secure-select.js +1 -589
- package/dist/components/secure-submit-button/secure-submit-button.js +1 -378
- package/dist/components/secure-table/secure-table.js +33 -528
- package/dist/components/secure-telemetry-provider/secure-telemetry-provider.js +1 -201
- package/dist/components/secure-textarea/secure-textarea.js +1 -491
- package/dist/core/base-component.js +1 -500
- package/dist/core/security-config.js +1 -242
- package/dist/core/types.js +0 -2
- package/dist/index.js +1 -17
- package/dist/package.json +4 -2
- package/package.json +4 -2
|
@@ -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};
|