| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/base64" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 16 | ) |
| 17 | |
| 18 | func roomAttachmentAuth(t *testing.T, secret, rawURL, method, body string) string { |
| 19 | t.Helper() |
| 20 | hash := sha256.Sum256([]byte(body)) |
| 21 | e := event.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Tags: [][]string{{"u" , rawURL}, {"method" , method}, {"payload" , hex.EncodeToString(hash[:])}}} |
| 22 | if err := event.Sign(&e, secret); err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | raw, err := json.Marshal(e) |
| 26 | if err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | return "Nostr " + base64.RawURLEncoding.EncodeToString(raw) |
| 30 | } |
| 31 | |
| 32 | func roomAttachmentBlossomAuth(t *testing.T, secret, action, hash string) string { |
| 33 | t.Helper() |
| 34 | e := event.Event{Kind: 24242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"t" , action}, {"x" , hash}, {"expiration" , strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10)}}} |
| 35 | if err := event.Sign(&e, secret); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | raw, err := json.Marshal(e) |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | // The daemon's authorization parser uses the Nostr scheme for both NIP-98 |
| 43 | // and kind-24242 Blossom proofs; the event kind selects the verifier. |
| 44 | return "Nostr " + base64.RawURLEncoding.EncodeToString(raw) |
| 45 | } |
| 46 | |
| 47 | func TestRoomAttachmentUploadAndPrivateReadDoors(t *testing.T) { |
| 48 | h := newRoomHarness(t) |
| 49 | h.must("alice" , event.KIND_CREATE_GROUP, [][]string{{"h" , "secret" }, {"closed" }}, "" ) |
| 50 | body := "private attachment" |
| 51 | path := "/rooms/secret/attachments?filename=secret.txt" |
| 52 | req := httptest.NewRequest(http.MethodPut, "http://relay.test" +path, strings.NewReader(body)) |
| 53 | req.Header.Set("Authorization" , roomAttachmentAuth(t, h.secrets["alice" ], req.URL.String(), req.Method, body)) |
| 54 | req.Header.Set("Content-Type" , "text/plain" ) |
| 55 | res := httptest.NewRecorder() |
| 56 | h.tenant.ServeHTTP(res, req) |
| 57 | if res.Code != http.StatusOK { |
| 58 | t.Fatalf("member upload = %d: %s" , res.Code, res.Body.String()) |
| 59 | } |
| 60 | var uploaded struct { |
| 61 | Hash string `json:"sha256"` |
| 62 | } |
| 63 | if err := json.Unmarshal(res.Body.Bytes(), &uploaded); err != nil || len(uploaded.Hash) != 64 { |
| 64 | t.Fatalf("upload response = %s: %v" , res.Body.String(), err) |
| 65 | } |
| 66 | |
| 67 | for _, door := range []string{"/media/" + uploaded.Hash, "/files/raw?hash=" + uploaded.Hash} { |
| 68 | t.Run("outsider " +door, func(t *testing.T) { |
| 69 | req := httptest.NewRequest(http.MethodGet, "http://relay.test" +door, nil) |
| 70 | req.Header.Set("Authorization" , roomAttachmentAuth(t, h.secrets["bob" ], req.URL.String(), req.Method, "" )) |
| 71 | got := httptest.NewRecorder() |
| 72 | h.tenant.ServeHTTP(got, req) |
| 73 | if got.Code != http.StatusForbidden && got.Code != http.StatusUnauthorized { |
| 74 | t.Fatalf("outsider read = %d: %s" , got.Code, got.Body.String()) |
| 75 | } |
| 76 | }) |
| 77 | t.Run("member " +door, func(t *testing.T) { |
| 78 | // Alice owns the closed room; a signed GET must be accepted. |
| 79 | req := httptest.NewRequest(http.MethodGet, "http://relay.test" +door, nil) |
| 80 | req.Header.Set("Authorization" , roomAttachmentAuth(t, h.secrets["alice" ], req.URL.String(), req.Method, "" )) |
| 81 | got := httptest.NewRecorder() |
| 82 | h.tenant.ServeHTTP(got, req) |
| 83 | if got.Code != http.StatusOK || got.Body.String() != body { |
| 84 | t.Fatalf("member read = %d %q" , got.Code, got.Body.String()) |
| 85 | } |
| 86 | }) |
| 87 | } |
| 88 | for _, door := range []string{"/" + uploaded.Hash + ".txt" , "/media/" + uploaded.Hash + ".txt" } { |
| 89 | t.Run("blossom outsider " +door, func(t *testing.T) { |
| 90 | req := httptest.NewRequest(http.MethodGet, "http://relay.test" +door, nil) |
| 91 | req.Header.Set("Authorization" , roomAttachmentBlossomAuth(t, h.secrets["bob" ], "get" , uploaded.Hash)) |
| 92 | got := httptest.NewRecorder() |
| 93 | h.tenant.ServeHTTP(got, req) |
| 94 | if got.Code != http.StatusForbidden && got.Code != http.StatusUnauthorized { |
| 95 | t.Fatalf("Blossom outsider read = %d: %s" , got.Code, got.Body.String()) |
| 96 | } |
| 97 | }) |
| 98 | } |
| 99 | for _, door := range []string{"/" + uploaded.Hash + ".txt" , "/media/" + uploaded.Hash + ".txt" } { |
| 100 | req := httptest.NewRequest(http.MethodGet, "http://relay.test" +door, nil) |
| 101 | req.Header.Set("Authorization" , roomAttachmentBlossomAuth(t, h.secrets["alice" ], "get" , uploaded.Hash)) |
| 102 | got := httptest.NewRecorder() |
| 103 | h.tenant.ServeHTTP(got, req) |
| 104 | if got.Code != http.StatusOK || got.Body.String() != body { |
| 105 | t.Fatalf("Blossom member read = %d %q" , got.Code, got.Body.String()) |
| 106 | } |
| 107 | } |
| 108 | if _, err := h.tenant.Execute(h.ctx, h.keys["bob" ], "browsefile" , []json.RawMessage{rawJSON(map[string]any{"hash" : uploaded.Hash})}); err == nil { |
| 109 | t.Fatal("outsider browsefile exposed private attachment" ) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | func TestRoomAttachmentMediaSandboxesActiveTypes(t *testing.T) { |
| 114 | h := newRoomHarness(t) |
| 115 | h.must("alice" , event.KIND_CREATE_GROUP, [][]string{{"h" , "secret" }, {"closed" }}, "" ) |
| 116 | body := "<html><script>document.body.dataset.pwned='yes'</script></html>" |
| 117 | req := httptest.NewRequest(http.MethodPut, "http://relay.test/rooms/secret/attachments?filename=x.html" , strings.NewReader(body)) |
| 118 | req.Header.Set("Authorization" , roomAttachmentAuth(t, h.secrets["alice" ], req.URL.String(), req.Method, body)) |
| 119 | req.Header.Set("Content-Type" , "text/html" ) |
| 120 | res := httptest.NewRecorder() |
| 121 | h.tenant.ServeHTTP(res, req) |
| 122 | if res.Code != http.StatusOK { |
| 123 | t.Fatalf("upload = %d: %s" , res.Code, res.Body.String()) |
| 124 | } |
| 125 | var uploaded struct { |
| 126 | Hash string `json:"sha256"` |
| 127 | } |
| 128 | _ = json.Unmarshal(res.Body.Bytes(), &uploaded) |
| 129 | get := httptest.NewRequest(http.MethodGet, "http://relay.test/media/" +uploaded.Hash, nil) |
| 130 | get.Header.Set("Authorization" , roomAttachmentAuth(t, h.secrets["alice" ], get.URL.String(), get.Method, "" )) |
| 131 | got := httptest.NewRecorder() |
| 132 | h.tenant.ServeHTTP(got, get) |
| 133 | if got.Code != http.StatusOK { |
| 134 | t.Fatalf("media read = %d: %s" , got.Code, got.Body.String()) |
| 135 | } |
| 136 | if got.Header().Get("X-Content-Type-Options" ) != "nosniff" || !strings.Contains(got.Header().Get("Content-Security-Policy" ), "sandbox" ) { |
| 137 | t.Fatalf("active media lacks isolation: nosniff=%q csp=%q" , got.Header().Get("X-Content-Type-Options" ), got.Header().Get("Content-Security-Policy" )) |
| 138 | } |
| 139 | if got.Header().Get("Content-Type" ) == "text/html" { |
| 140 | t.Fatal("HTML attachment served as active text/html" ) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func TestRoomAttachmentScopeDiesWithMembershipAndRoomGeneration(t *testing.T) { |
| 145 | h := newRoomHarness(t) |
| 146 | h.must("alice" , event.KIND_CREATE_GROUP, [][]string{{"h" , "removed" }, {"closed" }}, "" ) |
| 147 | h.must("alice" , event.KIND_PUT_USER, [][]string{{"h" , "removed" }, {"p" , h.keys["bob" ]}}, "" ) |
| 148 | removed, err := h.tenant.storeRoomAttachment(h.ctx, h.keys["bob" ], "removed" , roomAttachmentInput{Body: []byte("member bytes" ), Type: "text/plain" }) |
| 149 | if err != nil { |
| 150 | t.Fatalf("removed-member attachment = %v" , err) |
| 151 | } |
| 152 | h.must("alice" , event.KIND_REMOVE_USER, [][]string{{"h" , "removed" }, {"p" , h.keys["bob" ]}}, "" ) |
| 153 | if err := h.tenant.roomAttachmentAccess(h.ctx, h.keys["bob" ], removed["sha256" ].(string)); err == nil { |
| 154 | t.Fatal("removed member retained attachment access" ) |
| 155 | } |
| 156 | |
| 157 | created := h.must("alice" , event.KIND_CREATE_GROUP, [][]string{{"h" , "reused" }, {"closed" }}, "" ) |
| 158 | body := []byte("old room bytes" ) |
| 159 | result, err := h.tenant.storeRoomAttachment(h.ctx, h.keys["alice" ], "reused" , roomAttachmentInput{Body: body, Type: "text/plain" , Filename: "old.txt" }) |
| 160 | if err != nil { |
| 161 | t.Fatalf("store attachment = %v" , err) |
| 162 | } |
| 163 | hash := result["sha256" ].(string) |
| 164 | if err := h.tenant.roomAttachmentAccess(h.ctx, h.keys["alice" ], hash); err != nil { |
| 165 | t.Fatalf("initial room access = %v" , err) |
| 166 | } |
| 167 | // Deleting the room must invalidate its attachment scope, even if the |
| 168 | // identifier is later reused for a newly created room. |
| 169 | h.must("alice" , event.KIND_DELETE_GROUP, [][]string{{"h" , "reused" }}, "" ) |
| 170 | h.projectRooms() |
| 171 | if err := h.tenant.roomAttachmentAccess(h.ctx, h.keys["alice" ], hash); err == nil { |
| 172 | t.Fatal("deleted room retained attachment access" ) |
| 173 | } |
| 174 | newRoom := h.must("alice" , event.KIND_CREATE_GROUP, [][]string{{"h" , "reused" }, {"closed" }, {"name" , "Reused again" }}, "" ) |
| 175 | if created.ID == newRoom.ID { |
| 176 | t.Fatal("room recreation did not produce a new event" ) |
| 177 | } |
| 178 | if err := h.tenant.roomAttachmentAccess(h.ctx, h.keys["alice" ], hash); err == nil { |
| 179 | t.Fatal("new room generation inherited old attachment" ) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | func TestRoomAttachmentHTTPRejectsChangedPayload(t *testing.T) { |
| 184 | h := newRoomHarness(t) |
| 185 | request := httptest.NewRequest(http.MethodPut, "http://relay.test/rooms/main/attachments" , strings.NewReader("changed bytes" )) |
| 186 | request.Header.Set("Authorization" , roomAttachmentAuth(t, h.secrets["owner" ], request.URL.String(), request.Method, "signed bytes" )) |
| 187 | request.Header.Set("Content-Type" , "text/plain" ) |
| 188 | response := httptest.NewRecorder() |
| 189 | h.tenant.ServeHTTP(response, request) |
| 190 | if response.Code != http.StatusUnauthorized { |
| 191 | t.Fatalf("changed payload: %d %s" , response.Code, response.Body.String()) |
| 192 | } |
| 193 | var count int |
| 194 | if err := h.tenant.store.DB().QueryRowContext(h.ctx, "SELECT COUNT(*) FROM room_attachments" ).Scan(&count); err != nil || count != 0 { |
| 195 | t.Fatalf("changed payload was stored: %d %v" , count, err) |
| 196 | } |
| 197 | } |