Skip to content

Commit 8cfe906

Browse files
jyxjjjcodex
andauthored
fix(webdav): honor conditional PUT requests (#3066)
* fix(webdav): honor conditional PUT requests - Reuse HTTP precondition checks for existing and missing upload targets - Support repeated entity-tag headers and reject empty If-Match lists - Preserve nanosecond precision in metadata-based ETags - Return 204 for overwrites and retain ETags in buffered PUT responses - Add regression tests for preconditions, ranges, and ETag changes Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> * fix(webdav): stabilize fallback ETags - Use second-precision modification times for reproducible fallback ETags - Cover ETag stability across cache refresh timestamp normalization Co-authored-by: Codex <267193182+codex@users.noreply.github.com> Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> --------- Signed-off-by: jyxjjj <16695261+jyxjjj@users.noreply.github.com> Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
1 parent f27748d commit 8cfe906

7 files changed

Lines changed: 462 additions & 264 deletions

File tree

‎internal/net/preconditions.go‎

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
package net
2+
3+
import (
4+
"net/http"
5+
"net/textproto"
6+
"strings"
7+
"time"
8+
)
9+
10+
// scanETag determines if a syntactically valid ETag is present at s. If so,
11+
// the ETag and remaining text after consuming ETag is returned. Otherwise,
12+
// it returns "", "".
13+
func scanETag(s string) (etag string, remain string) {
14+
s = textproto.TrimString(s)
15+
start := 0
16+
if strings.HasPrefix(s, "W/") {
17+
start = 2
18+
}
19+
if len(s[start:]) < 2 || s[start] != '"' {
20+
return "", ""
21+
}
22+
// ETag is either W/"text" or "text".
23+
// See RFC 7232 2.3.
24+
for i := start + 1; i < len(s); i++ {
25+
c := s[i]
26+
switch {
27+
// Character values allowed in ETags.
28+
case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80:
29+
case c == '"':
30+
return s[:i+1], s[i+1:]
31+
default:
32+
return "", ""
33+
}
34+
}
35+
return "", ""
36+
}
37+
38+
// etagStrongMatch reports whether a and b match using strong ETag comparison.
39+
// Assumes a and b are valid ETags.
40+
func etagStrongMatch(a, b string) bool {
41+
return a == b && a != "" && a[0] == '"'
42+
}
43+
44+
// etagWeakMatch reports whether a and b match using weak ETag comparison.
45+
// Assumes a and b are valid ETags.
46+
func etagWeakMatch(a, b string) bool {
47+
return strings.TrimPrefix(a, "W/") == strings.TrimPrefix(b, "W/")
48+
}
49+
50+
// condResult is the result of an HTTP request precondition check.
51+
// See https://tools.ietf.org/html/rfc7232 section 3.
52+
type condResult int
53+
54+
const (
55+
condNone condResult = iota
56+
condTrue
57+
condFalse
58+
)
59+
60+
func checkIfMatch(w http.ResponseWriter, r *http.Request, exists bool) condResult {
61+
values := r.Header.Values("If-Match")
62+
if len(values) == 0 {
63+
return condNone
64+
}
65+
im := strings.Join(values, ",")
66+
r.Header.Del("If-Match")
67+
if !exists {
68+
return condFalse
69+
}
70+
for {
71+
im = textproto.TrimString(im)
72+
if len(im) == 0 {
73+
break
74+
}
75+
if im[0] == ',' {
76+
im = im[1:]
77+
continue
78+
}
79+
if im[0] == '*' {
80+
return condTrue
81+
}
82+
etag, remain := scanETag(im)
83+
if etag == "" {
84+
break
85+
}
86+
if etagStrongMatch(etag, w.Header().Get("Etag")) {
87+
return condTrue
88+
}
89+
im = remain
90+
}
91+
92+
return condFalse
93+
}
94+
95+
func checkIfUnmodifiedSince(r *http.Request, modtime time.Time) condResult {
96+
ius := r.Header.Get("If-Unmodified-Since")
97+
if ius == "" {
98+
return condNone
99+
}
100+
r.Header.Del("If-Unmodified-Since")
101+
if isZeroTime(modtime) {
102+
return condNone
103+
}
104+
t, err := http.ParseTime(ius)
105+
if err != nil {
106+
return condNone
107+
}
108+
109+
// The Last-Modified header truncates sub-second precision so
110+
// the modtime needs to be truncated too.
111+
modtime = modtime.Truncate(time.Second)
112+
if ret := modtime.Compare(t); ret <= 0 {
113+
return condTrue
114+
}
115+
return condFalse
116+
}
117+
118+
func checkIfNoneMatch(w http.ResponseWriter, r *http.Request, exists bool) condResult {
119+
values := r.Header.Values("If-None-Match")
120+
if len(values) == 0 {
121+
return condNone
122+
}
123+
inm := strings.Join(values, ",")
124+
r.Header.Del("If-None-Match")
125+
if !exists {
126+
return condTrue
127+
}
128+
buf := inm
129+
for {
130+
buf = textproto.TrimString(buf)
131+
if len(buf) == 0 {
132+
break
133+
}
134+
if buf[0] == ',' {
135+
buf = buf[1:]
136+
continue
137+
}
138+
if buf[0] == '*' {
139+
return condFalse
140+
}
141+
etag, remain := scanETag(buf)
142+
if etag == "" {
143+
break
144+
}
145+
if etagWeakMatch(etag, w.Header().Get("Etag")) {
146+
return condFalse
147+
}
148+
buf = remain
149+
}
150+
return condTrue
151+
}
152+
153+
func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult {
154+
if r.Method != "GET" && r.Method != "HEAD" {
155+
return condNone
156+
}
157+
ims := r.Header.Get("If-Modified-Since")
158+
if ims == "" {
159+
return condNone
160+
}
161+
r.Header.Del("If-Modified-Since")
162+
if isZeroTime(modtime) {
163+
return condNone
164+
}
165+
t, err := http.ParseTime(ims)
166+
if err != nil {
167+
return condNone
168+
}
169+
// The Last-Modified header truncates sub-second precision so
170+
// the modtime needs to be truncated too.
171+
modtime = modtime.Truncate(time.Second)
172+
if ret := modtime.Compare(t); ret <= 0 {
173+
return condFalse
174+
}
175+
return condTrue
176+
}
177+
178+
func checkIfRange(w http.ResponseWriter, r *http.Request, modtime time.Time) condResult {
179+
if r.Method != "GET" && r.Method != "HEAD" {
180+
return condNone
181+
}
182+
ir := r.Header.Get("If-Range")
183+
if ir == "" {
184+
return condNone
185+
}
186+
r.Header.Del("If-Range")
187+
etag, _ := scanETag(ir)
188+
if etag != "" {
189+
if etagStrongMatch(etag, w.Header().Get("Etag")) {
190+
return condTrue
191+
}
192+
return condFalse
193+
}
194+
// The If-Range value is typically the ETag value, but it may also be
195+
// the modtime date. See golang.org/issue/8367.
196+
if modtime.IsZero() {
197+
return condFalse
198+
}
199+
t, err := http.ParseTime(ir)
200+
if err != nil {
201+
return condFalse
202+
}
203+
if t.Unix() == modtime.Unix() {
204+
return condTrue
205+
}
206+
return condFalse
207+
}
208+
209+
var unixEpochTime = time.Unix(0, 0)
210+
211+
// isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0).
212+
func isZeroTime(t time.Time) bool {
213+
return t.IsZero() || t.Equal(unixEpochTime)
214+
}
215+
216+
func setLastModified(w http.ResponseWriter, modtime time.Time) {
217+
if !isZeroTime(modtime) {
218+
w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat))
219+
}
220+
}
221+
222+
func writeNotModified(w http.ResponseWriter) {
223+
// RFC 7232 section 4.1:
224+
// a sender SHOULD NOT generate representation metadata other than the
225+
// above listed fields unless said metadata exists for the purpose of
226+
// guiding cache updates (e.g., Last-Modified might be useful if the
227+
// response does not have an ETag field).
228+
h := w.Header()
229+
delete(h, "Content-Type")
230+
delete(h, "Content-Length")
231+
delete(h, "Content-Encoding")
232+
if h.Get("Etag") != "" {
233+
delete(h, "Last-Modified")
234+
}
235+
w.WriteHeader(http.StatusNotModified)
236+
}
237+
238+
// CheckPreconditions evaluates request preconditions and reports whether a precondition
239+
// resulted in sending StatusNotModified or StatusPreconditionFailed. The caller must
240+
// set the current ETag response header and indicate whether the representation exists.
241+
func CheckPreconditions(w http.ResponseWriter, r *http.Request, modtime time.Time, exists bool) (done bool, rangeHeader string) {
242+
// Evaluate preconditions in the order specified by RFC 9110 section 13.2.2.
243+
ch := checkIfMatch(w, r, exists)
244+
if ch == condNone {
245+
ch = checkIfUnmodifiedSince(r, modtime)
246+
}
247+
if ch == condFalse {
248+
w.WriteHeader(http.StatusPreconditionFailed)
249+
return true, ""
250+
}
251+
switch checkIfNoneMatch(w, r, exists) {
252+
case condFalse:
253+
if r.Method == "GET" || r.Method == "HEAD" {
254+
writeNotModified(w)
255+
return true, ""
256+
}
257+
w.WriteHeader(http.StatusPreconditionFailed)
258+
return true, ""
259+
case condNone:
260+
if checkIfModifiedSince(r, modtime) == condFalse {
261+
writeNotModified(w)
262+
return true, ""
263+
}
264+
}
265+
266+
rangeHeader = r.Header.Get("Range")
267+
if rangeHeader != "" && checkIfRange(w, r, modtime) == condFalse {
268+
rangeHeader = ""
269+
}
270+
return false, rangeHeader
271+
}

