226 lines
7.9 KiB
Go
226 lines
7.9 KiB
Go
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)
|
|
}
|
|
}
|