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
+182
View File
@@ -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, `<h1 id="dash">Dashboard SUCCESS</h1>`)
return
}
fmt.Fprint(w, `<h1 id="dash">DENIED</h1>`)
return
}
fmt.Fprint(w, `<!doctype html><meta charset=utf-8><form method=POST>
<input id=user_name name=user_name>
<input id=password name=password type=password>
<button id=signin type=submit>Sign In</button></form>`)
})
// /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, `<div id="flash">error: incorrect old password</div>`)
return
}
fmt.Fprint(w, `<div id="flash">Your password has been updated.</div>`)
return
}
fmt.Fprint(w, `<!doctype html><meta charset=utf-8><form method=POST>
<input id=old_password name=old_password type=password>
<input id=new_password name=password type=password>
<input id=retype name=retype type=password>
<button id=save type=submit>Update Password</button></form>`)
})
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"])
}
}