clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / internal / daemon / room_attachment_security_test.go
room_attachment_security_test.go 8.7K
1package daemon23import (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"1415 "github.com/FelineStateMachine/tinyrelay/internal/event"16)1718func 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}3132func 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-9843 // and kind-24242 Blossom proofs; the event kind selects the verifier.44 return "Nostr " + base64.RawURLEncoding.EncodeToString(raw)45}4647func 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 }6667 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}112113func 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}143144func 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 }156157 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 the168 // 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}182183func 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 int194 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}