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
|
#!/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:
|
||||||
|
|||||||
Reference in New Issue
Block a user