Fix TOTP fill missing digits on some sites (fixes #2215)

Fixes Authelia and Epic Games OTP fill by addressing
two underlying problems:

1. Incorrect DOM event ordering and data
- So far only empty key events were sent, which may confuse OTP input
  implementations that rely on the text from those events instead of
  input field value.
- The "beforeinput" event was missing altogether.
- Reorder the events to comply to the UIEvents spec
  (https://w3c.github.io/uievents/#keypress-event-order)
  and change field.value at the expected moment in that order.

2. Focus events were not sent
As described in
https://github.com/keepassxreboot/keepassxc-browser/issues/2215#issuecomment-3629070227,
the extension focuses fields sequentially when filling in the OTP digits, but
this does not trigger focus/blur events as this is performed within the same
window event loop cycle, throwing off certain implementations of
multi-textfield OTP input; in the cause of Authelia filling an internal digit
buffer starting from nonexistent index -1.

Sending those events manually was attempted but did not yield satisfactory
results as some frameworks like React use Promise objects to schedule
their state update, resulting in the problem still being present.

Instead, makes kpxcFill.setValueWithChange async so it can await on an empty
Promise of its own, giving time for the user agent to trigger the
appropriate focus events as well as for pages' scripts to settle.
This commit is contained in:
Céleste Wouters 2025-12-09 16:40:23 +01:00
parent fdbd077852
commit d3cc7ee925
No known key found for this signature in database
GPG key ID: 6E9DA8063322434B

View file

@ -18,7 +18,7 @@ kpxcFill.fillAttributeToActiveElementWith = async function(attr) {
return;
}
kpxcFill.setValue(el, value[0]);
await kpxcFill.setValue(el, value[0]);
};
// Fill requested from the context menu. Active element is used for combination detection
@ -152,20 +152,20 @@ kpxcFill.fillTOTPFromUuid = async function(el, uuid) {
return;
}
kpxcFill.setTOTPValue(el, totp);
await kpxcFill.setTOTPValue(el, totp);
} else if (user.stringFields?.length > 0) {
const stringFields = user.stringFields;
for (const s of stringFields) {
const val = s['KPH: {TOTP}'];
if (val) {
kpxcFill.setTOTPValue(el, val);
await kpxcFill.setTOTPValue(el, val);
}
}
}
};
// Set normal or segmented TOTP value
kpxcFill.setTOTPValue = function(elem, val) {
kpxcFill.setTOTPValue = async function(elem, val) {
if (kpxc.credentials.length === 0) {
logDebug('Error: Credential list is empty.');
return;
@ -173,22 +173,22 @@ kpxcFill.setTOTPValue = function(elem, val) {
for (const comb of kpxc.combinations) {
if (comb.totpInputs?.length > 0) {
kpxcFill.fillSegmentedTotp(elem, val, comb.totpInputs);
await kpxcFill.fillSegmentedTotp(elem, val, comb.totpInputs);
return;
}
}
kpxcFill.setValue(elem, val);
await kpxcFill.setValue(elem, val);
};
// Fill TOTP in parts
kpxcFill.fillSegmentedTotp = function(elem, val, totpInputs) {
kpxcFill.fillSegmentedTotp = async function(elem, val, totpInputs) {
if (!totpInputs.includes(elem) || val.length < totpInputs.length) {
return;
}
for (let i = 0; i < totpInputs.length; ++i) {
kpxcFill.setValue(totpInputs[i], val[i]);
await kpxcFill.setValue(totpInputs[i], val[i]);
}
};
@ -271,7 +271,7 @@ kpxcFill.fillInCredentials = async function(combination, predefinedUsername, uui
return;
}
kpxcFill.setValueWithChange(combination.password, selectedCredentials.password);
await kpxcFill.setValueWithChange(combination.password, selectedCredentials.password);
await kpxc.setPasswordFilled(true);
}
@ -279,20 +279,20 @@ kpxcFill.fillInCredentials = async function(combination, predefinedUsername, uui
if (combination.username && usernameValue && combination.username !== combination.password &&
(!combination.username.value || combination.username.value !== usernameValue)) {
if (!passOnly) {
kpxcFill.setValueWithChange(combination.username, usernameValue);
await kpxcFill.setValueWithChange(combination.username, usernameValue);
}
}
// Fill StringFields
if (selectedCredentials.stringFields?.length > 0) {
kpxcFill.fillInStringFields(combination.fields, selectedCredentials.stringFields);
await kpxcFill.fillInStringFields(combination.fields, selectedCredentials.stringFields);
}
// Fill TOTP
if (kpxc.settings.autoFillSingleTotp && kpxc.entryHasTotp(selectedCredentials)) {
const totpCombination = combination?.totp || kpxc.combinations?.find(c => c.totp);
if (totpCombination?.totp) {
kpxcFill.fillTOTPFromUuid(totpCombination.totp, selectedCredentials.uuid);
await kpxcFill.fillTOTPFromUuid(totpCombination.totp, selectedCredentials.uuid);
}
}
@ -306,7 +306,7 @@ kpxcFill.fillInCredentials = async function(combination, predefinedUsername, uui
};
// Fills StringFields defined in Custom Fields
kpxcFill.fillInStringFields = function(fields, stringFields) {
kpxcFill.fillInStringFields = async function(fields, stringFields) {
const filledInFields = [];
if (fields && stringFields && fields?.length > 0 && stringFields?.length > 0) {
for (let i = 0; i < fields.length; i++) {
@ -318,7 +318,7 @@ kpxcFill.fillInStringFields = function(fields, stringFields) {
const currentField = fields[i];
if (currentField && stringFieldValue[0]) {
kpxcFill.setValue(currentField, stringFieldValue[0], true);
await kpxcFill.setValue(currentField, stringFieldValue[0], true);
filledInFields.push(currentField);
}
}
@ -353,14 +353,14 @@ kpxcFill.performAutoSubmit = async function(combination, skipAutoSubmit) {
};
// Special handling for setting value to select and checkbox elements
kpxcFill.setValue = function(field, value, forced = false) {
kpxcFill.setValue = async function(field, value, forced = false) {
if (field.matches('select')) {
value = value.toLowerCase().trim();
const options = field.querySelectorAll('option');
for (const o of options) {
if (o.textContent.toLowerCase().trim() === value) {
kpxcFill.setValueWithChange(field, o.value);
await kpxcFill.setValueWithChange(field, o.value);
return false;
}
}
@ -378,11 +378,11 @@ kpxcFill.setValue = function(field, value, forced = false) {
}
kpxcFill.setValueWithChange(field, value, forced);
await kpxcFill.setValueWithChange(field, value, forced);
};
// Sets a new value to input field and triggers necessary events
kpxcFill.setValueWithChange = function(field, value, forced = false) {
kpxcFill.setValueWithChange = async function(field, value, forced = false) {
if (!field || (field?.readOnly && !forced)) {
return;
}
@ -401,12 +401,23 @@ kpxcFill.setValueWithChange = function(field, value, forced = false) {
};
field.focus();
field.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: false }));
field.dispatchEvent(new KeyboardEvent('keypress', { bubbles: true, cancelable: false }));
field.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: false }));
field.dispatchEvent(new Event('input', { bubbles: true, cancelable: false }));
field.dispatchEvent(new Event('change', { bubbles: true, cancelable: false }));
// Use a delay to allow focus events to trigger and to give some
// breathing room to frameworks that rely on promises to update their
// state, like React. Not doing so can break OTP input, see issue #2215.
await Promise.resolve();
field.dispatchEvent(new FocusEvent('focus', { bubbles: false, cancelable: false }));
field.dispatchEvent(new FocusEvent('focusin', { bubbles: true, cancelable: false }));
// https://w3c.github.io/uievents/#keypress-event-order
field.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: false, key: value }));
field.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: false, inputType: 'insertText', data: value }));
field.dispatchEvent(new KeyboardEvent('keypress', { bubbles: true, cancelable: false, key: value }));
field.value = value;
field.dispatchEvent(new Event('input', { bubbles: true, cancelable: false }));
field.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: false, key: value }));
field.dispatchEvent(new Event('change', { bubbles: true, cancelable: false }));
// Some pages will not accept the value change without dispatching events directly to the document
dispatchLegacyEvent(field, 'input');