From d57acb416b4674fc4a8c65a994804226ed333930 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Thu, 30 Apr 2026 17:47:10 +0000 Subject: [PATCH] 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. --- universal-runtime/manager.py | 203 +++++++++++++++++++++-------------- 1 file changed, 120 insertions(+), 83 deletions(-) diff --git a/universal-runtime/manager.py b/universal-runtime/manager.py index 219c4c1..18c2f5d 100644 --- a/universal-runtime/manager.py +++ b/universal-runtime/manager.py @@ -1,16 +1,12 @@ #!/usr/bin/env python3 -import hashlib import json import os import shutil import signal -import sqlite3 import subprocess import tempfile import threading -import time from http.server import BaseHTTPRequestHandler, HTTPServer -from urllib.parse import urlparse DISPLAY = os.environ.get("DISPLAY", ":1") CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080") @@ -25,95 +21,128 @@ _state = { "target": "", "resolution": CHROME_WINDOW_SIZE, "profile_dir": None, + "extension_dir": None, } _lock = threading.Lock() -def _chrome_encrypt_v10(plaintext: str) -> bytes: - try: - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - from cryptography.hazmat.backends import default_backend - except ImportError: - return plaintext.encode("utf-8") - key = hashlib.pbkdf2_hmac("sha1", b"peanuts", b"saltysalt", 1, dklen=16) - iv = b" " * 16 - data = plaintext.encode("utf-8") - pad = 16 - (len(data) % 16) - data += bytes([pad] * pad) - cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) - enc = cipher.encryptor() - return b"v10" + enc.update(data) + enc.finalize() +_AUTOFILL_CONTENT_JS = r""" +(function() { + const CREDS = __CREDS__; + let filled = false; + + function findUserField() { + const candidates = document.querySelectorAll( + 'input[type="email"], ' + + 'input[autocomplete*="username"], ' + + 'input[autocomplete*="email"], ' + + 'input[name*="user" i], ' + + 'input[name*="login" i], ' + + 'input[name*="email" i], ' + + '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": [""], + "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-") default_dir = os.path.join(profile_dir, "Default") os.makedirs(default_dir, exist_ok=True) - prefs = { "intl": {"accept_languages": "ru-RU,ru,en", "selected_languages": "ru-RU,ru"}, "translate": {"enabled": False}, "translate_blocked_languages": ["ru"], - "credentials_enable_service": True, - "credentials_enable_autosign_in": False, } with open(os.path.join(default_dir, "Preferences"), "w") as 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 @@ -131,10 +160,11 @@ def _stop_current() -> None: finally: _state["proc"] = None - profile_dir = _state.get("profile_dir") - if profile_dir and os.path.isdir(profile_dir): - shutil.rmtree(profile_dir, ignore_errors=True) - _state["profile_dir"] = None + for key in ("profile_dir", "extension_dir"): + path = _state.get(key) + if path and os.path.isdir(path): + shutil.rmtree(path, ignore_errors=True) + _state[key] = None def _start_process(cmd: list[str], mode: str, target: str) -> None: @@ -235,7 +265,9 @@ def open_web( password: str = "", ) -> None: 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 = [ "chromium", "--no-sandbox", @@ -257,10 +289,15 @@ def open_web( "--accept-lang=ru-RU,ru", "--password-store=basic", 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) _state["profile_dir"] = profile_dir + _state["extension_dir"] = extension_dir def open_rdp(payload: dict) -> None: