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
|
ChangeURL string
|
||||||
Form Form
|
Form Form
|
||||||
Login Login
|
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
|
// 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()
|
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 {
|
if _, err := sess.Goto(ctx, changeURL); err != nil {
|
||||||
return nil, fmt.Errorf("browserrot: goto change page: %w", err)
|
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.
|
// HealedSite returns the Site with any healed selectors folded in — the recipe to persist.
|
||||||
func (r *Rotator) HealedSite() Site { return r.site }
|
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
|
// 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
|
// 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,
|
// 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()
|
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)
|
buf, err := v.Open(newSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("browserrot: open new secret (verify): %w", err)
|
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 {
|
txt, err := r.login(ctx, sess, buf.Bytes())
|
||||||
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("browserrot: read login result: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
if !strings.Contains(txt, r.site.Login.SuccessText) {
|
if !strings.Contains(txt, r.site.Login.SuccessText) {
|
||||||
return fmt.Errorf("browserrot: new password did NOT authenticate: got %q", strings.TrimSpace(txt))
|
return fmt.Errorf("browserrot: new password did NOT authenticate: got %q", strings.TrimSpace(txt))
|
||||||
|
|||||||
@@ -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 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user