Files
brain/internal/http/security_test.go
T
Andrew S Erwin b9721ff13f
release gate / check (push) Failing after 8m35s
initial commit on Federted servers
2026-10-19 16:23:55 -05:00

257 lines
11 KiB
Go

package http
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"github.com/andrew/go-computer/internal/scripts"
"net/http"
"net/http/cookiejar"
"os"
"strconv"
"testing"
"time"
)
func TestConfiguredAdminCannotBeBypassedDuringBootstrap(t *testing.T) {
srv, d := testServerDB(t)
t.Setenv("ADMIN_EMAILS", "operator@computer.test")
visitor, _ := accountClient(t, srv.URL, "visitor@computer.test")
if visitor.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("public signup bypassed reserved administrator bootstrap")
}
visitor.request("GET", "/api/admin/users", nil, 403)
t.Setenv("SEED_EMAIL", "operator@computer.test")
t.Setenv("SEED_PASSWORD", "operator-test-password")
if err := scripts.Seed(context.Background(), d); err != nil {
t.Fatal(err)
}
visitor.request("POST", "/api/auth/login", map[string]any{"email": "operator@computer.test", "password": "operator-test-password"}, 200)
if visitor.request("GET", "/api/me", nil, 200)["isAdmin"] != true {
t.Fatal("operator-provisioned account is not admin")
}
t.Setenv("SEED_PASSWORD", "different-password")
if err := scripts.Seed(context.Background(), d); err != nil {
t.Fatal(err)
}
visitor.request("POST", "/api/auth/login", map[string]any{"email": "operator@computer.test", "password": "operator-test-password"}, 200)
}
func accountClient(t *testing.T, base, email string) (apiClient, string) {
t.Helper()
jar, _ := cookiejar.New(nil)
c := apiClient{t, &http.Client{Jar: jar}, base}
body := map[string]any{"email": email, "password": "test-long-password"}
c.request("POST", "/api/signup", body, 201)
c.request("POST", "/api/auth/login", body, 200)
// Review CR-03: the signup body no longer carries the id; identity comes
// from the authenticated session.
return c, meID(c)
}
func TestAdminEnrollmentRequiresTrustedIdentity(t *testing.T) {
srv, d := testServerDB(t)
admin, _ := accountClient(t, srv.URL, "admin@computer.test")
user, uid := accountClient(t, srv.URL, "unverified@computer.test")
t.Setenv("ADMIN_EMAILS", "reserved@computer.test,unverified@computer.test")
// Reserved addresses answer exactly like existing accounts (no 403 oracle).
// Review SEC-4/CR-03: operator-reserved addresses return the SAME uniform
// 201 {"ok":true} body as every other accepted registration — no userId
// field, no 409.
reserved := user.request("POST", "/api/signup", map[string]any{"email": "reserved@computer.test", "password": "test-password"}, 201)
if len(reserved) != 1 || reserved["ok"] != true {
t.Fatalf("operator-reserved address body is distinguishable: %v", reserved)
}
if user.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("unverified address promoted to admin")
}
user.request("GET", "/api/admin/users", nil, 403)
if _, err := d.Exec(`UPDATE "User" SET "emailVerified"=now() WHERE id=$1`, uid); err != nil {
t.Fatal(err)
}
if user.request("GET", "/api/me", nil, 200)["isAdmin"] != true {
t.Fatal("verified allowlisted address was not promoted")
}
admin.request("GET", "/api/admin/users", nil, 200)
}
func TestPublicBookmarksDoNotExposePrivateMetadata(t *testing.T) {
srv, d := testServerDB(t)
owner, uid := accountClient(t, srv.URL, "owner@computer.test")
viewer, vid := accountClient(t, srv.URL, "viewer@computer.test")
if _, err := d.Exec(`INSERT INTO "Contact"(id,uid,"displayName",email,phone,"userId") VALUES('contact','vcard','Private','private@computer.test','555-private',$1)`, uid); err != nil {
t.Fatal(err)
}
id := owner.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/test", "isPublic": true, "contactId": "contact", "imapQuery": "private mailbox query", "alertAt": "2026-10-07"}, 201)["id"].(string)
v := viewer.request("GET", "/api/public/bookmarks/"+id, nil, 200)
// Review PRIV-04: public links carry no owner attribution at all; the
// README promises owner identity stays private.
for _, key := range []string{"contact", "contactId", "sharedWith", "owner", "ownerId", "imapQuery", "imapWatchEnabled", "alertAt", "alertSent", "kanbanColumnId", "kanbanPosition", "pageLinks"} {
if _, exists := v[key]; exists {
t.Errorf("public response includes %s", key)
}
}
viewer.request("GET", "/api/bookmarks/"+id, nil, 404)
owner.request("POST", "/api/bookmarks/"+id+"/share", map[string]any{"userIds": []string{vid}}, 200)
// Review PRIV-02: an authenticated shared reader gets the narrowed
// projection — no contact record, no recipient list, no owner operational
// metadata — while the owner keeps the full view.
shared := viewer.request("GET", "/api/bookmarks/"+id, nil, 200)
for _, key := range []string{"contact", "contactId", "sharedWith", "imapQuery", "imapWatchEnabled", "alertAt", "alertSent", "kanbanColumnId", "kanbanPosition"} {
if _, exists := shared[key]; exists {
t.Errorf("shared-reader response includes %s", key)
}
}
if shared["url"] != "https://127.0.0.1/test" || shared["ownerId"] != uid {
t.Error("shared-reader response lost basic bookmark fields")
}
if _, exists := shared["tags"]; !exists {
t.Error("shared-reader response lost tags")
}
full := owner.request("GET", "/api/bookmarks/"+id, nil, 200)
if c, _ := full["contact"].(map[string]any); c == nil || c["email"] != "private@computer.test" {
t.Error("owner projection lost the contact record")
}
if s, ok := full["sharedWith"].([]any); !ok || len(s) != 1 {
t.Error("owner projection lost the recipient list")
}
if _, exists := full["imapQuery"]; !exists {
t.Error("owner projection lost mailbox-watch settings")
}
// The list endpoint must apply the same permission-specific projections.
res, err := viewer.c.Get(srv.URL + "/api/bookmarks?shared=true")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var list []map[string]any
if err := json.NewDecoder(res.Body).Decode(&list); err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0]["id"] != id {
t.Fatalf("shared list returned %d entries", len(list))
}
for _, key := range []string{"contact", "sharedWith", "imapQuery", "alertAt"} {
if _, exists := list[0][key]; exists {
t.Errorf("shared list response includes %s", key)
}
}
putBookmark(owner, id, map[string]any{"isPublic": false}, 200)
viewer.request("GET", "/api/public/bookmarks/"+id, nil, 404)
}
func TestAdminBootstrapIsOneShot(t *testing.T) {
srv, d := testServerDB(t)
// Fresh installation: the first signup completes bootstrap as admin.
admin, adminID := accountClient(t, srv.URL, "first@computer.test")
if admin.request("GET", "/api/me", nil, 200)["isAdmin"] != true {
t.Fatal("first signup on a fresh installation was not admin")
}
// Review AUTH-06 acceptance: demoting the last administrator must not
// re-open administrator assignment through signup.
if _, err := d.Exec(`UPDATE "User" SET role='USER' WHERE id=$1`, adminID); err != nil {
t.Fatal(err)
}
latecomer, _ := accountClient(t, srv.URL, "late@computer.test")
if latecomer.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("signup re-opened admin bootstrap after the last admin was demoted")
}
// Removing every administrator entirely does not re-open it either.
if _, err := d.Exec(`DELETE FROM "User" WHERE id=$1`, adminID); err != nil {
t.Fatal(err)
}
successor, _ := accountClient(t, srv.URL, "successor@computer.test")
if successor.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("signup re-opened admin bootstrap after the last admin was removed")
}
successor.request("GET", "/api/admin/users", nil, 403)
}
// Review SEC-5: with ALLOW_BOOTSTRAP_SIGNUP=0 a fresh installation must not
// hand administrator to the first signup — provisioning stays operator-driven.
func TestBootstrapSignupCanBeDisabled(t *testing.T) {
srv := testServer(t)
t.Setenv("ALLOW_BOOTSTRAP_SIGNUP", "0")
visitor, _ := accountClient(t, srv.URL, "unclaimed@computer.test")
if visitor.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("first signup became admin despite ALLOW_BOOTSTRAP_SIGNUP=0")
}
visitor.request("GET", "/api/admin/users", nil, 403)
}
func TestDirectLoginRejectsBrowserForgery(t *testing.T) {
srv := testServer(t)
for _, tc := range []struct {
contentType, origin string
status int
}{
{"text/plain", "https://attacker.invalid", 415},
{"application/json", "https://attacker.invalid", 403},
} {
req, _ := http.NewRequest("POST", srv.URL+"/api/auth/login", bytes.NewBufferString(`{"email":"attacker@computer.test","password":"password"}`))
req.Header.Set("Content-Type", tc.contentType)
req.Header.Set("Origin", tc.origin)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != tc.status || len(res.Cookies()) != 0 {
t.Fatalf("forged login: status=%d cookies=%v", res.StatusCode, res.Cookies())
}
}
}
// Review SEC-1/SEC-2: the failure lockout keys on the proxy-attested browser
// IP. Two different signed identities get independent lockout buckets; a
// replayed (stale-timestamp) signature is not honored.
func TestLoginLockoutHonorsSignedForwardedIdentity(t *testing.T) {
srv := testServer(t)
secret := os.Getenv("AUTH_SECRET")
sign := func(ip string, ts int64) (string, string, string) {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10)))
return ip, strconv.FormatInt(ts, 10), hex.EncodeToString(mac.Sum(nil))
}
post := func(email, password, ip string, ts int64) int {
body, _ := json.Marshal(map[string]any{"email": email, "password": password})
req, _ := http.NewRequest("POST", srv.URL+"/api/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if ip != "" {
hip, hts, sig := sign(ip, ts)
req.Header.Set("X-Computer-Client-Ip", hip)
req.Header.Set("X-Computer-Client-Ts", hts)
req.Header.Set("X-Computer-Client-Sig", sig)
}
res, e := http.DefaultClient.Do(req)
if e != nil {
t.Fatal(e)
}
res.Body.Close()
return res.StatusCode
}
now := time.Now().Unix()
// Browser A: five failures lock out its (IP, email) bucket.
for i := 0; i < 5; i++ { // failureThreshold
if code := post("victim-a@computer.test", "wrong-password", "198.51.100.1", now); code != 401 {
t.Fatalf("browser A attempt %d: %d", i+1, code)
}
}
if code := post("victim-a@computer.test", "wrong-password", "198.51.100.1", now); code != 429 {
t.Fatalf("browser A not locked out: %d", code)
}
// Browser B behind the same proxy peer keeps its own buckets.
if code := post("victim-a@computer.test", "wrong-password", "198.51.100.2", now); code != 401 {
t.Fatalf("browser B locked out by browser A's failures: %d", code)
}
// A stale signature (outside the ~60s window) falls back to the peer, so
// it cannot smuggle a fresh identity: these attempts count against the
// peer bucket and stay 401.
if code := post("victim-b@computer.test", "wrong-password", "198.51.100.9", now-3600); code != 401 {
t.Fatalf("stale signature changed behavior: %d", code)
}
}