| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | func TestMemberFileAccessAcrossHTTPAndBrowseSurfaces(t *testing.T) { |
| 13 | h := newRoomHarness(t) |
| 14 | ctx := context.Background() |
| 15 | const body = "members-only file bytes" |
| 16 | request := httptest.NewRequest(http.MethodPut, "http://relay.test/upload?access=members&filename=private.txt" , strings.NewReader(body)) |
| 17 | request.Header.Set("Content-Type" , "text/plain" ) |
| 18 | signRequestWithSecret(t, request, body, h.secrets["alice" ]) |
| 19 | uploaded := httptest.NewRecorder() |
| 20 | h.tenant.ServeHTTP(uploaded, request) |
| 21 | if uploaded.Code != http.StatusOK { |
| 22 | t.Fatalf("member upload = %d: %s" , uploaded.Code, uploaded.Body.String()) |
| 23 | } |
| 24 | var descriptor struct { |
| 25 | Hash string `json:"sha256"` |
| 26 | } |
| 27 | if err := json.Unmarshal(uploaded.Body.Bytes(), &descriptor); err != nil || len(descriptor.Hash) != 64 { |
| 28 | t.Fatalf("upload descriptor = %s: %v" , uploaded.Body.String(), err) |
| 29 | } |
| 30 | |
| 31 | if rows, err := h.browse("bob" , "browsefiles" , map[string]any{"view" : "library" , "limit" : 50}); err != nil { |
| 32 | t.Fatalf("member library: %v" , err) |
| 33 | } else if !libraryContainsHash(rows, descriptor.Hash, "private.txt" ) { |
| 34 | t.Fatalf("member library omitted private file: %v" , rows) |
| 35 | } |
| 36 | if rows, err := h.browse("stranger" , "browsefiles" , map[string]any{"view" : "library" , "limit" : 50}); err != nil { |
| 37 | t.Fatalf("outsider library: %v" , err) |
| 38 | } else if libraryContainsHash(rows, descriptor.Hash, "private.txt" ) { |
| 39 | t.Fatalf("outsider library exposed private file: %v" , rows) |
| 40 | } |
| 41 | |
| 42 | for _, route := range []string{"/" + descriptor.Hash, "/nip96/" + descriptor.Hash, "/files/raw?hash=" + descriptor.Hash, "/media/" + descriptor.Hash} { |
| 43 | for _, method := range []string{http.MethodGet, http.MethodHead} { |
| 44 | for _, actor := range []string{"owner" , "bob" } { |
| 45 | t.Run(method+" " +actor+" " +route, func(t *testing.T) { |
| 46 | response := memberFileRequest(t, h, method, route, actor) |
| 47 | if response.Code != http.StatusOK { |
| 48 | t.Fatalf("%s response = %d: %s" , actor, response.Code, response.Body.String()) |
| 49 | } |
| 50 | if method == http.MethodGet && response.Body.String() != body { |
| 51 | t.Fatalf("%s body = %q" , actor, response.Body.String()) |
| 52 | } |
| 53 | }) |
| 54 | } |
| 55 | t.Run(method+" guest " +route, func(t *testing.T) { |
| 56 | response := memberFileRequest(t, h, method, route, "" ) |
| 57 | if response.Code != http.StatusUnauthorized && response.Code != http.StatusForbidden { |
| 58 | t.Fatalf("guest response = %d: %s" , response.Code, response.Body.String()) |
| 59 | } |
| 60 | }) |
| 61 | t.Run(method+" outsider " +route, func(t *testing.T) { |
| 62 | response := memberFileRequest(t, h, method, route, "stranger" ) |
| 63 | if response.Code != http.StatusForbidden { |
| 64 | t.Fatalf("outsider response = %d: %s" , response.Code, response.Body.String()) |
| 65 | } |
| 66 | }) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | for _, actor := range []string{"bob" , "stranger" } { |
| 71 | request := httptest.NewRequest(http.MethodGet, "http://relay.test/file?hash=" +descriptor.Hash, nil) |
| 72 | if actor != "" { |
| 73 | signRequestWithSecret(t, request, "" , h.secrets[actor]) |
| 74 | } |
| 75 | response := httptest.NewRecorder() |
| 76 | h.tenant.ServeHTTP(response, request) |
| 77 | page := response.Body.String() |
| 78 | if actor == "bob" { |
| 79 | if response.Code != http.StatusOK || !strings.Contains(page, "private.txt" ) { |
| 80 | t.Fatalf("member file page = %d: %s" , response.Code, page) |
| 81 | } |
| 82 | } else if strings.Contains(page, "private.txt" ) || strings.Contains(page, body) { |
| 83 | t.Fatalf("outsider file page leaked private data: %d: %s" , response.Code, page) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | result, err := h.tenant.Execute(ctx, h.keys["bob" ], "browsefile" , []json.RawMessage{rawJSON(map[string]any{"hash" : descriptor.Hash})}) |
| 88 | if err != nil || !strings.Contains(string(rawJSON(result)), "private.txt" ) { |
| 89 | t.Fatalf("member browsefile = %v, %v" , result, err) |
| 90 | } |
| 91 | if _, err := h.tenant.Execute(ctx, h.keys["stranger" ], "browsefile" , []json.RawMessage{rawJSON(map[string]any{"hash" : descriptor.Hash})}); err == nil { |
| 92 | t.Fatal("outsider browsefile exposed private file" ) |
| 93 | } |
| 94 | |
| 95 | if _, err := h.tenant.Execute(ctx, h.keys["owner" ], "removemember" , []json.RawMessage{rawJSON(h.keys["bob" ])}); err != nil { |
| 96 | t.Fatalf("remove member: %v" , err) |
| 97 | } |
| 98 | if response := memberFileRequest(t, h, http.MethodGet, "/files/raw?hash=" +descriptor.Hash+"&check=revoked" , "bob" ); response.Code != http.StatusForbidden { |
| 99 | t.Fatalf("revoked member response = %d: %s" , response.Code, response.Body.String()) |
| 100 | } |
| 101 | if _, err := h.tenant.Execute(ctx, h.keys["owner" ], "setmember" , []json.RawMessage{rawJSON(h.keys["bob" ]), rawJSON(map[string]any{"role" : "member" })}); err != nil { |
| 102 | t.Fatalf("restore member: %v" , err) |
| 103 | } |
| 104 | if _, err := h.tenant.Execute(ctx, h.keys["owner" ], "banpubkey" , []json.RawMessage{rawJSON(h.keys["bob" ]), rawJSON("test" )}); err != nil { |
| 105 | t.Fatalf("ban member: %v" , err) |
| 106 | } |
| 107 | if response := memberFileRequest(t, h, http.MethodGet, "/files/raw?hash=" +descriptor.Hash+"&check=banned" , "bob" ); response.Code != http.StatusForbidden { |
| 108 | t.Fatalf("banned member response = %d: %s" , response.Code, response.Body.String()) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func memberFileRequest(t *testing.T, h *roomHarness, method, route, actor string) *httptest.ResponseRecorder { |
| 113 | t.Helper() |
| 114 | request := httptest.NewRequest(method, "http://relay.test" +route, nil) |
| 115 | if actor != "" { |
| 116 | signRequestWithSecret(t, request, "" , h.secrets[actor]) |
| 117 | } |
| 118 | response := httptest.NewRecorder() |
| 119 | h.tenant.ServeHTTP(response, request) |
| 120 | return response |
| 121 | } |
| 122 | |
| 123 | func libraryContainsHash(rows map[string]any, hash, name string) bool { |
| 124 | items, ok := rows["items" ].([]any) |
| 125 | if !ok { |
| 126 | return false |
| 127 | } |
| 128 | for _, raw := range items { |
| 129 | item, ok := raw.(map[string]any) |
| 130 | if ok && item["sha256" ] == hash && item["name" ] == name { |
| 131 | return true |
| 132 | } |
| 133 | } |
| 134 | return false |
| 135 | } |