From e11d2f06ffbb794254a9b7cb7008d79058b6e762 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Mon, 20 Jul 2026 22:07:32 -0700 Subject: [PATCH] browserrot: login-before-change phase + curated Gitea recipe (M-B4 step 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/browserrot/browserrot.go | 71 +++++++--- internal/browserrot/browserrot_test.go | 182 +++++++++++++++++++++++++ internal/browserrot/sites.go | 53 +++++++ 3 files changed, 289 insertions(+), 17 deletions(-) create mode 100644 internal/browserrot/sites.go diff --git a/internal/browserrot/browserrot.go b/internal/browserrot/browserrot.go index 59c147a..3d903ec 100644 --- a/internal/browserrot/browserrot.go +++ b/internal/browserrot/browserrot.go @@ -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)) diff --git a/internal/browserrot/browserrot_test.go b/internal/browserrot/browserrot_test.go index 7961865..2a81ac1 100644 --- a/internal/browserrot/browserrot_test.go +++ b/internal/browserrot/browserrot_test.go @@ -337,3 +337,185 @@ func runLiveRotationProof(t *testing.T, drv Driver, mutators ...func(*Site)) *Ro } func hostForDebug(c discover.Credential) string { return links.HostFor(c) } + +func TestGiteaSite_recipeShape(t *testing.T) { + s := GiteaSite("https://git.churchofmalware.org/", "trilltechnician") + if s.Host != "git.churchofmalware.org" { + t.Fatalf("host: got %q", s.Host) + } + if s.ChangeURL != "https://git.churchofmalware.org/user/settings/account" { + t.Fatalf("changeURL: got %q", s.ChangeURL) + } + if !s.LoginBeforeChange { + t.Fatal("Gitea account page is auth-gated; LoginBeforeChange must be true") + } + if s.Login.URL == "" || s.Form.CurrentSel == "" || s.Form.NewSel == "" || s.Form.SubmitSel == "" { + t.Fatalf("recipe has empty required selectors: %+v", s) + } + // A seed recipe is UNPROVEN until actually run against the real instance (proof-as-data). + if s.Proof != rotate.ProofUnproven { + t.Fatalf("a fresh curated recipe must be ProofUnproven, got %v", s.Proof) + } +} + +// TestIntegration_loginBeforeChangeRotation proves the Site.LoginBeforeChange path against +// a real Chromium: the change page is reachable ONLY inside an authenticated session (a +// Gitea-style cookie gate — 302 → /login when unauthenticated). Rotate must log in with the +// OLD secret first, carry the cookie into the change page, rotate, and then Verify re-logs-in +// with the NEW secret. This is the engine change M-B4 needs for real account-settings pages. +func TestIntegration_loginBeforeChangeRotation(t *testing.T) { + bin := os.Getenv("CHROME_BIN") + if bin == "" { + t.Skip("set CHROME_BIN to a Chromium executable to run the LIVE-VM login-gated proof") + } + + const oldPW = "OLD-corr3ct-horse-battery" + const user = "trilltechnician" + var mu sync.Mutex + stored := oldPW + const cookie = "sess=authok" + + authed := func(r *http.Request) bool { + c, err := r.Cookie("sess") + return err == nil && c.Value == "authok" + } + + mux := http.NewServeMux() + // /login: GET renders the form; POST checks the CURRENT stored password and, on + // success, sets the session cookie (this is what gates the settings page). + mux.HandleFunc("/user/login", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + r.ParseForm() + mu.Lock() + ok := r.FormValue("user_name") == user && r.FormValue("password") == stored + mu.Unlock() + if ok { + http.SetCookie(w, &http.Cookie{Name: "sess", Value: "authok", Path: "/"}) + fmt.Fprint(w, `

Dashboard SUCCESS

`) + return + } + fmt.Fprint(w, `

DENIED

`) + return + } + fmt.Fprint(w, `
+ + +
`) + }) + // /user/settings/account: the change-password page, gated behind the cookie. + mux.HandleFunc("/user/settings/account", func(w http.ResponseWriter, r *http.Request) { + if !authed(r) { + http.Redirect(w, r, "/user/login", http.StatusSeeOther) + return + } + if r.Method == http.MethodPost { + r.ParseForm() + mu.Lock() + ok := r.FormValue("old_password") == stored + if ok { + stored = r.FormValue("password") + } + mu.Unlock() + if !ok { + fmt.Fprint(w, `
error: incorrect old password
`) + return + } + fmt.Fprint(w, `
Your password has been updated.
`) + return + } + fmt.Fprint(w, `
+ + + +
`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + host := strings.TrimPrefix(srv.URL, "http://") + site := Site{ + Host: host, + ChangeURL: srv.URL + "/user/settings/account", + Form: Form{ + CurrentSel: "#old_password", NewSel: "#new_password", ConfirmSel: "#retype", + SubmitSel: "#save", SuccessSel: "#flash", SuccessText: "has been updated", + }, + Login: Login{ + URL: srv.URL + "/user/login", UserSel: "#user_name", PassSel: "#password", + SubmitSel: "#signin", SuccessSel: "#dash", SuccessText: "SUCCESS", + }, + LoginBeforeChange: true, + Proof: rotate.ProofLiveVM, + } + + v := vault.New() + defer v.Purge() + c := discover.Credential{ + Source: "file", Identity: user + " @ " + host, Location: "imported/file/x", + Secret: v.Store([]byte(oldPW)), + Meta: map[string]string{"username": user}, + } + + drv := &RodDriver{Bin: bin, Headless: true, Timeout: 30 * time.Second} + r := New(site, drv, pwgen.DefaultPolicy()) + + ctx := context.Background() + newH, err := r.Rotate(ctx, c, v) + if err != nil { + t.Fatalf("Rotate (login-before-change): %v", err) + } + if err := r.Verify(ctx, newH, v); err != nil { + t.Fatalf("Verify (new pw must authenticate): %v", err) + } + mu.Lock() + oldStillWorks := stored == oldPW + mu.Unlock() + if oldStillWorks { + t.Fatal("old password still stored — rotation did not take effect") + } + t.Log("LIVE-VM login-before-change proof passed: login(old)→settings→rotate→verify(new)→old-rejected") +} + +// TestRotate_loginBeforeChangeOrder asserts (no browser) that LoginBeforeChange makes +// Rotate hit the login flow BEFORE the change page, using the fake driver's op log. +func TestRotate_loginBeforeChangeOrder(t *testing.T) { + v := vault.New() + defer v.Purge() + c := gitHubPAT(t, v, "OLD-pw") + + site := testSite() + site.ChangeURL = "https://github.com/settings/password" + site.Login = Login{ + URL: "https://github.com/login", UserSel: "#u", PassSel: "#p", + SubmitSel: "#go", SuccessSel: "#welcome", SuccessText: "SUCCESS", + } + site.LoginBeforeChange = true + + drv := &fakeDriver{success: "SUCCESS"} + r := New(site, drv, pwgen.DefaultPolicy()) + + newH, err := r.Rotate(context.Background(), c, v) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + defer v.Forget(newH) + + // The first op must be the login goto, and the login submit must precede the change goto. + var seq []string + for _, o := range drv.ops { + seq = append(seq, o.kind+":"+o.arg) + } + joined := strings.Join(seq, ",") + loginGoto := "goto:https://github.com/login" + changeGoto := "goto:https://github.com/settings/password" + if seq[0] != loginGoto { + t.Fatalf("first op must be the login goto, got %q (seq=%v)", seq[0], seq) + } + if strings.Index(joined, loginGoto) > strings.Index(joined, changeGoto) { + t.Fatalf("login must precede change navigation: %v", seq) + } + // The OLD secret authenticates the pre-change login (fill #p before the change goto). + if drv.fields["#p"] != "OLD-pw" { + t.Fatalf("pre-change login must use the OLD secret, got %q", drv.fields["#p"]) + } +} diff --git a/internal/browserrot/sites.go b/internal/browserrot/sites.go new file mode 100644 index 0000000..9081dd5 --- /dev/null +++ b/internal/browserrot/sites.go @@ -0,0 +1,53 @@ +package browserrot + +import ( + "strings" + + "incredigo/internal/rotate" +) + +// Curated recipes for self-hosted software whose markup is stable and known. These +// seed the Tier-1 selector table; on a first real run any miss is repaired by the +// Tier-2 self-heal (Healer) and re-persisted (recipes.go), so a slightly-off seed +// self-corrects rather than failing permanently. A recipe stays ProofUnproven until +// it has actually driven a rotation on that host — same proof-as-data discipline as +// the API drivers (rotate/proofs.go); only then does it promote to LIVE-REAL. + +// GiteaSite builds a change-password recipe for a Gitea instance. baseURL is the +// scheme+host (e.g. "https://git.churchofmalware.org"), username the account login. +// +// Gitea reaches the change-password controls only inside an authenticated session +// (/user/settings/account 302s to /user/login otherwise), so LoginBeforeChange is on. +// The password form on the account page carries old_password / password / retype; the +// result renders in a flash message. These selectors match current Gitea markup but are +// UNPROVEN against any specific instance/version — VERIFY on first run (self-heal will +// rewrite any that drift). The SuccessText markers are the one part self-heal cannot fix +// (they are content, not selectors): a wrong marker only makes Rotate fail SAFE (old +// secret kept, human flagged), never a silent bad rotation. +func GiteaSite(baseURL, username string) Site { + base := strings.TrimRight(baseURL, "/") + return Site{ + Host: strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://"), + ChangeURL: base + "/user/settings/account", + Form: Form{ + CurrentSel: "#old_password", + NewSel: "#password", + ConfirmSel: "#retype", + SubmitSel: "form[action$='/user/settings/account'] button[type=submit]", + SuccessSel: ".ui.positive.message, .flash-success", + SuccessText: "has been updated", + }, + Login: Login{ + URL: base + "/user/login", + UserSel: "#user_name", + PassSel: "#password", + SubmitSel: ".ui.primary.button[type=submit], button[type=submit]", + // Post-login, Gitea lands on the dashboard. The sign-out control's text is a + // stable "logged in" marker across themes; confirm on first run. + SuccessSel: "body", + SuccessText: username, + }, + LoginBeforeChange: true, + Proof: rotate.ProofUnproven, + } +}