| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "io" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "net/url" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/FelineStateMachine/tinyrelay/tinyclient" |
| 14 | ) |
| 15 | |
| 16 | func TestStandaloneTinyclientAuthenticatesAgainstRealRelay(t *testing.T) { |
| 17 | _, tenant := testTenant(t) |
| 18 | upstream := httptest.NewServer(tenant) |
| 19 | defer upstream.Close() |
| 20 | handler, err := tinyclient.NewRemote(tinyclient.RemoteOptions{BackendURL: upstream.URL, PublicURL: "http://relay.test" }) |
| 21 | if err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | frontend := httptest.NewServer(handler) |
| 25 | defer frontend.Close() |
| 26 | frontendURL, _ := url.Parse(frontend.URL) |
| 27 | client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| 28 | issue := func(method, path, body string, cookie *http.Cookie, signed bool) (*http.Response, string) { |
| 29 | t.Helper() |
| 30 | request := httptest.NewRequest(method, "http://relay.test" +path, strings.NewReader(body)) |
| 31 | request.Header.Set("Content-Type" , "application/json" ) |
| 32 | request.Header.Set("Origin" , "http://relay.test" ) |
| 33 | if cookie != nil { |
| 34 | request.AddCookie(cookie) |
| 35 | } |
| 36 | if signed { |
| 37 | signRequest(t, request, body) |
| 38 | } |
| 39 | request.URL.Scheme, request.URL.Host = frontendURL.Scheme, frontendURL.Host |
| 40 | request.RequestURI = "" |
| 41 | response, err := client.Do(request) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | raw, err := io.ReadAll(response.Body) |
| 46 | response.Body.Close() |
| 47 | if err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | return response, string(raw) |
| 51 | } |
| 52 | login, body := issue("POST" , "/session" , "" , nil, true) |
| 53 | if login.StatusCode != 200 || len(login.Cookies()) != 1 { |
| 54 | t.Fatalf("signed login: %d %s" , login.StatusCode, body) |
| 55 | } |
| 56 | cookie := login.Cookies()[0] |
| 57 | if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode { |
| 58 | t.Fatalf("unsafe cookie: %+v" , cookie) |
| 59 | } |
| 60 | for _, path := range []string{"/" , "/account" , "/manage/rules" , "/chat" , "/scripts/bridge.js" , "/manifest.webmanifest" } { |
| 61 | response, body := issue("GET" , path, "" , cookie, false) |
| 62 | integrated := httptest.NewRecorder() |
| 63 | request := httptest.NewRequest("GET" , "http://relay.test" +path, nil) |
| 64 | request.AddCookie(cookie) |
| 65 | tenant.ServeHTTP(integrated, request) |
| 66 | if response.StatusCode != integrated.Code || body != integrated.Body.String() { |
| 67 | t.Fatalf("standalone parity %s: status=%d want=%d body lengths=%d/%d" , path, response.StatusCode, integrated.Code, len(body), integrated.Body.Len()) |
| 68 | } |
| 69 | } |
| 70 | denied, _ := issue("POST" , "/manage/rpc" , `{"method":"stats"}` , cookie, false) |
| 71 | if denied.StatusCode != 401 { |
| 72 | t.Fatalf("cookie-only write accepted: %d" , denied.StatusCode) |
| 73 | } |
| 74 | signed, body := issue("POST" , "/manage/rpc" , `{"method":"stats"}` , cookie, true) |
| 75 | if signed.StatusCode != 200 { |
| 76 | t.Fatalf("signed management request lost URL binding: %d %s" , signed.StatusCode, body) |
| 77 | } |
| 78 | // The new read bridge ignores claimed pubkeys and still requires a real session. |
| 79 | forged, body := issue("GET" , tinyclient.BackendPath+"?actor=" +tenant.Policy().Owner, "" , nil, false) |
| 80 | var snapshot struct { |
| 81 | Actor string `json:"actor"` |
| 82 | } |
| 83 | if forged.StatusCode != 200 || json.Unmarshal([]byte(body), &snapshot) != nil || snapshot.Actor != "" { |
| 84 | t.Fatalf("forged actor accepted: %d %s" , forged.StatusCode, body) |
| 85 | } |
| 86 | logout, body := issue("POST" , "/session/logout" , "" , cookie, false) |
| 87 | if logout.StatusCode != 200 { |
| 88 | t.Fatalf("logout: %d %s" , logout.StatusCode, body) |
| 89 | } |
| 90 | response, body := issue("GET" , "/account" , "" , cookie, false) |
| 91 | if response.StatusCode != 200 || !strings.Contains(body, "Sign in to open your account." ) { |
| 92 | t.Fatalf("revoked session survived: %d" , response.StatusCode) |
| 93 | } |
| 94 | p := tenant.Policy() |
| 95 | p.Features.Grasp, p.Features.Grasp08, p.Reads, p.Name = true, true, "members" , "private secret project" |
| 96 | if err := tenant.applyPolicy(context.Background(), p); err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | for _, path := range []string{"/" , tinyclient.BackendPath} { |
| 100 | response, body := issue("GET" , path, "" , cookie, false) |
| 101 | if response.StatusCode != 200 || strings.Contains(body, p.Name) || strings.Contains(body, p.Owner) { |
| 102 | t.Fatalf("private metadata leaked through %s: %d" , path, response.StatusCode) |
| 103 | } |
| 104 | } |
| 105 | response, _ = issue("GET" , "/social" , "" , cookie, false) |
| 106 | if response.StatusCode != 401 { |
| 107 | t.Fatalf("private read without membership: %d" , response.StatusCode) |
| 108 | } |
| 109 | } |