221 lines
9.2 KiB
Go
221 lines
9.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"os/user"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// laneOwner records uid as the machine's owner account, as the installer's
|
|
// root step does, with machine authority faked and the account looked up as
|
|
// an invented one.
|
|
func laneOwner(t *testing.T, uid uint32) {
|
|
t.Helper()
|
|
prevAuth, prevLookup, prevState := machineAuthorityHeld, lookupOwnerAccount, ownerServiceState
|
|
t.Cleanup(func() { machineAuthorityHeld, lookupOwnerAccount, ownerServiceState = prevAuth, prevLookup, prevState })
|
|
machineAuthorityHeld = func() bool { return true }
|
|
ownerServiceState = func() string { return "" }
|
|
lookupOwnerAccount = func(who string) (*user.User, error) {
|
|
return &user.User{Uid: strconv.FormatUint(uint64(uid), 10), Username: "sam"}, nil
|
|
}
|
|
if err := cmdOwnerAccount("sam", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// laneSignedIn redeems a ticket minted for the owner's lane, the way the
|
|
// browser does, and returns the session cookie it sets.
|
|
func laneSignedIn(t *testing.T, u *uiServer, uid uint32) string {
|
|
t.Helper()
|
|
u.addr = "127.0.0.1:" + uiTestPort
|
|
prev := consoleLaneServer.Load()
|
|
consoleLaneServer.Store(u)
|
|
t.Cleanup(func() { consoleLaneServer.Store(prev) })
|
|
link, err := consoleLaneEntry(uid, "sam")
|
|
if err != nil {
|
|
t.Fatalf("the owner's lane refused its owner: %v", err)
|
|
}
|
|
p, _ := url.Parse(link)
|
|
w := uiGet(t, u.handler(), "/?"+p.RawQuery, "")
|
|
if w.Code != http.StatusSeeOther {
|
|
t.Fatalf("redeeming the lane's ticket = %d: %s", w.Code, firstLines(w.Body.String()))
|
|
}
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == uiCookieName {
|
|
return uiCookieName + "=" + c.Value
|
|
}
|
|
}
|
|
t.Fatal("no session cookie")
|
|
return ""
|
|
}
|
|
|
|
func TestTheLaneOpensOnlyForTheOwnersAccount(t *testing.T) {
|
|
uiTestConfigDir(t)
|
|
if why := consoleLaneRefusal(1000, "sam"); !strings.Contains(why, "approvals-owner --account sam") {
|
|
t.Errorf("with no owner recorded the refusal does not say how to record one: %q", why)
|
|
}
|
|
laneOwner(t, 1000)
|
|
if why := consoleLaneRefusal(1000, "sam"); why != "" {
|
|
t.Errorf("the owner was refused: %q", why)
|
|
}
|
|
if why := consoleLaneRefusal(1001, "lee"); !strings.Contains(why, "only for its owner's account (sam)") {
|
|
t.Errorf("another account was let in, or told the wrong thing: %q", why)
|
|
}
|
|
if _, err := consoleLaneEntry(1001, "lee"); err == nil {
|
|
t.Error("a ticket was minted for an account that is not the owner's")
|
|
}
|
|
}
|
|
|
|
func TestTheOwnerAccountIsNotQuietlyHandedToAnother(t *testing.T) {
|
|
uiTestConfigDir(t)
|
|
laneOwner(t, 1000)
|
|
if err := cmdOwnerAccount("sam", false); err != nil {
|
|
t.Errorf("the same account again: %v", err)
|
|
}
|
|
lookupOwnerAccount = func(string) (*user.User, error) { return &user.User{Uid: "1001", Username: "lee"}, nil }
|
|
if err := cmdOwnerAccount("lee", false); err == nil || !strings.Contains(err.Error(), "stays so") {
|
|
t.Errorf("another account took the console: %v", err)
|
|
}
|
|
prev := ownerConsole
|
|
t.Cleanup(func() { ownerConsole = prev })
|
|
ownerConsole = func() bool { return false }
|
|
if err := cmdOwnerAccount("lee", true); err == nil || !strings.Contains(err.Error(), "only at a terminal") {
|
|
t.Errorf("--replace was taken from a pipe: %v", err)
|
|
}
|
|
if uid, _, _, _ := ownerAccountLoad(); uid != 1000 {
|
|
t.Fatalf("the owner changed to uid %d", uid)
|
|
}
|
|
lookupOwnerAccount = func(string) (*user.User, error) { return &user.User{Uid: "0", Username: "root"}, nil }
|
|
ownerConsole = func() bool { return true }
|
|
if err := cmdOwnerAccount("root", true); err == nil || !strings.Contains(err.Error(), "root administers") {
|
|
t.Errorf("root was recorded as the owner's account: %v", err)
|
|
}
|
|
}
|
|
|
|
// A limited session sees the console and does what takes the code or narrows
|
|
// reach. What widens reach with no code is the administrator's, and the page
|
|
// says so instead of offering it.
|
|
func TestALaneSessionIsRefusedWhatWidensReachWithNoCode(t *testing.T) {
|
|
u, admin := settingsWorld(t, true)
|
|
laneOwner(t, 1000)
|
|
lane := laneSignedIn(t, u, 1000)
|
|
h := u.handler()
|
|
|
|
for _, p := range []string{"/", "/setup", "/approvals", "/activity", "/machine"} {
|
|
if w := uiGet(t, h, p, lane); w.Code != http.StatusOK {
|
|
t.Errorf("GET %s from the lane = %d", p, w.Code)
|
|
}
|
|
}
|
|
for _, p := range []string{"/setup/pair", "/setup/stepup/start", "/setup/stepup/confirm", "/setup/stepup/key",
|
|
"/setup/folder/propose", "/setup/anchor/choose", "/device/approve", "/device/approve-shown"} {
|
|
w := uiPost(t, h, p, url.Values{"target": {"machine"}, "token": {"skb_x"}}, lane)
|
|
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "sudo -H privatae-bastion ui") {
|
|
t.Errorf("POST %s from the lane = %d %s", p, w.Code, firstLines(w.Body.String()))
|
|
}
|
|
}
|
|
if w := uiGet(t, h, "/setup/stepup/qr?e=x", lane); w.Code != http.StatusForbidden {
|
|
t.Errorf("the enrolment QR was served to the lane: %d", w.Code)
|
|
}
|
|
// The disk map, run as root, names files in every account's home. Its
|
|
// stand-in is installed, so a POST that got through would walk nothing:
|
|
// it blocks until the test ends.
|
|
dm := dmTestInstall(t, nil)
|
|
release := make(chan struct{})
|
|
t.Cleanup(func() { close(release) })
|
|
dm.scan = func(ctx context.Context, _ []string, _ int, _ func(dmProgress)) *dmResult {
|
|
<-release
|
|
return nil
|
|
}
|
|
for _, p := range []string{"/diskmap", "/fragments/diskmap"} {
|
|
if w := uiGet(t, h, p, lane); w.Code != http.StatusForbidden {
|
|
t.Errorf("GET %s from the lane = %d", p, w.Code)
|
|
}
|
|
}
|
|
if w := uiPost(t, h, "/diskmap/scan", url.Values{}, lane); w.Code != http.StatusForbidden {
|
|
t.Errorf("POST /diskmap/scan from the lane = %d", w.Code)
|
|
}
|
|
// Narrowing reach needs no code, from the lane too.
|
|
uiPost(t, h, "/setup/host/disconnect", url.Values{"host": {"member.host.privatae.ai"}, "confirm": {"1"}}, lane)
|
|
if cfgs, _ := loadPairings(); len(cfgs) != 0 {
|
|
t.Error("the lane could not disconnect a Host")
|
|
}
|
|
// The same routes still work for the administrator's own session.
|
|
if w := uiPost(t, h, "/setup/stepup/start", url.Values{"target": {"machine"}, "replace": {"1"}}, admin); w.Code != http.StatusOK {
|
|
t.Errorf("the administrator was refused: %d %s", w.Code, firstLines(w.Body.String()))
|
|
}
|
|
body := uiGet(t, h, "/setup", lane).Body.String()
|
|
if strings.Contains(body, `hx-post="/setup/pair"`) || strings.Contains(body, `hx-post="/setup/stepup/start"`) {
|
|
t.Error("Settings offers the lane a form it would refuse")
|
|
}
|
|
if !strings.Contains(body, "sudo -H privatae-bastion stepup-enroll --machine --rotate") {
|
|
t.Error("Settings does not tell the lane how the code is replaced")
|
|
}
|
|
if body := uiGet(t, h, "/setup", admin).Body.String(); !strings.Contains(body, `hx-post="/setup/pair"`) {
|
|
t.Error("Settings lost the pairing form for the administrator")
|
|
}
|
|
}
|
|
|
|
// The folder check stats whatever path is posted, as the daemon, which is root
|
|
// on a whole-machine install. The lane draws no folder form, so from the lane
|
|
// it could only be used to learn what exists where the person's account cannot
|
|
// look; it is the administrator's, like the proposal it serves.
|
|
func TestALaneSessionCannotProbePathsThroughTheFolderCheck(t *testing.T) {
|
|
u, admin := settingsWorld(t, true)
|
|
laneOwner(t, 1000)
|
|
lane := laneSignedIn(t, u, 1000)
|
|
h := u.handler()
|
|
dir := t.TempDir()
|
|
w := uiPost(t, h, "/setup/folder/check", url.Values{"path": {dir}}, lane)
|
|
if w.Code != http.StatusForbidden || strings.Contains(w.Body.String(), "A folder on this machine") {
|
|
t.Errorf("the lane's folder check = %d %s", w.Code, firstLines(w.Body.String()))
|
|
}
|
|
if w := uiPost(t, h, "/setup/folder/check", url.Values{"path": {dir}}, admin); w.Code != http.StatusOK ||
|
|
!strings.Contains(w.Body.String(), "A folder on this machine") {
|
|
t.Errorf("the administrator's folder check = %d %s", w.Code, firstLines(w.Body.String()))
|
|
}
|
|
}
|
|
|
|
// Through the lane the Host factor's seed is out of reach, so wrong codes
|
|
// typed for a Host's request are throttled there, and only there.
|
|
func TestALaneSessionsWrongHostCodesAreThrottled(t *testing.T) {
|
|
const host = "member.host.privatae.ai"
|
|
_, id := uiTestApprovalBox(t, host)
|
|
u := uiTestServer(t)
|
|
laneOwner(t, 1000)
|
|
lane := laneSignedIn(t, u, 1000)
|
|
h := u.handler()
|
|
var last string
|
|
for i := 0; i < pipeFreeFailures+1; i++ {
|
|
last = uiPost(t, h, "/approve", url.Values{"host": {host}, "id": {id}, "code": {"000000"}}, lane).Body.String()
|
|
}
|
|
if !strings.Contains(last, "wait") && !strings.Contains(last, "again in") {
|
|
t.Errorf("the lane typed %d wrong codes with no wait: %s", pipeFreeFailures+1, firstLines(last))
|
|
}
|
|
admin := uiSignedIn(t, u)
|
|
body := uiPost(t, h, "/approve", url.Values{"host": {host}, "id": {id}, "code": {"000000"}}, admin).Body.String()
|
|
if strings.Contains(body, "again in") {
|
|
t.Errorf("the administrator's page is throttled too: %s", firstLines(body))
|
|
}
|
|
}
|
|
|
|
// Links minted for the lane are capped, so an account asking in a loop cannot
|
|
// grow the root daemon's memory; one opened frees its place.
|
|
func TestTheLaneCapsTheLinksWaitingToBeOpened(t *testing.T) {
|
|
u := uiTestServer(t)
|
|
uiTestConfigDir(t)
|
|
laneOwner(t, 1000)
|
|
laneSignedIn(t, u, 1000) // sets the server for the lane; its link is used
|
|
for i := 0; i < consoleLaneTicketsMax; i++ {
|
|
if _, err := consoleLaneEntry(1000, "sam"); err != nil {
|
|
t.Fatalf("link %d refused: %v", i+1, err)
|
|
}
|
|
}
|
|
if _, err := consoleLaneEntry(1000, "sam"); err == nil || !strings.Contains(err.Error(), "too many") {
|
|
t.Fatalf("link %d was minted: %v", consoleLaneTicketsMax+1, err)
|
|
}
|
|
}
|