c8998c3957
payment and address items could be stored (CLI wizard, Desktop dialog) but nothing could fill them: a dead surface holding real card numbers. browser_vault_fill now handles all three kinds through the same origin-bound, supervisor-only, redacted path: - classify_checkout_control / select_checkout_fills map WHATWG autocomplete tokens (cc-number, cc-exp[-month|-year], cc-csc, address-line1/2, address-level1/2, postal-code, country-name) with label/name heuristics as backup; a combined "MM/YY" control gets exp_month+exp_year and suppresses the split fills; inspection now covers <select> (country, state, expiry month) and the fill script picks an option by value or text. - Every payment fill goes through request_elicitation_consent (gateway button round-trip or CLI panel) before a byte is written; declined → payment_declined, headless sessions are refused. A prompt injection that reaches a checkout can ask, not spend. Card values join the redaction registry like passwords; the result lists targeted field tokens only. - Origin is now required for every kind (CLI wizard asks; Desktop dialog always shows the field) because a card without a bound origin is unfillable. - The tool descriptions, docs and CLI copy drop "Phase 1 / login only". Live (evals/vault_fill_live_e2e.py, real browser_exec + packaged Chromium): decline writes nothing; accept fills card/expiry/CVC on the /checkout tab, leaves the email box and the country <select> untouched, and neither the card number nor the CVC appears in any result. browser_vault_tool also: focuses the tab on the bound origin holding the right form before the origin pre-check (focus_page from the previous commit); tool descriptions say "the browser's input tool" (rewritten per session by model_tools); _check_vault_available is registered uncached because its answer is per profile (vault dir + config) and the probe is a file stat.
289 lines
13 KiB
Python
289 lines
13 KiB
Python
"""Login / checkout form control classifier for vault autofill.
|
|
|
|
Python port (~170 LOC) of Merit-Systems/OpenInstinct's
|
|
``lib/manager/server/kernel-login-autofill.ts`` (MIT). Classifies visible
|
|
input controls on a page into login-autofill tokens. The vault fill path
|
|
uses the classification to select the single best current-password control
|
|
(the identifier is agent-visible metadata and is typed by the agent
|
|
itself via normal input tools).
|
|
|
|
Scoring:
|
|
- exact autocomplete-token match ................ 100
|
|
- type=password (not new/confirm/create/repeat) .. 90
|
|
- type=email / type=tel .......................... 85
|
|
- label/name regex heuristics .................. 70-75
|
|
Hard exclusions: autocomplete ``new-password`` / ``one-time-code``, and
|
|
label/name text matching ``(new|confirm|create|repeat)\\s*password``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import unicodedata
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
LOGIN_AUTOFILL_TOKENS = ("username", "email", "tel", "current-password")
|
|
|
|
# Payment / address autocomplete tokens (WHATWG) the checkout fill targets. ``cc-exp`` (combined
|
|
# MM/YY) is derived at fill time from exp_month + exp_year. Field-name/label heuristics below back
|
|
# up sites that omit autocomplete attributes.
|
|
PAYMENT_AUTOFILL_TOKENS = ("cc-number", "cc-name", "cc-exp", "cc-exp-month", "cc-exp-year", "cc-csc")
|
|
ADDRESS_AUTOFILL_TOKENS = ("address-line1", "address-line2", "address-level2", "address-level1",
|
|
"postal-code", "country-name", "country")
|
|
_CHECKOUT_HEURISTICS = (
|
|
(re.compile(r"\b(?:card\s*number|cardnumber|ccnumber|cc\s*num|pan)\b"), "cc-number"),
|
|
(re.compile(r"\b(?:name\s*on\s*card|cardholder|cc\s*name|ccname)\b"), "cc-name"),
|
|
(re.compile(r"\b(?:cvc|cvv|csc|security\s*code|card\s*code)\b"), "cc-csc"),
|
|
(re.compile(r"\b(?:exp(?:iry|iration)?\s*month|exp\s*mm|ccmonth)\b"), "cc-exp-month"),
|
|
(re.compile(r"\b(?:exp(?:iry|iration)?\s*year|exp\s*yy(?:yy)?|ccyear)\b"), "cc-exp-year"),
|
|
(re.compile(r"\b(?:exp(?:iry|iration)?(?:\s*date)?|mm\s*yy|valid\s*thru)\b"), "cc-exp"),
|
|
(re.compile(r"\b(?:address\s*(?:line\s*)?2|apt|suite|unit)\b"), "address-line2"),
|
|
(re.compile(r"\b(?:address(?:\s*line\s*1)?|street)\b"), "address-line1"),
|
|
(re.compile(r"\b(?:city|town|locality)\b"), "address-level2"),
|
|
(re.compile(r"\b(?:state|province|region|county)\b"), "address-level1"),
|
|
(re.compile(r"\b(?:zip|postal|postcode)\b"), "postal-code"),
|
|
(re.compile(r"\b(?:country)\b"), "country-name"),
|
|
)
|
|
|
|
_EXCLUDED_AUTOCOMPLETE = {"new-password", "one-time-code"}
|
|
|
|
_RE_EXCLUDED_PASSWORD = re.compile(r"\b(?:new|confirm|create|repeat)\s*password\b")
|
|
_RE_EMAIL = re.compile(r"\b(?:e[\s-]?mail|email address)\b")
|
|
_RE_TEL = re.compile(r"\b(?:phone|telephone|mobile)\b")
|
|
_RE_USERNAME = re.compile(
|
|
r"\b(?:user\s*name|username|login|account|member|membership|mileageplus)\b"
|
|
)
|
|
|
|
|
|
def _normalize_text(value: str) -> str:
|
|
value = unicodedata.normalize("NFKD", value).lower()
|
|
return re.sub(r"[^a-z0-9]+", " ", value).strip()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LoginControl:
|
|
"""Descriptor of a visible input control, as inspected in the page."""
|
|
|
|
autocomplete: str
|
|
form_index: Optional[int]
|
|
index: int
|
|
label: str
|
|
name: str
|
|
type: str
|
|
|
|
@classmethod
|
|
def from_dict(cls, raw: Dict[str, Any]) -> "LoginControl":
|
|
form_index = raw.get("formIndex", raw.get("form_index"))
|
|
return cls(
|
|
autocomplete=str(raw.get("autocomplete") or ""),
|
|
form_index=int(form_index) if form_index is not None else None,
|
|
index=int(raw.get("index") or 0),
|
|
label=str(raw.get("label") or ""),
|
|
name=str(raw.get("name") or ""),
|
|
type=str(raw.get("type") or ""),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ClassifiedLoginControl:
|
|
control: LoginControl
|
|
score: int
|
|
token: str
|
|
|
|
|
|
def classify_login_control(control: LoginControl) -> Optional[ClassifiedLoginControl]:
|
|
"""Classify one control, or return None if it is not a login fill target."""
|
|
autocomplete_tokens = [
|
|
t for t in control.autocomplete.lower().split() if t
|
|
]
|
|
if any(t in _EXCLUDED_AUTOCOMPLETE for t in autocomplete_tokens):
|
|
return None
|
|
|
|
for token in LOGIN_AUTOFILL_TOKENS:
|
|
if token in autocomplete_tokens:
|
|
return ClassifiedLoginControl(control, 100, token)
|
|
|
|
searchable = _normalize_text(
|
|
" ".join(part for part in (control.name, control.label) if part)
|
|
)
|
|
if _RE_EXCLUDED_PASSWORD.search(searchable):
|
|
return None
|
|
if control.type == "password":
|
|
return ClassifiedLoginControl(control, 90, "current-password")
|
|
if control.type == "email":
|
|
return ClassifiedLoginControl(control, 85, "email")
|
|
if control.type == "tel":
|
|
return ClassifiedLoginControl(control, 85, "tel")
|
|
if _RE_EMAIL.search(searchable):
|
|
return ClassifiedLoginControl(control, 75, "email")
|
|
if _RE_TEL.search(searchable):
|
|
return ClassifiedLoginControl(control, 75, "tel")
|
|
if _RE_USERNAME.search(searchable):
|
|
return ClassifiedLoginControl(control, 70, "username")
|
|
return None
|
|
|
|
|
|
def select_password_fill(
|
|
classified: List[ClassifiedLoginControl],
|
|
password: str,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Select the single best current-password control to fill.
|
|
|
|
The vault fill path is password-only: the identifier is agent-visible
|
|
metadata and is typed by the agent via normal input tools. This picks
|
|
the highest-scoring ``current-password`` control (ties broken by DOM
|
|
order) and returns ``[{"index": int, "token": "current-password",
|
|
"value": password}]`` or ``[]`` when no password field exists.
|
|
"""
|
|
passwords = [c for c in classified if c.token == "current-password"]
|
|
if not passwords or not password:
|
|
return []
|
|
best_password = sorted(
|
|
passwords, key=lambda c: (-c.score, c.control.index)
|
|
)[0]
|
|
return [
|
|
{
|
|
"index": best_password.control.index,
|
|
"token": "current-password",
|
|
"value": password,
|
|
}
|
|
]
|
|
|
|
|
|
def classify_checkout_control(control: LoginControl) -> Optional[ClassifiedLoginControl]:
|
|
"""Classify one control as a payment/address fill target (autocomplete token exact match 100,
|
|
label/name heuristic 70), or None. Password/email inputs are never checkout targets."""
|
|
tokens = [t for t in control.autocomplete.lower().split() if t]
|
|
for token in PAYMENT_AUTOFILL_TOKENS + ADDRESS_AUTOFILL_TOKENS:
|
|
if token in tokens:
|
|
return ClassifiedLoginControl(control, 100, "country-name" if token == "country" else token)
|
|
if control.type in ("password", "email"):
|
|
return None
|
|
searchable = _normalize_text(" ".join(part for part in (control.name, control.label) if part))
|
|
for pattern, token in _CHECKOUT_HEURISTICS:
|
|
if pattern.search(searchable):
|
|
return ClassifiedLoginControl(control, 70, token)
|
|
return None
|
|
|
|
|
|
def select_checkout_fills(classified: List[ClassifiedLoginControl], secret: Dict[str, str],
|
|
field_tokens: Dict[str, str]) -> List[Dict[str, Any]]:
|
|
"""Map a payment/address secret payload onto the best control per autocomplete token.
|
|
|
|
``field_tokens`` is ``PAYMENT_FIELDS`` / ``ADDRESS_FIELDS`` (agent/vault_store.py). A combined
|
|
``cc-exp`` control gets ``MM/YY`` from exp_month + exp_year and then suppresses the separate
|
|
month/year fills. Returns ``[{"index", "token", "value"}]``: one control per token, highest
|
|
score then DOM order.
|
|
"""
|
|
values: Dict[str, str] = {tok: secret[f] for f, tok in field_tokens.items() if secret.get(f)}
|
|
if "cc-exp-month" in values and "cc-exp-year" in values:
|
|
values["cc-exp"] = f"{values['cc-exp-month'].zfill(2)}/{values['cc-exp-year'][-2:]}"
|
|
fills: List[Dict[str, Any]] = []
|
|
for token, value in values.items():
|
|
candidates = sorted((c for c in classified if c.token == token), key=lambda c: (-c.score, c.control.index))
|
|
if candidates:
|
|
fills.append({"index": candidates[0].control.index, "token": token, "value": value})
|
|
if any(f["token"] == "cc-exp" for f in fills):
|
|
fills = [f for f in fills if f["token"] not in ("cc-exp-month", "cc-exp-year")]
|
|
return fills
|
|
|
|
|
|
# JS expression evaluated in the page to inspect candidate input controls.
|
|
# Ported from OpenInstinct's nativeLoginControlInspectionExpression.
|
|
# Inspection stamps every input with ``<nonce>:<index>`` under a per-inspection attribute; the fill
|
|
# script resolves targets by the stamp of ITS OWN inspection instead of re-querying by position, so
|
|
# neither a DOM reflow nor a second inspection in between can redirect the password into another field.
|
|
INSPECTION_STAMP_ATTR = "data-hermes-vault-slot"
|
|
|
|
|
|
def build_inspection_js(nonce: str) -> str:
|
|
return _LOGIN_CONTROL_INSPECTION_JS_TEMPLATE.replace("__NONCE__", json.dumps(nonce))
|
|
|
|
|
|
_LOGIN_CONTROL_INSPECTION_JS_TEMPLATE = """(() => {
|
|
const nonce = __NONCE__;
|
|
const elements = Array.from(document.querySelectorAll("input, select"));
|
|
const forms = Array.from(document.forms);
|
|
elements.forEach((element, index) => element.setAttribute("data-hermes-vault-slot", nonce + ":" + index));
|
|
const out = elements.flatMap((element, index) => {
|
|
if (element.disabled || element.readOnly) return [];
|
|
if (["hidden", "submit", "button", "reset", "file", "image", "checkbox", "radio"].includes(element.type)) return [];
|
|
const style = getComputedStyle(element);
|
|
if (style.display === "none" || style.visibility === "hidden" || element.getClientRects().length === 0) return [];
|
|
const labels = element.labels ? Array.from(element.labels, (l) => l.textContent || "") : [];
|
|
const ariaText = (element.getAttribute("aria-labelledby") || "")
|
|
.split(/\\s+/).filter(Boolean)
|
|
.map((id) => { const n = document.getElementById(id); return n ? (n.textContent || "") : ""; })
|
|
.join(" ");
|
|
const resolvedFormIndex = element.form ? forms.indexOf(element.form) : -1;
|
|
return [{
|
|
autocomplete: element.autocomplete || "",
|
|
formIndex: resolvedFormIndex >= 0 ? resolvedFormIndex : null,
|
|
index,
|
|
label: [
|
|
...labels,
|
|
element.getAttribute("aria-label") || "",
|
|
ariaText,
|
|
element.getAttribute("placeholder") || "",
|
|
element.getAttribute("title") || "",
|
|
].join(" "),
|
|
name: [element.name, element.id].join(" "),
|
|
type: element.tagName === "SELECT" ? "select" : (element.type || ""),
|
|
}];
|
|
});
|
|
return JSON.stringify(out);
|
|
})()"""
|
|
|
|
|
|
def build_fill_js(fills: List[Dict[str, Any]], expected_origin: str, nonce: str = "") -> str:
|
|
"""Build a JS expression that fills the selected controls and reports only a count. The
|
|
returned expression never echoes the values back.
|
|
|
|
``expected_origin`` is asserted against ``window.location.origin`` synchronously inside the SAME
|
|
evaluated script, immediately before any write. If the page navigated between inspection and fill
|
|
(TOCTOU), the script writes nothing and returns ``{"refused": "origin_changed", "found": <actual>}``:
|
|
proof scope equals mutation scope (#88706). Targets resolve by the ``<nonce>:<index>`` stamp of
|
|
THIS inspection; a ``current-password`` fill additionally requires ``type=password``; ``<select>``
|
|
controls (country, state, expiry month) match an option by value or visible text. No marker is
|
|
left on filled controls so later model-driven DOM reads cannot address them deterministically.
|
|
"""
|
|
payload = json.dumps(
|
|
[{"index": f["index"], "token": f.get("token", "current-password"), "value": f["value"]} for f in fills]
|
|
)
|
|
return (_FILL_JS_TEMPLATE.replace("__EXPECTED_ORIGIN__", json.dumps(expected_origin))
|
|
.replace("__FILLS__", payload).replace("__NONCE__", json.dumps(nonce)))
|
|
|
|
|
|
_FILL_JS_TEMPLATE = """(() => {
|
|
const expectedOrigin = __EXPECTED_ORIGIN__;
|
|
if (window.location.origin !== expectedOrigin) {
|
|
return JSON.stringify({ refused: "origin_changed", found: window.location.origin });
|
|
}
|
|
const fills = __FILLS__;
|
|
const nonce = __NONCE__;
|
|
let filled = 0;
|
|
const norm = (t) => String(t || "").trim().toLowerCase();
|
|
for (const f of fills) {
|
|
const el = document.querySelector('[data-hermes-vault-slot="' + nonce + ':' + f.index + '"]');
|
|
if (!el || (f.token === "current-password" && el.type !== "password")) continue;
|
|
try {
|
|
if (el.tagName === "SELECT") {
|
|
const want = norm(f.value);
|
|
const opt = Array.from(el.options).find((o) => [o.value, o.textContent].some((t) => norm(t) === want || norm(t) === want.replace(/^0/, "")));
|
|
if (opt) { el.value = opt.value; el.dispatchEvent(new Event("change", { bubbles: true })); filled += 1; }
|
|
continue;
|
|
}
|
|
el.focus();
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value");
|
|
if (setter && setter.set) { setter.set.call(el, f.value); } else { el.value = f.value; }
|
|
el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }));
|
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
if (el.value.length > 0) filled += 1;
|
|
} catch (e) { /* skip */ }
|
|
}
|
|
document.querySelectorAll("[data-hermes-vault-slot]").forEach((n) => n.removeAttribute("data-hermes-vault-slot"));
|
|
return JSON.stringify({ filled });
|
|
})()"""
|