Skip to content

Commit e1f45e8

Browse files
committed
Add metadata key validation and dot-segment path escaping
1 parent 94eda94 commit e1f45e8

File tree

3 files changed

+173
-3
lines changed

3 files changed

+173
-3
lines changed

metadata/service.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"io"
99
"net/http"
10+
"net/url"
1011

1112
"github.com/uploadcare/uploadcare-go/v2/internal/config"
1213
"github.com/uploadcare/uploadcare-go/v2/internal/svc"
@@ -57,10 +58,13 @@ func (s service) Get(
5758
ctx context.Context,
5859
fileUUID, key string,
5960
) (data string, err error) {
61+
if err = validateKey(key); err != nil {
62+
return
63+
}
6064
err = s.svc.ResourceOp(
6165
ctx,
6266
http.MethodGet,
63-
fmt.Sprintf("/files/%s/metadata/%s/", fileUUID, key),
67+
metadataKeyPath(fileUUID, key),
6468
nil,
6569
&data,
6670
)
@@ -72,10 +76,13 @@ func (s service) Set(
7276
ctx context.Context,
7377
fileUUID, key, value string,
7478
) (data string, err error) {
79+
if err = validateKey(key); err != nil {
80+
return
81+
}
7582
err = s.svc.ResourceOp(
7683
ctx,
7784
http.MethodPut,
78-
fmt.Sprintf("/files/%s/metadata/%s/", fileUUID, key),
85+
metadataKeyPath(fileUUID, key),
7986
stringBody(value),
8087
&data,
8188
)
@@ -87,10 +94,13 @@ func (s service) Delete(
8794
ctx context.Context,
8895
fileUUID, key string,
8996
) (err error) {
97+
if err = validateKey(key); err != nil {
98+
return
99+
}
90100
err = s.svc.ResourceOp(
91101
ctx,
92102
http.MethodDelete,
93-
fmt.Sprintf("/files/%s/metadata/%s/", fileUUID, key),
103+
metadataKeyPath(fileUUID, key),
94104
nil,
95105
nil,
96106
)
@@ -112,3 +122,22 @@ func (s stringBody) EncodeReq(req *http.Request) error {
112122
req.ContentLength = int64(buf.Len())
113123
return nil
114124
}
125+
126+
func metadataKeyPath(fileUUID, key string) string {
127+
return fmt.Sprintf(
128+
"/files/%s/metadata/%s/",
129+
fileUUID,
130+
escapeKeyPathSegment(key),
131+
)
132+
}
133+
134+
func escapeKeyPathSegment(key string) string {
135+
switch key {
136+
case ".":
137+
return "%2E"
138+
case "..":
139+
return "%2E%2E"
140+
default:
141+
return url.PathEscape(key)
142+
}
143+
}

metadata/service_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http"
99
"net/http/httptest"
1010
"reflect"
11+
"strings"
1112
"testing"
1213

1314
"github.com/stretchr/testify/assert"
@@ -155,3 +156,123 @@ func TestGet_NotFound(t *testing.T) {
155156
assert.Error(t, err)
156157
assert.Contains(t, err.Error(), "404")
157158
}
159+
160+
func TestKeyValidation(t *testing.T) {
161+
t.Parallel()
162+
163+
tests := []struct {
164+
name string
165+
call func(Service) error
166+
}{
167+
{
168+
name: "get rejects slash",
169+
call: func(svc Service) error {
170+
_, err := svc.Get(context.Background(), "test-uuid", "a/b")
171+
return err
172+
},
173+
},
174+
{
175+
name: "set rejects empty",
176+
call: func(svc Service) error {
177+
_, err := svc.Set(context.Background(), "test-uuid", "", "value")
178+
return err
179+
},
180+
},
181+
{
182+
name: "delete rejects too long",
183+
call: func(svc Service) error {
184+
err := svc.Delete(context.Background(), "test-uuid", strings.Repeat("a", 65))
185+
return err
186+
},
187+
},
188+
}
189+
190+
for _, tt := range tests {
191+
t.Run(tt.name, func(t *testing.T) {
192+
t.Parallel()
193+
194+
svc, srv := newTestService(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
195+
t.Fatalf("unexpected request: %s %s", r.Method, r.RequestURI)
196+
}))
197+
defer srv.Close()
198+
199+
err := tt.call(svc)
200+
assert.ErrorIs(t, err, ErrInvalidKey)
201+
})
202+
}
203+
}
204+
205+
func TestDotSegmentKeysAreEscaped(t *testing.T) {
206+
t.Parallel()
207+
208+
tests := []struct {
209+
name string
210+
method string
211+
key string
212+
wantURI string
213+
wantBody string
214+
call func(Service, string) error
215+
statusCode int
216+
}{
217+
{
218+
name: "get dot key",
219+
method: http.MethodGet,
220+
key: ".",
221+
wantURI: "/files/test-uuid/metadata/%2E/",
222+
statusCode: http.StatusOK,
223+
call: func(svc Service, key string) error {
224+
_, err := svc.Get(context.Background(), "test-uuid", key)
225+
return err
226+
},
227+
},
228+
{
229+
name: "set dotdot key",
230+
method: http.MethodPut,
231+
key: "..",
232+
wantURI: "/files/test-uuid/metadata/%2E%2E/",
233+
wantBody: `"value"`,
234+
statusCode: http.StatusOK,
235+
call: func(svc Service, key string) error {
236+
_, err := svc.Set(context.Background(), "test-uuid", key, "value")
237+
return err
238+
},
239+
},
240+
{
241+
name: "delete dot key",
242+
method: http.MethodDelete,
243+
key: ".",
244+
wantURI: "/files/test-uuid/metadata/%2E/",
245+
statusCode: http.StatusNoContent,
246+
call: func(svc Service, key string) error {
247+
return svc.Delete(context.Background(), "test-uuid", key)
248+
},
249+
},
250+
}
251+
252+
for _, tt := range tests {
253+
t.Run(tt.name, func(t *testing.T) {
254+
t.Parallel()
255+
256+
svc, srv := newTestService(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
257+
assert.Equal(t, tt.method, r.Method)
258+
assert.Equal(t, tt.wantURI, r.RequestURI)
259+
260+
if tt.wantBody != "" {
261+
body, err := io.ReadAll(r.Body)
262+
assert.NoError(t, err)
263+
assert.Equal(t, tt.wantBody, string(body))
264+
}
265+
266+
w.Header().Set("Content-Type", "application/json")
267+
w.WriteHeader(tt.statusCode)
268+
if tt.statusCode != http.StatusNoContent {
269+
json.NewEncoder(w).Encode("ok")
270+
}
271+
}))
272+
defer srv.Close()
273+
274+
err := tt.call(svc, tt.key)
275+
assert.NoError(t, err)
276+
})
277+
}
278+
}

metadata/validation.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package metadata
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"regexp"
7+
)
8+
9+
var (
10+
keyPattern = regexp.MustCompile(`^[-_.:A-Za-z0-9]{1,64}$`)
11+
// ErrInvalidKey reports metadata keys that do not match the documented format.
12+
ErrInvalidKey = errors.New("metadata key must match ^[-_.:A-Za-z0-9]{1,64}$")
13+
)
14+
15+
func validateKey(key string) error {
16+
if !keyPattern.MatchString(key) {
17+
return fmt.Errorf("%w: %q", ErrInvalidKey, key)
18+
}
19+
return nil
20+
}

0 commit comments

Comments
 (0)