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:
+120
-83
@@ -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": ["<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-")
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user