‎internal/net/preconditions_test.go‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package net
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestCheckPreconditions(t *testing.T) {
11+
modified := time.Date(2026, time.January, 2, 3, 4, 5, 123456789, time.UTC)
12+
date := modified.Format(http.TimeFormat)
13+
older := modified.Add(-time.Second).Format(http.TimeFormat)
14+
for _, tt := range []struct {
15+
name string
16+
method string
17+
exists bool
18+
etag string
19+
header http.Header
20+
status int
21+
}{
22+
{"unconditional create", "PUT", false, "", nil, 0},
23+
{"unconditional overwrite", "PUT", true, `"current"`, nil, 0},
24+
{"create only missing", "PUT", false, "", http.Header{"If-None-Match": {"*"}}, 0},
25+
{"create only existing", "PUT", true, `"current"`, http.Header{"If-None-Match": {"*"}}, 412},
26+
{"create only existing without etag", "PUT", true, "", http.Header{"If-None-Match": {"*"}}, 412},
27+
{"match missing", "PUT", false, "", http.Header{"If-Match": {"*"}}, 412},
28+
{"match existing", "PUT", true, `"current"`, http.Header{"If-Match": {"*"}}, 0},
29+
{"match existing without etag", "PUT", true, "", http.Header{"If-Match": {"*"}}, 0},
30+
{"match current", "PUT", true, `"current"`, http.Header{"If-Match": {`"current"`}}, 0},
31+
{"match stale", "PUT", true, `"current"`, http.Header{"If-Match": {`"stale"`}}, 412},
32+
{"match missing tag", "PUT", false, "", http.Header{"If-Match": {`"current"`}}, 412},
33+
{"match weak request", "PUT", true, `"current"`, http.Header{"If-Match": {`W/"current"`}}, 412},
34+
{"match weak representation", "PUT", true, `W/"current"`, http.Header{"If-Match": {`"current"`}}, 412},
35+
{"match list", "PUT", true, `"current"`, http.Header{"If-Match": {`"stale", "current"`}}, 0},
36+
{"match multiple lines", "PUT", true, `"current"`, http.Header{"If-Match": {`"stale"`, `"current"`}}, 0},
37+
{"match empty list members", "PUT", true, `"current"`, http.Header{"If-Match": {`, , "current",`}}, 0},
38+
{"match quoted comma", "PUT", true, `"one,two"`, http.Header{"If-Match": {`"stale", "one,two"`}}, 0},
39+
{"malformed match", "PUT", true, `"current"`, http.Header{"If-Match": {"current"}}, 412},
40+
{"empty match", "PUT", true, `"current"`, http.Header{"If-Match": {""}}, 412},
41+
{"none match current", "PUT", true, `"current"`, http.Header{"If-None-Match": {`"current"`}}, 412},
42+
{"none match stale", "PUT", true, `"current"`, http.Header{"If-None-Match": {`"stale"`}}, 0},
43+
{"none match missing tag", "PUT", false, "", http.Header{"If-None-Match": {`"current"`}}, 0},
44+
{"none match weak request", "PUT", true, `"current"`, http.Header{"If-None-Match": {`W/"current"`}}, 412},
45+
{"none match weak representation", "PUT", true, `W/"current"`, http.Header{"If-None-Match": {`"current"`}}, 412},
46+
{"none match multiple lines", "PUT", true, `"current"`, http.Header{"If-None-Match": {`"stale"`, `"current"`}}, 412},
47+
{"none match quoted comma", "PUT", true, `"one,two"`, http.Header{"If-None-Match": {`"stale", "one,two"`}}, 412},
48+
{"both conditions must pass", "PUT", true, `"current"`, http.Header{"If-Match": {`"current"`}, "If-None-Match": {`"current"`}}, 412},
49+
{"both conditions pass", "PUT", true, `"current"`, http.Header{"If-Match": {`"current"`}, "If-None-Match": {`"stale"`}}, 0},
50+
{"both wildcards missing", "PUT", false, "", http.Header{"If-Match": {"*"}, "If-None-Match": {"*"}}, 412},
51+
{"unmodified since older", "PUT", true, `"current"`, http.Header{"If-Unmodified-Since": {older}}, 412},
52+
{"unmodified since same second", "PUT", true, `"current"`, http.Header{"If-Unmodified-Since": {date}}, 0},
53+
{"unmodified since missing", "PUT", false, "", http.Header{"If-Unmodified-Since": {older}}, 0},
54+
{"invalid unmodified since", "PUT", true, `"current"`, http.Header{"If-Unmodified-Since": {"invalid"}}, 0},
55+
{"match overrides unmodified since", "PUT", true, `"current"`, http.Header{"If-Match": {`"current"`}, "If-Unmodified-Since": {older}}, 0},
56+
{"ignore modified since on put", "PUT", true, `"current"`, http.Header{"If-Modified-Since": {date}}, 0},
57+
{"get not modified", "GET", true, `"current"`, http.Header{"If-None-Match": {`W/"current"`}}, 304},
58+
{"head not modified", "HEAD", true, `"current"`, http.Header{"If-None-Match": {"*"}}, 304},
59+
{"get stale match", "GET", true, `"current"`, http.Header{"If-Match": {`"stale"`}}, 412},
60+
{"get modified since", "GET", true, `"current"`, http.Header{"If-Modified-Since": {date}}, 304},
61+
{"none match overrides modified since", "GET", true, `"current"`, http.Header{"If-None-Match": {`"stale"`}, "If-Modified-Since": {date}}, 0},
62+
{"empty none match overrides modified since", "GET", true, `"current"`, http.Header{"If-None-Match": {""}, "If-Modified-Since": {date}}, 0},
63+
} {
64+
t.Run(tt.name, func(t *testing.T) {
65+
r := httptest.NewRequest(tt.method, "/file.txt", nil)
66+
for name, values := range tt.header {
67+
for _, value := range values {
68+
r.Header.Add(name, value)
69+
}
70+
}
71+
w := httptest.NewRecorder()
72+
if tt.etag != "" {
73+
w.Header().Set("Etag", tt.etag)
74+
}
75+
var modTime time.Time
76+
if tt.exists {
77+
modTime = modified
78+
}
79+
done, _ := CheckPreconditions(w, r, modTime, tt.exists)
80+
if done != (tt.status != 0) {
81+
t.Fatalf("done = %v, want status %d", done, tt.status)
82+
}
83+
if done && w.Code != tt.status {
84+
t.Errorf("status = %d, want %d", w.Code, tt.status)
85+
}
86+
if w.Body.Len() != 0 {
87+
t.Errorf("unexpected response body: %q", w.Body.String())
88+
}
89+
})
90+
}
91+
}
92+
93+
func TestCheckPreconditionsIfRange(t *testing.T) {
94+
for _, tt := range []struct {
95+
etag string
96+
want string
97+
}{
98+
{`"current"`, "bytes=0-3"},
99+
{`"stale"`, ""},
100+
{`W/"current"`, ""},
101+
} {
102+
t.Run(tt.etag, func(t *testing.T) {
103+
r := httptest.NewRequest(http.MethodGet, "/file.txt", nil)
104+
r.Header.Set("Range", "bytes=0-3")
105+
r.Header.Set("If-Range", tt.etag)
106+
w := httptest.NewRecorder()
107+
w.Header().Set("Etag", `"current"`)
108+
done, got := CheckPreconditions(w, r, time.Time{}, true)
109+
if done || got != tt.want {
110+
t.Fatalf("CheckPreconditions() = (%v, %q), want (false, %q)", done, got, tt.want)
111+
}
112+
})
113+
}
114+
}

‎internal/net/serve.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import (
4949
// ServeHTTP uses it to handle requests using If-Match, If-None-Match, or If-Range.
5050
func ServeHTTP(w http.ResponseWriter, r *http.Request, name string, modTime time.Time, size int64, rangeReader model.RangeReaderIF) (err error) {
5151
setLastModified(w, modTime)
52-
done, rangeReq := checkPreconditions(w, r, modTime)
52+
done, rangeReq := CheckPreconditions(w, r, modTime, true)
5353
if done {
5454
return nil
5555
}

0 commit comments

Comments
 (0)