DEV Community

Cover image for Your voice typing app will type your password into a login box. Here is how to stop it
Ahmed Ibrahim
Ahmed Ibrahim

Posted on AI-assisted

Your voice typing app will type your password into a login box. Here is how to stop it

A dictation app has a simple job: you hold a key, say a sentence, and the text is typed into whatever window has focus. That is also its worst failure mode. Click into a login page, hold the key out of habit, say a few words, and they land in the password box. You cannot see them, a wrong word is invisible, and a copy of what you said has just been saved to the app's history.

VoxScribe, a free and open source dictation app for Windows and Android, has to make sure that never happens. Here is how the Windows side works, including the one bug that made the check look broken for browsers.

The rules were strict:

  • It must never raise an exception.
  • It must never make the hotkey wait.
  • It must not add a dependency to the installer.

Check 1: the classic Win32 password style

Old desktop apps and many Windows dialogs use a plain edit control with the ES_PASSWORD style. That check is cheap and instant, using only user32:

_ES_PASSWORD = 0x0020
_GWL_STYLE = -16

def _win32_focus_is_password() -> bool:
    user32 = ctypes.windll.user32
    info = _GUITHREADINFO(cbSize=ctypes.sizeof(_GUITHREADINFO))
    if not user32.GetGUIThreadInfo(0, ctypes.byref(info)) or not info.hwndFocus:
        return False
    class_name = ctypes.create_unicode_buffer(64)
    user32.GetClassNameW(info.hwndFocus, class_name, 64)
    if "edit" not in class_name.value.lower():
        return False
    style = user32.GetWindowLongW(info.hwndFocus, _GWL_STYLE)
    return bool(style & _ES_PASSWORD)
Enter fullscreen mode Exit fullscreen mode

This is where most tutorials stop, and it is why most implementations are wrong. A browser does not use Windows edit controls. Chrome, Edge and Electron draw their own text boxes, so GetClassNameW returns something like Chrome_RenderWidgetHostHWND and the style check sees nothing.

Check 2: UI Automation knows

UI Automation exposes an IsPassword property on the focused element, and browsers set it for <input type="password">. The question is how to ask it from Python without bundling a COM library. The answer is raw vtable calls with ctypes. Two methods are all you need:

# IUIAutomation: IUnknown (0-2), CompareElements, CompareRuntimeIds,
# GetRootElement, ElementFromHandle, ElementFromPoint, GetFocusedElement (8).
_IUIAUTOMATION_GET_FOCUSED_ELEMENT = 8
# IUIAutomationElement: ..., GetCurrentPropertyValue (10).
_ELEMENT_GET_CURRENT_PROPERTY_VALUE = 10
_UIA_IS_PASSWORD_PROPERTY_ID = 30019

def _com_method(obj, index, *argtypes):
    vtable = ctypes.cast(obj, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p))).contents
    prototype = ctypes.WINFUNCTYPE(ctypes.HRESULT, ctypes.c_void_p, *argtypes)
    return prototype(vtable[index])
Enter fullscreen mode Exit fullscreen mode

One trap in the result: IsPassword comes back in a VARIANT, and a VARIANT_BOOL uses -1 (0xFFFF) for true, not 1. Compare against zero after masking, or every password field reads as false:

is_password = value.vt == _VT_BOOL and (value.value & 0xFFFF) != 0
Enter fullscreen mode Exit fullscreen mode

The bug that looked like a failed check

In Edge, the check said "not a password field" on a login page, even though the password box was right there.

The cause: Chromium builds its accessibility tree lazily. The very first time a UI Automation client asks, the focused element is just the bare window Pane, not the text box. The second question, a moment later, returns the real element. That first answer looked exactly like "this is not a password field".

The fix is small: if the first answer is not a password but the element is a bare pane, wait 150 ms and ask once more.

is_password, is_pane = cls._ask_focused(automation)
if not is_password and is_pane:
    time.sleep(_CHROMIUM_WAKE_SEC)   # 0.15
    is_password, _ = cls._ask_focused(automation)
Enter fullscreen mode Exit fullscreen mode

Later questions are answered immediately, so the retry only costs time once.

Making sure it never blocks

UI Automation calls can stall for seconds when the target app is hung. A dictation hotkey cannot wait on that. Three decisions keep it safe:

  1. Its own thread with its own COM apartment. Every question goes to a worker thread and waits at most 0.5 seconds.
  2. A busy flag. If the worker is still stuck on an earlier question, a new one returns immediately instead of queueing behind it.
  3. Skip our own window. Asking UI Automation about the app's own window from inside the app waits on the app's own busy UI thread. So if the foreground window belongs to this process, the check is skipped.

What happens when it says "password"

The first check runs as soon as the text comes back from the speech model, before anything is shown or saved. If it reports a password field, the text is not typed, and it is not written to the transcript or the history either. A spoken password never reaches the disk. A second check runs just before typing, after the target window is refocused, and it stops the typing if focus moved into a password box in the meantime. At that point the text is already in the history, so that second check protects the box but not the log.

The trade-off I would like your opinion on

A timeout counts as "not a password field". That is fail-open: a slow or hung app loses this protection for that moment, and dictation still works. The alternative is fail-closed: if the check cannot answer in time, refuse to type. That is safer for passwords and much worse for everyone dictating into a slow app.

I chose fail-open because a dictation tool that randomly refuses to type gets uninstalled, and the check answers in time in nearly every normal case. But I am not sure it is right. Would you fail closed?

What it does not cover

It trusts what apps report. A custom-drawn control that never sets IsPassword, and anything inside a remote desktop or virtual machine window, looks like an ordinary field from the outside. On Android the equivalent is the accessibility service reporting that a node is a password field, and the floating button simply hides there.

The code is in core/password_field.py if you want to read it in full or send a pull request, especially from an app where it gets the answer wrong. This article and the code were written with Claude Code as an assistant, and the Edge behavior was found and fixed by testing in the browser itself.

Top comments (2)

Collapse
 
starlightxbaby profile image
Lolita Starlight •

fail-open is probably fine for most apps, but the lazy chromium tree makes me wonder if you could split it: fail closed only when the focused element is still a bare pane or the timeout hits inside a browser/electron window, and fail open elsewhere. that's exactly where a slow first answer is most likely to hide a real password box. do you log how often the 150ms retry actually fires?

Collapse
 
ahmed_ibrahim_1994 profile image
Ahmed Ibrahim •

Splitting it by context makes sense. A bare pane that is still a bare pane after the retry, inside a browser or Electron window, is the case where failing closed costs the least and protects the most. And no, I don’t log how often the retry fires, so I can’t give you a number. That is the first thing I’d add, because it would show whether 150 ms is the right wait.