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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user