package httpapi_test import ( "context" "encoding/json" "io" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" "rmm-openwrt/server/internal/httpapi" "rmm-openwrt/server/internal/model" "rmm-openwrt/server/internal/store" ) func TestMultiUserEnrollmentAndDeviceIsolation(t *testing.T) { st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "security.db")) if err != nil { t.Fatal(err) } defer st.Close() srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{ OperatorUsername: "admin", OperatorPassword: "correct-horse-battery-staple", DeviceDomain: "routers.example.test", PublicScheme: "https", })) defer srv.Close() admin := authenticatedClient(t, srv.URL, "admin", "correct-horse-battery-staple") var aliceUser model.User authRequestJSON(t, admin, http.MethodPost, srv.URL+"/api/users", map[string]any{ "username": "alice", "password": "alice-password-long", "role": "user", }, http.StatusCreated, &aliceUser) var bobUser model.User authRequestJSON(t, admin, http.MethodPost, srv.URL+"/api/users", map[string]any{ "username": "bob", "password": "bob-password-long", "role": "user", }, http.StatusCreated, &bobUser) alice := authenticatedClient(t, srv.URL, "alice", "alice-password-long") bob := authenticatedClient(t, srv.URL, "bob", "bob-password-long") authRequestJSON(t, alice, http.MethodGet, srv.URL+"/api/users", nil, http.StatusForbidden, nil) aliceDevice := enrollForClient(t, srv.URL, alice, "alice-router", "alice-edge") bobDevice := enrollForClient(t, srv.URL, bob, "bob-router", "bob-edge") var aliceDevices struct { Devices []model.Device `json:"devices"` } authRequestJSON(t, alice, http.MethodGet, srv.URL+"/api/devices", nil, http.StatusOK, &aliceDevices) if len(aliceDevices.Devices) != 1 || aliceDevices.Devices[0].ID != aliceDevice.DeviceID { t.Fatalf("alice received devices outside her account: %#v", aliceDevices.Devices) } if aliceDevices.Devices[0].DomainName != "alice-edge.routers.example.test" { t.Fatalf("unexpected device domain: %q", aliceDevices.Devices[0].DomainName) } authRequestJSON(t, alice, http.MethodGet, srv.URL+"/api/devices/"+bobDevice.DeviceID, nil, http.StatusNotFound, nil) authRequestJSON(t, bob, http.MethodPost, srv.URL+"/api/devices/bulk-commands", map[string]any{ "device_ids": []string{aliceDevice.DeviceID}, "type": "ping", "args": map[string]any{"target": "1.1.1.1"}, }, http.StatusNotFound, nil) req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/enrollment-grants", jsonBody(map[string]any{})) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "https://evil.example") copyClientCookies(req, alice, srv.URL) resp, err := alice.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("expected cross-origin cookie request to be forbidden, got %d", resp.StatusCode) } authRequestJSON(t, admin, http.MethodPatch, srv.URL+"/api/users/"+bobUser.ID, map[string]any{ "disabled": true, }, http.StatusOK, nil) authRequestJSON(t, bob, http.MethodGet, srv.URL+"/api/devices", nil, http.StatusUnauthorized, nil) } func TestDeviceDomainUsesOneTimeLuCIAccessGrant(t *testing.T) { st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "luci-security.db")) if err != nil { t.Fatal(err) } defer st.Close() srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{ OperatorUsername: "admin", OperatorPassword: "correct-horse-battery-staple", DeviceDomain: "routers.example.test", PublicScheme: "https", })) defer srv.Close() admin := authenticatedClient(t, srv.URL, "admin", "correct-horse-battery-staple") device := enrollForClient(t, srv.URL, admin, "office-router", "office") remote, found, err := st.CreateRemoteSession(context.Background(), model.RemoteSession{ DeviceID: device.DeviceID, Target: "luci", Status: "active", LuCIPort: 22101, LuCIScheme: "http", ExpiresAt: time.Now().UTC().Add(time.Hour), }) if err != nil || !found { t.Fatalf("create remote session: found=%v err=%v", found, err) } var access struct { URL string `json:"url"` } authRequestJSON(t, admin, http.MethodPost, srv.URL+"/api/devices/"+device.DeviceID+"/remote-sessions/"+remote.ID+"/access", nil, http.StatusCreated, &access) parsed, err := url.Parse(access.URL) if err != nil { t.Fatal(err) } if parsed.Host != "office.routers.example.test" || parsed.Query().Get("token") == "" { t.Fatalf("unexpected access URL: %s", access.URL) } noRedirect := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} consume := func(host string) *http.Response { t.Helper() req, err := http.NewRequest(http.MethodGet, srv.URL+parsed.RequestURI(), nil) if err != nil { t.Fatal(err) } req.Host = host resp, err := noRedirect.Do(req) if err != nil { t.Fatal(err) } return resp } wrongHost := consume("wrong.routers.example.test") wrongHost.Body.Close() if wrongHost.StatusCode != http.StatusUnauthorized { t.Fatalf("expected wrong device host to be rejected, got %d", wrongHost.StatusCode) } valid := consume(parsed.Host) defer valid.Body.Close() if valid.StatusCode != http.StatusSeeOther || valid.Header.Get("Location") != "/cgi-bin/luci/" { body, _ := io.ReadAll(valid.Body) t.Fatalf("expected one-time access redirect, got %d %q: %s", valid.StatusCode, valid.Header.Get("Location"), body) } var deviceCookie *http.Cookie for _, cookie := range valid.Cookies() { if cookie.Name == "rmm_device_access" { deviceCookie = cookie } } if deviceCookie == nil || deviceCookie.Domain != "" || !deviceCookie.HttpOnly || deviceCookie.SameSite != http.SameSiteStrictMode { t.Fatalf("unexpected device access cookie: %#v", deviceCookie) } if route, ok, err := st.AuthorizeDeviceAccessSession(context.Background(), store.TokenHash(deviceCookie.Value), "office"); err != nil || !ok || route.DeviceID != device.DeviceID { t.Fatalf("device access session was not persisted: route=%#v ok=%v err=%v", route, ok, err) } reused := consume(parsed.Host) reused.Body.Close() if reused.StatusCode != http.StatusUnauthorized { t.Fatalf("expected reused access grant to be rejected, got %d", reused.StatusCode) } } type enrolledCredentials struct { DeviceID string `json:"device_id"` DeviceToken string `json:"device_token"` } func authenticatedClient(t *testing.T, baseURL, username, password string) *http.Client { t.Helper() jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) } client := &http.Client{Jar: jar} authRequestJSON(t, client, http.MethodPost, baseURL+"/api/auth/login", map[string]any{ "username": username, "password": password, }, http.StatusOK, nil) return client } func enrollForClient(t *testing.T, baseURL string, client *http.Client, hostname, dnsLabel string) enrolledCredentials { t.Helper() var grant struct { EnrollmentToken string `json:"enrollment_token"` } authRequestJSON(t, client, http.MethodPost, baseURL+"/api/enrollment-grants", map[string]any{ "dns_label": dnsLabel, }, http.StatusCreated, &grant) var enrolled enrolledCredentials requestJSON(t, http.MethodPost, baseURL+"/api/agent/enroll", "", map[string]any{ "enrollment_token": grant.EnrollmentToken, "hostname": hostname, "openwrt_version": "OpenWrt test", }, http.StatusCreated, &enrolled) requestJSON(t, http.MethodPost, baseURL+"/api/agent/enroll", "", map[string]any{ "enrollment_token": grant.EnrollmentToken, "hostname": hostname, "openwrt_version": "OpenWrt test", }, http.StatusUnauthorized, nil) return enrolled } func jsonBody(value any) io.Reader { data, _ := json.Marshal(value) return strings.NewReader(string(data)) } func copyClientCookies(req *http.Request, client *http.Client, rawURL string) { parsed, _ := url.Parse(rawURL) for _, cookie := range client.Jar.Cookies(parsed) { req.AddCookie(cookie) } }