package console import ( "context" "net/http" "net/url" "strings" "testing" "github.com/carlosframework/platform/internal/provision" ) func TestAppSettingsGateSetAndClear(t *testing.T) { fx, token := instanceFixture(t, "code-settings-gate") letStateUpstreams(t, fx, "alice@keymail.dev") enableInstances(t, fx, "titogo", sharedPoolManifest()) if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, `{"host":"pw.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated { t.Fatalf("create = %d %s", status, out) } c := newBrowser(t) signUp(t, fx, c, "code-settings-gate-browser") csrf := sessionToken(t, fx, c) // The page shows the host as open, with a form. _, _, body := get(t, c, fx.u("/apps/titogo/settings/domains")) if !strings.Contains(body, "Password gate") || !strings.Contains(body, "pw.go.tito.io") || !strings.Contains(body, "Open") { t.Fatalf("settings page lacks the gate section: %s", body) } status, loc, _ := postForm(t, c, fx.u("/apps/titogo/gate"), url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"correct horse"}}) if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings/domains") { t.Fatalf("set = %d -> %q", status, loc) } rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io") if err != nil { t.Fatal(err) } if !strings.HasPrefix(rec.Gate, "$2a$10$") { t.Fatalf("stored gate = %q", rec.Gate) } _, _, body = get(t, c, fx.u("/apps/titogo/settings/domains")) if !strings.Contains(body, "Password set on pw.go.tito.io.") || !strings.Contains(body, "Turn off") { t.Fatalf("after set: %s", body) } if strings.Contains(body, "$2a$") || strings.Contains(body, "correct horse") { t.Fatal("page leaks the hash or the password") } // A short password is refused with the rule, and nothing changes. postForm(t, c, fx.u("/apps/titogo/gate"), url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"short"}}) _, _, body = get(t, c, fx.u("/apps/titogo/settings/domains")) if !strings.Contains(body, "at least 8 characters") { t.Fatalf("short password not refused on the page: %s", body) } status, _, _ = postForm(t, c, fx.u("/apps/titogo/gate"), url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"clear"}}) if status != http.StatusSeeOther { t.Fatalf("clear = %d", status) } rec, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io") if rec.Gate != "" { t.Fatalf("gate after clear = %q", rec.Gate) } events, _ := fx.st.Events(OpsAccountID, "titogo", 20) var saw int for _, e := range events { if e.Action == "gate" { saw++ if strings.Contains(e.Detail, "correct horse") { t.Fatal("audit carries the password") } } } if saw != 2 { t.Fatalf("gate audit rows = %d, want 2", saw) } } // The gate door is not an oracle. A signed-in browser that belongs to no // account here gets the account family's 404 — byte for byte what an // account that does not exist answers, so a stranger cannot use the gate // form to learn that this account, this app or this host is real — and the // record is untouched. Worth pinning on THIS door in particular: it is the // one that takes a password, and a stranger who could set one would lock an // account out of its own site. func TestAppSettingsGateIsNotAnOracle(t *testing.T) { fx, token := instanceFixture(t, "code-settings-gate-stranger") letStateUpstreams(t, fx, "alice@keymail.dev") enableInstances(t, fx, "titogo", sharedPoolManifest()) if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, `{"host":"pw.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated { t.Fatalf("create = %d %s", status, out) } stranger := newBrowser(t) fx.setUser("eve@keymail.dev") // signs up into an account of her own signUp(t, fx, stranger, "code-settings-gate-stranger-browser") csrf := sessionToken(t, fx, stranger) form := url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"eve was here"}} ghost, _, missing := postForm(t, stranger, fx.ts.URL+"/a/zzzzz/apps/titogo/gate", form) if ghost != http.StatusNotFound { t.Fatalf("the yardstick is not a 404: nonexistent account = %d", ghost) } status, loc, body := postForm(t, stranger, fx.u("/apps/titogo/gate"), form) if status != http.StatusNotFound { t.Fatalf("gate POST as a non-member = %d -> %q, want 404", status, loc) } if body != missing { t.Fatalf("a non-member's 404 differs from a nonexistent account's:\n%q\nvs\n%q", body, missing) } rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io") if err != nil { t.Fatal(err) } if rec.Gate != "" { t.Fatalf("a stranger set a gate: %q", rec.Gate) } } func TestAppSettingsGateNeedsCSRF(t *testing.T) { fx, _ := instanceFixture(t, "code-settings-gate-csrf") c := newBrowser(t) signUp(t, fx, c, "code-settings-gate-csrf-browser") status, _, body := postForm(t, c, fx.u("/apps/titogo/gate"), url.Values{"csrf": {"not-the-token"}, "host": {"pw.go.tito.io"}, "action": {"clear"}}) if status != http.StatusForbidden || !strings.Contains(body, "Cross-site request blocked.") { t.Fatalf("got %d %s", status, body) } }