tinyrelayfile
tinyrelay/internal/daemon/room_attachment_security_test.go/

internal/daemon/room_attachment_security_test.go raw
1package daemon
2
3import (
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
18func 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
32func 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
47func 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
113func 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
144func 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
183func 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs