tinyrelayfile
tinyrelay/internal/daemon/blob_authorization_test.go/

internal/daemon/blob_authorization_test.go raw
1package daemon
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/hex"
8 "encoding/json"
9 "io"
10 "net"
11 "net/http"
12 "net/http/httptest"
13 "net/url"
14 "strconv"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/FelineStateMachine/tinyrelay/internal/blob"
20 "github.com/FelineStateMachine/tinyrelay/internal/event"
21 "github.com/FelineStateMachine/tinyrelay/internal/sites"
22)
23
24// agentUpload sends one Blossom PUT /upload signed by secret through the
25// tenant and returns the response.
26func agentUpload(t *testing.T, tenant *Tenant, secret, body, typ string) *httptest.ResponseRecorder {
27 t.Helper()
28 sum := sha256.Sum256([]byte(body))
29 token := event.Event{Kind: 24242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"t", "upload"}, {"x", hex.EncodeToString(sum[:])}, {"expiration", strconv.FormatInt(time.Now().Unix()+60, 10)}}, Content: "Authorize file storage"}
30 if err := event.Sign(&token, secret); err != nil {
31 t.Fatal(err)
32 }
33 raw, err := json.Marshal(token)
34 if err != nil {
35 t.Fatal(err)
36 }
37 r := httptest.NewRequest(http.MethodPut, "http://relay.test/upload", strings.NewReader(body))
38 r.Header.Set("Authorization", "Nostr "+base64.RawURLEncoding.EncodeToString(raw))
39 r.Header.Set("Content-Type", typ)
40 w := httptest.NewRecorder()
41 tenant.ServeHTTP(w, r)
42 return w
43}
44
45// TestAgentGrantGovernsUploads covers the blob door for agent keys: an
46// active grant uploads as a member, a paused or revoked one is refused, a
47// sites ttl stamps the claim and the maintenance sweep retires it, and an
48// encrypted grant refuses plain files.
49func TestAgentGrantGovernsUploads(t *testing.T) {
50 _, tenant := testTenant(t)
51 ctx := context.Background()
52 owner := tenant.Policy().Owner
53 agent, _ := event.PublicKey(testAgentSecret)
54 if _, err := tenant.Execute(ctx, owner, "setpolicy", []json.RawMessage{json.RawMessage(`{"writes":"allowlist"}`)}); err != nil {
55 t.Fatal(err)
56 }
57 if w := agentUpload(t, tenant, testAgentSecret, "stranger", "text/plain"); w.Code == http.StatusOK {
58 t.Fatal("a key without a grant uploaded under allowlist writes")
59 }
60 now := time.Now().Unix()
61 own := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: agent})
62 if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"sites", own, "ttl=2"})); err != nil {
63 t.Fatal(err)
64 }
65 if w := agentUpload(t, tenant, testAgentSecret, "site file", "text/html"); w.Code != http.StatusOK {
66 t.Fatalf("granted agent upload = %d %s", w.Code, w.Body.String())
67 }
68 sum := sha256.Sum256([]byte("site file"))
69 hash := hex.EncodeToString(sum[:])
70 var expires int64
71 if err := tenant.store.DB().QueryRow("SELECT expires FROM blob_claims WHERE sha256=? AND uploader=?", hash, agent).Scan(&expires); err != nil || expires < now+2*86400 || expires > now+2*86400+60 {
72 t.Fatalf("claim expires=%d err=%v, want about %d", expires, err, now+2*86400)
73 }
74 if entries, err := tenant.blobs.ListMetadata(ctx, agent); err != nil || len(entries) != 1 {
75 t.Fatalf("agent list = %d entries, err=%v", len(entries), err)
76 }
77 if _, err := tenant.Execute(ctx, owner, "pauseagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {
78 t.Fatal(err)
79 }
80 if w := agentUpload(t, tenant, testAgentSecret, "while paused", "text/html"); w.Code == http.StatusOK || !strings.Contains(w.Body.String(), "paused") {
81 t.Fatalf("paused agent upload = %d %s", w.Code, w.Body.String())
82 }
83 if _, err := tenant.Execute(ctx, owner, "resumeagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {
84 t.Fatal(err)
85 }
86 if w := agentUpload(t, tenant, testAgentSecret, "after resume", "text/html"); w.Code != http.StatusOK {
87 t.Fatalf("resumed agent upload = %d %s", w.Code, w.Body.String())
88 }
89 if _, err := tenant.Execute(ctx, owner, "revokeagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {
90 t.Fatal(err)
91 }
92 if w := agentUpload(t, tenant, testAgentSecret, "after revoke", "text/html"); w.Code == http.StatusOK {
93 t.Fatalf("revoked agent upload = %d %s", w.Code, w.Body.String())
94 }
95 // The sweep removes the agent's files once the ttl lapses.
96 if err := tenant.sweepExpiredBlobs(ctx, now+2*86400+61); err != nil {
97 t.Fatal(err)
98 }
99 if _, _, err := tenant.blobs.Get(ctx, hash); err == nil {
100 t.Fatal("expired agent upload still stored")
101 }
102 // A fresh grant with the encrypted flag refuses plain uploads.
103 if err := publishAs(t, tenant, agentGrantEvent(t, agent, now+1, now+3600, []string{"sites", "*", "ttl=1", "encrypted"})); err != nil {
104 t.Fatal(err)
105 }
106 if w := agentUpload(t, tenant, testAgentSecret, "<html>plain</html>", "text/html"); w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "requires encrypted uploads") {
107 t.Fatalf("plain upload under encrypted grant = %d %s", w.Code, w.Body.String())
108 }
109 if w := agentUpload(t, tenant, testAgentSecret, "\x8f\x1a\x9c\x03"+strings.Repeat("\xe2\x71\x05\x9b\x4c", 20), "application/octet-stream"); w.Code != http.StatusOK {
110 t.Fatalf("ciphertext upload under encrypted grant = %d %s", w.Code, w.Body.String())
111 }
112}
113
114type blobOriginTransport func(*http.Request) (*http.Response, error)
115
116func (f blobOriginTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
117
118func TestBlobMirrorAuthenticationBindsRequestAndFetchedContent(t *testing.T) {
119 const content = "fetched encrypted bytes"
120 sum := sha256.Sum256([]byte(content))
121 hash := hex.EncodeToString(sum[:])
122 for _, tc := range []struct {
123 name, mode, action, scope string
124 pathUpload, tamper bool
125 want int
126 }{
127 {name: "mirror NIP98", mode: "nip98", want: 201},
128 {name: "mirror NIP98 tampered JSON", mode: "nip98", tamper: true, want: 401},
129 {name: "mirror Blossom upload verb", action: "upload", scope: hash, want: 201},
130 {name: "mirror Blossom wrong verb", action: "mirror", scope: hash, want: 401},
131 {name: "mirror Blossom wrong content", action: "upload", scope: strings.Repeat("a", 64), want: 401},
132 {name: "remote NIP98", mode: "nip98", pathUpload: true, want: 201},
133 {name: "remote Blossom", action: "upload", scope: hash, pathUpload: true, want: 201},
134 } {
135 t.Run(tc.name, func(t *testing.T) {
136 _, tenant := testTenant(t)
137 fetches := 0
138 service, err := blob.New(context.Background(), blob.Config{
139 Root: t.TempDir(), Store: tenant.store, PublicURL: tenant.publicURL,
140 Authorize: tenant.authorizeBlob, ValidateUpload: tenant.validateBlobUpload,
141 ResolveIP: func(context.Context, string) ([]net.IP, error) { return []net.IP{net.ParseIP("8.8.8.8")}, nil },
142 HTTPClient: &http.Client{Transport: blobOriginTransport(func(*http.Request) (*http.Response, error) {
143 fetches++
144 return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/octet-stream"}}, Body: io.NopCloser(strings.NewReader(content))}, nil
145 })},
146 })
147 if err != nil {
148 t.Fatal(err)
149 }
150 body := `{"url":"https://files.example/source"}`
151 target := "http://relay.test/mirror"
152 if tc.pathUpload {
153 body = ""
154 target = "http://relay.test/" + hash + "?url=" + url.QueryEscape("https://files.example/source")
155 }
156 r := httptest.NewRequest(http.MethodPut, target, strings.NewReader(body))
157 if tc.mode == "nip98" {
158 signRequest(t, r, body)
159 } else {
160 token := event.Event{Kind: 24242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"t", tc.action}, {"x", tc.scope}, {"expiration", strconv.FormatInt(time.Now().Unix()+60, 10)}}, Content: "Authorize file storage"}
161 if err := event.Sign(&token, strings.Repeat("0", 63)+"1"); err != nil {
162 t.Fatal(err)
163 }
164 raw, err := json.Marshal(token)
165 if err != nil {
166 t.Fatal(err)
167 }
168 r.Header.Set("Authorization", "Nostr "+base64.RawURLEncoding.EncodeToString(raw))
169 }
170 if tc.tamper {
171 r.Body = io.NopCloser(strings.NewReader(`{"url":"https://files.example/altered"}`))
172 }
173 w := httptest.NewRecorder()
174 service.Handler().ServeHTTP(w, r)
175 if w.Code != tc.want {
176 t.Fatalf("status=%d, want %d: %s", w.Code, tc.want, w.Body.String())
177 }
178 if (tc.tamper || tc.action == "mirror") && fetches != 0 {
179 t.Fatal("unauthorized request fetched an origin")
180 }
181 })
182 }
183}
184
185func TestUploadURLQueryCannotBypassNIP98PayloadValidation(t *testing.T) {
186 _, tenant := testTenant(t)
187 r := httptest.NewRequest(http.MethodPut, "http://relay.test/upload?url=https://files.example/ignored", strings.NewReader("tampered body"))
188 signRequest(t, r, "signed body")
189 w := httptest.NewRecorder()
190 tenant.ServeHTTP(w, r)
191 if w.Code != http.StatusUnauthorized {
192 t.Fatalf("tampered upload status=%d: %s", w.Code, w.Body.String())
193 }
194}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs