Replace Login Data injection with autofill via Chromium extension

The previous approach pre-populated Chromiums Login Data SQLite with
schema version 30 and AES-128-CBC v10 encrypted passwords. Chromium 147
expects schema version 43, fails to migrate (Unable to migrate database
from 30 to 43), and refuses to open Login Data altogether. Result: the
row was written but Chromium never read it, so autofill never worked.

Instead generate a tiny Manifest V3 extension per session in a temp dir
with a content_script that finds username and password fields, sets
their values, and dispatches input/change events. Pass it via
--load-extension and --disable-extensions-except so it is the only
extension loaded.

Benefits:
- Independent of Chromium version and Login Database schema
- Works on SPAs (MutationObserver re-runs on DOM changes)
- Credentials live only in a temp file alongside the profile, removed
  on session end via _stop_current
- No SQLite or cryptography dependency
- Removes the silent failure mode of Login Data migration

Removes _chrome_encrypt_v10, sqlite3, hashlib, urlparse imports.
Adds _create_autofill_extension and tracks extension_dir alongside
profile_dir in _state for cleanup symmetry.
This commit is contained in:
2026-04-30 17:47:10 +00:00
parent cf68bc848f
commit d57acb416b
+120 -83
View File
@@ -1,16 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import hashlib
import json import json
import os import os
import shutil import shutil
import signal import signal
import sqlite3
import subprocess import subprocess
import tempfile import tempfile
import threading import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse
DISPLAY = os.environ.get("DISPLAY", ":1") DISPLAY = os.environ.get("DISPLAY", ":1")
CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080") CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080")
@@ -25,95 +21,128 @@ _state = {
"target": "", "target": "",
"resolution": CHROME_WINDOW_SIZE, "resolution": CHROME_WINDOW_SIZE,
"profile_dir": None, "profile_dir": None,
"extension_dir": None,
} }
_lock = threading.Lock() _lock = threading.Lock()
def _chrome_encrypt_v10(plaintext: str) -> bytes: _AUTOFILL_CONTENT_JS = r"""
try: (function() {
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes const CREDS = __CREDS__;
from cryptography.hazmat.backends import default_backend let filled = false;
except ImportError:
return plaintext.encode("utf-8") function findUserField() {
key = hashlib.pbkdf2_hmac("sha1", b"peanuts", b"saltysalt", 1, dklen=16) const candidates = document.querySelectorAll(
iv = b" " * 16 'input[type="email"], ' +
data = plaintext.encode("utf-8") 'input[autocomplete*="username"], ' +
pad = 16 - (len(data) % 16) 'input[autocomplete*="email"], ' +
data += bytes([pad] * pad) 'input[name*="user" i], ' +
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) 'input[name*="login" i], ' +
enc = cipher.encryptor() 'input[name*="email" i], ' +
return b"v10" + enc.update(data) + enc.finalize() 'input[id*="user" i], ' +
'input[id*="login" i], ' +
'input[id*="email" i], ' +
'input[type="text"]'
);
for (const el of candidates) {
if (el.type === 'password' || el.type === 'hidden') continue;
if (el.offsetParent === null && el.type !== 'email') continue;
return el;
}
return null;
}
function findPassField() {
const list = document.querySelectorAll('input[type="password"]');
for (const el of list) {
if (el.offsetParent !== null) return el;
}
return list[0] || null;
}
function setNativeValue(el, v) {
if (!el) return false;
if (el.value === v) return true;
const proto = Object.getPrototypeOf(el);
const desc = Object.getOwnPropertyDescriptor(proto, 'value') ||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
if (desc && desc.set) desc.set.call(el, v); else el.value = v;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
function tryFill() {
if (filled) return;
const p = findPassField();
if (!p) return;
const u = findUserField();
let did = false;
if (CREDS.login && u) did = setNativeValue(u, CREDS.login) || did;
if (CREDS.password) did = setNativeValue(p, CREDS.password) || did;
if (did) filled = true;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryFill);
} else {
tryFill();
}
const obs = new MutationObserver(() => { if (!filled) tryFill(); });
if (document.documentElement) {
obs.observe(document.documentElement, { childList: true, subtree: true });
}
const resetAndRefill = () => { filled = false; setTimeout(tryFill, 150); };
['pushState', 'replaceState'].forEach(fn => {
const orig = history[fn];
history[fn] = function() { const r = orig.apply(this, arguments); resetAndRefill(); return r; };
});
window.addEventListener('popstate', resetAndRefill);
})();
"""
_AUTOFILL_MANIFEST = {
"manifest_version": 3,
"name": "Portal Autofill",
"version": "1.0",
"description": "Auto-fill credentials for portal session",
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": True,
}
],
}
def _create_chrome_profile(login: str, password: str, url: str) -> str: def _create_autofill_extension(login: str, password: str) -> str | None:
if not login and not password:
return None
ext_dir = tempfile.mkdtemp(prefix="chrome-autofill-ext-")
creds_json = json.dumps({"login": login, "password": password})
content_js = _AUTOFILL_CONTENT_JS.replace("__CREDS__", creds_json)
with open(os.path.join(ext_dir, "manifest.json"), "w") as f:
json.dump(_AUTOFILL_MANIFEST, f)
with open(os.path.join(ext_dir, "content.js"), "w") as f:
f.write(content_js)
return ext_dir
def _create_chrome_profile() -> str:
profile_dir = tempfile.mkdtemp(prefix="chrome-profile-") profile_dir = tempfile.mkdtemp(prefix="chrome-profile-")
default_dir = os.path.join(profile_dir, "Default") default_dir = os.path.join(profile_dir, "Default")
os.makedirs(default_dir, exist_ok=True) os.makedirs(default_dir, exist_ok=True)
prefs = { prefs = {
"intl": {"accept_languages": "ru-RU,ru,en", "selected_languages": "ru-RU,ru"}, "intl": {"accept_languages": "ru-RU,ru,en", "selected_languages": "ru-RU,ru"},
"translate": {"enabled": False}, "translate": {"enabled": False},
"translate_blocked_languages": ["ru"], "translate_blocked_languages": ["ru"],
"credentials_enable_service": True,
"credentials_enable_autosign_in": False,
} }
with open(os.path.join(default_dir, "Preferences"), "w") as f: with open(os.path.join(default_dir, "Preferences"), "w") as f:
json.dump(prefs, f) json.dump(prefs, f)
if not login and not password:
return profile_dir
parsed = urlparse(url)
origin = f"{parsed.scheme}://{parsed.netloc}/"
db_path = os.path.join(default_dir, "Login Data")
conn = sqlite3.connect(db_path)
conn.execute(
"CREATE TABLE IF NOT EXISTS meta "
"(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR)"
)
conn.execute("INSERT OR REPLACE INTO meta VALUES ('version', '30')")
conn.execute("INSERT OR REPLACE INTO meta VALUES ('last_compatible_version', '30')")
conn.execute("""
CREATE TABLE IF NOT EXISTS logins (
origin_url VARCHAR NOT NULL,
action_url VARCHAR,
username_element VARCHAR,
username_value VARCHAR,
password_element VARCHAR,
password_value BLOB,
submit_element VARCHAR,
signon_realm VARCHAR NOT NULL,
date_created INTEGER NOT NULL,
blacklisted_by_user INTEGER NOT NULL,
scheme INTEGER NOT NULL,
password_type INTEGER DEFAULT 0,
times_used INTEGER DEFAULT 0,
form_data BLOB DEFAULT '',
display_name VARCHAR DEFAULT '',
icon_url VARCHAR DEFAULT '',
federation_url VARCHAR DEFAULT '',
skip_zero_click INTEGER DEFAULT 0,
generation_upload_status INTEGER DEFAULT 0,
possible_username_pairs BLOB DEFAULT '',
id INTEGER PRIMARY KEY AUTOINCREMENT,
date_last_used INTEGER DEFAULT 0,
moving_blocked_for BLOB DEFAULT '',
date_password_modified INTEGER DEFAULT 0
)""")
enc_password = _chrome_encrypt_v10(password) if password else b""
now = int(time.time() * 1_000_000)
conn.execute(
"INSERT INTO logins "
"(origin_url, action_url, username_element, username_value, "
"password_element, password_value, submit_element, signon_realm, "
"date_created, blacklisted_by_user, scheme, times_used, date_last_used) "
"VALUES (?,?,?,?,?,?,?,?,?,0,0,1,?)",
(origin, origin, "", login, "", enc_password, "", origin, now, now),
)
conn.commit()
conn.close()
return profile_dir return profile_dir
@@ -131,10 +160,11 @@ def _stop_current() -> None:
finally: finally:
_state["proc"] = None _state["proc"] = None
profile_dir = _state.get("profile_dir") for key in ("profile_dir", "extension_dir"):
if profile_dir and os.path.isdir(profile_dir): path = _state.get(key)
shutil.rmtree(profile_dir, ignore_errors=True) if path and os.path.isdir(path):
_state["profile_dir"] = None shutil.rmtree(path, ignore_errors=True)
_state[key] = None
def _start_process(cmd: list[str], mode: str, target: str) -> None: def _start_process(cmd: list[str], mode: str, target: str) -> None:
@@ -235,7 +265,9 @@ def open_web(
password: str = "", password: str = "",
) -> None: ) -> None:
safe_w, safe_h = apply_resolution(width, height) safe_w, safe_h = apply_resolution(width, height)
profile_dir = _create_chrome_profile(login, password, url) profile_dir = _create_chrome_profile()
extension_dir = _create_autofill_extension(login, password)
cmd = [ cmd = [
"chromium", "chromium",
"--no-sandbox", "--no-sandbox",
@@ -257,10 +289,15 @@ def open_web(
"--accept-lang=ru-RU,ru", "--accept-lang=ru-RU,ru",
"--password-store=basic", "--password-store=basic",
f"--user-data-dir={profile_dir}", f"--user-data-dir={profile_dir}",
url,
] ]
if extension_dir:
cmd.append(f"--load-extension={extension_dir}")
cmd.append(f"--disable-extensions-except={extension_dir}")
cmd.append(url)
_start_process(cmd, "web", url) _start_process(cmd, "web", url)
_state["profile_dir"] = profile_dir _state["profile_dir"] = profile_dir
_state["extension_dir"] = extension_dir
def open_rdp(payload: dict) -> None: def open_rdp(payload: dict) -> None: