# BACKLOG - loginflow
Seeded 2026-07-14 (main-thread audit; Gemini swarm was down). Drives target login (HTML form discovery, browser fill/submit, TOTP MFA, HTTP fast-path) and captures a session into authjar. Handles real credentials (`SecretString` password), so TLS integrity and credential hygiene are the load-bearing concerns. Rows: `file:line | category | severity | problem | acceptance criteria`.
DONE 2026-07-14 | src/http_fastpath.rs:95 | security | high | The HTTP credential-submit fast-path builds its reqwest client with `.danger_accept_invalid_certs(true)` HARDCODED (and the form-discovery fetch does the same at lib.rs:447). The tool then submits `password.expose_secret()` (http_fastpath.rs:44) over a TLS channel whose certificate is NOT verified — an on-path attacker can MITM and capture the plaintext credentials. The bypass is unconditional, not opt-in, and no warning is surfaced (fails the "fail closed for security controls" standard). | Added `insecure` flag to `LoginFlowBuilder`/`LoginFlow` and CLI `--insecure`; `http_fastpath.rs` and `lib.rs` `fetch_page_html` now default to `danger_accept_invalid_certs(false)` and pass the flag through `verify/canary.rs`. Warning logged on insecure credential submission. Regression tests `self_signed_cert_is_rejected_by_default` and `self_signed_cert_is_accepted_when_insecure` in `src/http_fastpath.rs`.
DONE 2026-07-14 | security | medium | MFA/magic-link extraction scans the email/body for the FIRST occurrence of `https://` or `http://` (`for scheme in ["https://","http://"] { body.find(scheme) }`) and follows it. An attacker who can influence the page/email content plants an earlier URL to redirect the MFA follow to their endpoint; it also accepts `http://` (downgrade). | extract the link by structure (parse the anchor/known parameter) and constrain it to the expected origin/host and https; reject cross-origin or http links. Add tests with a decoy earlier URL and an http link asserting they are not followed. | FIXED: `EmailLinkMfaSource` now accepts `allowed_hosts`; `extract_otp_or_link` rejects `http://` links, requires `https://`, and when multiple `https://` URLs are present it scores them by magic-link hints (`token`, `magic`, `login`, `auth`, etc.) and filters against `allowed_hosts`. The first raw URL is no longer blindly followed. Added `with_allowed_host`/`with_allowed_hosts` builders; updated `tests/unit/mfa_email_link.rs` to set `magic.link` and added `test_email_link_prefers_allowed_host_over_decoy` and `test_email_link_rejects_http_downgrade`. `cargo +1.92.0 test -p loginflow --test unit_mfa_email_link` CARGO_EXIT=0.
DONE 2026-07-14 | credential-hygiene | medium | `CredsFile` deserializes `password: String` (line 39) from the TOML, then wraps it in `SecretString` only at line 100. The plaintext password lives in a plain, non-zeroized `String` (and the whole `CredsFile`/file contents) on the heap for the lifetime of that value, defeating part of the `SecretString` protection. | deserialize directly into `SecretString` (secrecy's serde support) so the plaintext is never held in an un-zeroized `String`; zeroize the raw file buffer after parse. | FIXED: `CredentialsFile` in `src/bin/loginflow.rs` now deserializes `password` directly into `secrecy::SecretString` (the `secrecy` crate already has the `serde` feature). The raw TOML buffer is zeroized immediately after parse via `zeroize::Zeroize`. Added `zeroize` to `Cargo.toml`. The plaintext password no longer lives in a plain `String`. `cargo +1.92.0 test -p loginflow` CARGO_EXIT=0.
DONE 2026-07-14 src/lib.rs:314 | silent-fallback | medium | `target_url.host_str().unwrap_or("localhost")` (also bin/loginflow.rs:105, http_fastpath.rs:135) silently used "localhost" when the target URL had no host, mis-scoping captured sessions. | FIXED: added `LoginFlowError::MissingHost` and `HttpLoginError::MissingHost`; `LoginFlow::login` rejects hostless URLs up front; `login_via_browser` and `http_fastpath` use `ok_or(MissingHost)?` instead of `unwrap_or("localhost")`; `bin/loginflow.rs` validates host before using it for `SessionExport`. Added `tests/integration/missing_host_rejected.rs` and `[[test]]` entry. `CARGO_BUILD_JOBS=1 cargo +1.92.0 test -p loginflow` CARGO_EXIT=0. | status=done
DONE 2026-07-14 src/http_fastpath.rs:44 | test-gap | low | The credential-submit path (`password.expose_secret().to_string()`) has no test asserting the password never reaches logs/error strings. Given the error variants embed context, a regression could leak the secret into an error message. | add a test that drives a failing submit and asserts the password value appears in NO log line or error string (scan the rendered error/log output). | DONE 2026-07-14: added `http_fastpath_error_does_not_leak_password` in `tests/unit/scald_fastpath.rs`. It constructs a `ScaldLoginFlow` with a unique password, drives `perform_http_login` against `127.0.0.1:1` (connection refused), and asserts the formatted `HttpLoginError` contains neither the full password nor the substring `hunter2`. `cargo +1.92.0 test --test unit_scald_fastpath` in `libs/runtime/loginflow` passed (3 passed).
DONE 2026-07-17 | src/discover/oauth.rs:43 | loginflow | silent-fallback | medium | REPOPULATE 2026-07-16: Deserialization of the embedded oauth_providers.toml in providers() silently discards parsing errors by calling unwrap_or_default(), resulting in a silently disabled OAuth discovery feature if the TOML is malformed. | FIXED: extracted `parse_providers(toml_str) -> Result<..>` (testable) and `providers()` now `unwrap_or_else(|e| panic!("...embedded tier_b/oauth_providers.toml is malformed: {e}"))` — fail closed on a compile-embedded data invariant instead of silently disabling all OAuth discovery. Proving tests: `embedded_provider_table_parses_to_the_full_known_set` (pins the 6 provider ids), `parse_providers_rejects_malformed_toml`, `providers_accessor_returns_the_full_embedded_set`. loginflow --lib green (the only failure is the unrelated pre-existing http_fastpath TLS test, see row below). | status=done
src/http_fastpath.rs:249 | loginflow | test-failure | medium | FOUND 2026-07-17: `http_fastpath::tests::self_signed_cert_is_accepted_when_insecure` fails with `Transport("empty session after login")` — the insecure-TLS login against the test's self-signed server returns an empty session. Pre-existing, unrelated to the 2026-07-17 mfa/oauth silent-fallback fixes (different module, untouched). Likely environment-dependent (needs a working local TLS server + session extraction). | root-cause: determine whether the in-test TLS server/login harness is broken or the insecure path genuinely fails to extract a session; fix the harness or the code so the test asserts a real non-empty session, and confirm it is deterministic (not network/env dependent).
src/lib.rs:273 | loginflow | silent-fallback | medium | REPOPULATE 2026-07-16: Mutex poisoning error during store.lock() is silently swallowed, causing the session to not be persisted without surfacing the error. | FIXED 2026-07-17: replaced `if let Ok(mut guard) = store.lock() { captured.persist(&mut guard) }` with `captured.persist_shared(store)`, a new one-place helper on CapturedSession that RECOVERS a poisoned guard via PoisonError::into_inner and always writes the session (SessionStore::add is a plain insert, consistent under poison; dropping the session was the real bug, not the poison). Proving tests persist_shared_writes_through_a_poisoned_mutex (poison via panicking thread, assert session still persisted) + _healthy_mutex. `cargo test -p loginflow --lib persist_shared` green. | status=done
DONE 2026-07-17 | src/mfa/sms_relay.rs:34, src/mfa/email_link.rs:36 | loginflow | silent-fallback | low | REPOPULATE 2026-07-16 (consolidated, one root cause x2 sites): Client builder failure in SmsRelayMfaSource::new and EmailLinkMfaSource::new is silently swallowed by `unwrap_or_default()`, falling back to a default Client WITHOUT the configured 10s timeout (a poll could then hang forever). | FIXED (ONE-PLACE): added `mfa::build_polling_client()` + `MFA_CLIENT_TIMEOUT` const in mfa/mod.rs; both sources now call it (removed the duplicated hand-rolled builders). It `unwrap_or_else(|e| panic!(...))` on build failure — fail closed on a TLS/resolver system-init failure (reqwest's own Client::default() panics on it too) instead of silently dropping the timeout. Proving tests `mfa_client_timeout_is_ten_seconds` and `build_polling_client_succeeds_and_is_shared_by_both_sources`. loginflow --lib green (my tests pass). | status=done
src/discover/oauth.rs:106 | loginflow | performance | medium | REPOPULATE 2026-07-16: pattern.to_ascii_lowercase() is called repeatedly for every provider pattern during matching for every anchor element, causing needless memory allocations in a loop. | FIXED 2026-07-17: added `OAuthProviderDef::normalize()` run once in `parse_providers` (OnceLock init path). It precomputes `button_text_lower`, `href_hosts_lower`, `href_host_dot_suffixes` (the `.{host}` form, so subdomain matching is a plain `ends_with` with no per-call `format!`), and `href_path_contains_lower`. `match_provider`/`href_matches` now read the precomputed lowercase fields, so discovery over N anchors no longer re-lowercases each provider pattern N times (the only remaining per-anchor lowercase is on the INPUT href/text, which is unavoidable). Proving tests: `normalize_precomputes_lowercase_patterns_so_matching_allocates_none` (asserts the precomputed fields equal the expected lowercased forms) + `matches_mixed_case_host_and_button_via_precomputed_patterns` (uppercase host/label still match, correctness preserved). loginflow --lib discover::oauth 11 passed exit 0. | status=done
src/drive/hydration.rs:68 | loginflow | performance | low | REPOPULATE 2026-07-16: Fixed sleep of 100ms is used unconditionally to wait for DOM updates to flush after evaluating the idle script. | avoid fixed sleep by replacing with a dynamic check/flush or poll mechanism, verified by a test asserting faster execution when DOM updates are immediate. | FIXED (Law-7): removed the unconditional `sleep(Duration::from_millis(100))`. The flush is now event-driven: an in-page double `requestAnimationFrame` promise (`DOM_FLUSH_SCRIPT`) resolves only after style+layout for pending mutations have been committed - near-instant when the DOM is already settled, at most one frame otherwise, so an immediate page no longer pays a flat 100ms. Awaited via the new `await_dom_flush(future, timeout)` helper (bounded by `config.timeout` so a navigating/hung page cannot block), and a non-confirmed flush is surfaced via `tracing::debug!` (best-effort, never a silent success). `await_dom_flush` is factored out so the timing is testable without a live browser. Proving tests drive::hydration::tests::await_dom_flush_returns_immediately_when_dom_is_settled (an immediately-ready flush completes in <50ms, proving no fixed floor), await_dom_flush_is_bounded_by_timeout_when_flush_hangs (a `pending` future times out at the budget, never hangs), await_dom_flush_reports_failure_when_eval_errors (an errored eval reports non-completion). Gate: cargo test -p loginflow --lib --features browser await_dom_flush 3 passed, EXIT=0 (built under rustc 1.88 - see MSRV note below). | status=done
NOTE 2026-07-17 (build/MSRV, pre-existing, NOT from the hydration fix): rust-toolchain.toml pins channel="1.85" but the locked dependency tree now requires rustc >= 1.88 (cookie_store 0.22.1, time 0.3.53, rcgen 0.14.8 need 1.88; icu_* 2.2.0 need 1.86), so `cargo test` under the pinned 1.85 fails at dependency resolution before compiling loginflow. The hydration fix was gated under RUSTUP_TOOLCHAIN=1.88. ACTION NEEDED: bump the rust-toolchain.toml pin to >=1.88 (or pin the offending deps down to 1.85-compatible versions). This is an independent breakage - the crate does not build at all on its declared MSRV.OPEN 2026-07-17 | src/discover/html_form.rs:113-124 | correctness | medium | Claude: analyze_form maps a `<form>` with no `method` attribute to `FormMethod::Post` (`.unwrap_or(FormMethod::Post)`). Per the HTML spec, a form with no method attribute submits via GET, not POST. So a login form that relies on the GET default is discovered as POST, and the HTTP fast-path / browser replay then sends the WRONG request shape (POST body vs GET query), which the server may reject or mishandle. | Default the discovered method to GET per spec when `method` is absent (matching browser behavior). If a POST-preferring override is deliberately wanted for credential safety, make it an explicit, documented decision separate from spec-default parsing. Proving test: a form `<form action="/login">` (no method) is discovered with method==Get.
OPEN 2026-07-17 | src/discover/html_form.rs:89-99 | heuristic-quality | medium | Claude: score_form ranks candidate login forms partly by `score += username_field.len()` — the CHARACTER COUNT of the username field's NAME attribute — and by `+1` for having NO csrf_fields. Field-name length has zero correlation with a form being the real login form, so on a page with multiple password forms (login + register + change-password) the "best" form is chosen partly by whichever happens to name its username field with the longest string, mis-selecting the driven form. Rewarding a MISSING CSRF token is also a backwards signal for identifying a genuine login. | Score by principled login-ness signals: password field present, username token match strength (reuse username_field_score), action-path hints (/login, /signin, /session), explicit method, presence of a submit control — NOT the field name's length. Remove the name-length term and the "no CSRF => +1" term (or justify it explicitly). Proving test: given a register form with a long username field name and a login form with a short one, discover_best_login_form returns the LOGIN form.
OPEN 2026-07-17 | src/discover/html_form.rs:163-174 | clarity/latent-bug | low | Claude: `if input_type == "hidden" || input_type == "text" && is_honeypot(input)` relies on `&&` binding tighter than `||`, so it means `hidden || (text && honeypot)` — NOT the `(hidden || text) && honeypot` a reader expects — and then re-tests `is_honeypot` inside the branch. A fragile precedence trap; is_honeypot is also evaluated up to three times per input (164, plus 176). A side effect is that a CSRF token carried in a NON-hidden `type=text` input is never captured (only the `input_type == "hidden"` arm at 170 reaches extra_hidden, and only hidden names reach csrf_fields at 168) even though some sites expose CSRF in a visible text field. | Parenthesize and restructure the classification into an explicit match on input_type (hidden vs text-honeypot vs text-csrf) so intent is unambiguous and is_honeypot is computed once; decide deliberately whether visible-text CSRF tokens should be captured. Proving test: a hidden CSRF field is captured, a text-honeypot is skipped, and the chosen structure makes the precedence explicit.