browserrot: login-before-change phase + curated Gitea recipe (M-B4 step 1)

Real account-settings pages (Gitea /user/settings/account, most dashboards) are
reachable only inside an authenticated session, but the engine went straight to
the change URL. Add Site.LoginBeforeChange: when set, Rotate authenticates through
the Login flow with the OLD secret first, so the session cookie carries into the
change-page fetch. Extract the login sequence into a shared r.login() helper reused
by both the pre-change auth (old secret) and Verify (new secret). A login wall we
cannot clear — including where MFA would appear — fails SAFE (old secret kept).

GiteaSite() seeds a curated change-password recipe (LoginBeforeChange on, standard
Gitea selectors, ProofUnproven until run against a real instance — self-heal repairs
any selector drift on first run; SuccessText mismatch only fails safe).

Proven LIVE-VM: TestIntegration_loginBeforeChangeRotation drives real Chromium
through a Gitea-style cookie gate (change page 302s to /login unless authenticated):
login(old)→settings→rotate→verify(new)→old-rejected. Non-browser
TestRotate_loginBeforeChangeOrder asserts login precedes change navigation and the
pre-change login uses the OLD secret. Full suite green.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-07-20 22:07:32 -07:00
parent 1db4134019
commit e11d2f06ff
3 changed files with 289 additions and 17 deletions
+54 -17
View File
@@ -110,7 +110,13 @@ type Site struct {
ChangeURL string
Form Form
Login Login
Proof rotate.ProofLevel
// LoginBeforeChange makes Rotate authenticate through the Login flow (with the
// OLD secret) BEFORE navigating to the change page. Real account settings pages
// (Gitea /user/settings/account, most dashboards) are reachable only inside an
// authenticated session; a directly-served change form (the lab target, or a
// public reset link) does not need it. Requires a non-empty Login.
LoginBeforeChange bool
Proof rotate.ProofLevel
}
// Rotator drives one Site. Unlike the stateless API drivers, a browser Rotator is
@@ -163,6 +169,25 @@ func (r *Rotator) Rotate(ctx context.Context, c discover.Credential, v *vault.Va
}
defer sess.Close()
// Sites whose change page lives behind auth (Gitea /user/settings/account, most
// dashboards) must be logged in first, using the OLD secret. The same login flow
// Verify uses; here the session's cookies then carry into the change-page fetch.
if r.site.LoginBeforeChange {
oldBuf, err := v.Open(c.Secret)
if err != nil {
return nil, fmt.Errorf("browserrot: open old secret (pre-change login): %w", err)
}
txt, err := r.login(ctx, sess, oldBuf.Bytes())
if err != nil {
return nil, fmt.Errorf("browserrot: pre-change login: %w", err)
}
if !strings.Contains(txt, r.site.Login.SuccessText) {
// A login wall we could not clear with the old secret is also where MFA
// would appear: the marker won't match. Keep the old secret, flag the human.
return nil, fmt.Errorf("browserrot: pre-change login did not succeed (needs human / MFA?): got %q", strings.TrimSpace(txt))
}
}
if _, err := sess.Goto(ctx, changeURL); err != nil {
return nil, fmt.Errorf("browserrot: goto change page: %w", err)
}
@@ -304,6 +329,32 @@ func (r *Rotator) Healed() bool { return r.healed }
// HealedSite returns the Site with any healed selectors folded in — the recipe to persist.
func (r *Rotator) HealedSite() Site { return r.site }
// login runs the Site's Login flow with the given password on an already-open session
// and returns the post-login status text. It is the shared core of both Rotate's
// optional pre-change auth (with the OLD secret) and Verify's re-login (with the NEW
// secret). The password crosses as []byte and is never logged; each field self-heals.
func (r *Rotator) login(ctx context.Context, sess Session, password []byte) (string, error) {
if _, err := sess.Goto(ctx, r.site.Login.URL); err != nil {
return "", fmt.Errorf("browserrot: goto login: %w", err)
}
if r.site.Login.UserSel != "" {
if err := r.fill(ctx, sess, &r.site.Login.UserSel, "the username or email input field", []byte(r.username)); err != nil {
return "", err
}
}
if err := r.fill(ctx, sess, &r.site.Login.PassSel, "the password input field", password); err != nil {
return "", err
}
if err := r.click(ctx, sess, &r.site.Login.SubmitSel, "the log-in button"); err != nil {
return "", err
}
txt, err := r.text(ctx, sess, &r.site.Login.SuccessSel, "the post-login status message")
if err != nil {
return "", fmt.Errorf("browserrot: read login result: %w", err)
}
return txt, nil
}
// Verify proves the new secret authenticates by re-logging-in through the Site's
// Login flow (hard rule 2: verify-new-before-revoke-old). If the Site has no Login
// flow, Verify REFUSES — the spine then keeps the old secret and flags the human,
@@ -318,27 +369,13 @@ func (r *Rotator) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.
}
defer sess.Close()
if _, err := sess.Goto(ctx, r.site.Login.URL); err != nil {
return fmt.Errorf("browserrot: goto login: %w", err)
}
if r.site.Login.UserSel != "" {
if err := r.fill(ctx, sess, &r.site.Login.UserSel, "the username or email input field", []byte(r.username)); err != nil {
return err
}
}
buf, err := v.Open(newSecret)
if err != nil {
return fmt.Errorf("browserrot: open new secret (verify): %w", err)
}
if err := r.fill(ctx, sess, &r.site.Login.PassSel, "the password input field", buf.Bytes()); err != nil {
return err
}
if err := r.click(ctx, sess, &r.site.Login.SubmitSel, "the log-in button"); err != nil {
return err
}
txt, err := r.text(ctx, sess, &r.site.Login.SuccessSel, "the post-login status message")
txt, err := r.login(ctx, sess, buf.Bytes())
if err != nil {
return fmt.Errorf("browserrot: read login result: %w", err)
return err
}
if !strings.Contains(txt, r.site.Login.SuccessText) {
return fmt.Errorf("browserrot: new password did NOT authenticate: got %q", strings.TrimSpace(txt))