From b408705f5b0c7e5ea976de420cf78162a964fcf7 Mon Sep 17 00:00:00 2001 From: 7y-9 Date: Mon, 8 Jun 2026 16:12:07 +0800 Subject: [PATCH] fix(s3api): accept HTTP-date conditionals (#9863) * fix(s3api): accept HTTP-date conditionals Problem: Object conditional headers rejected valid HTTP-date values in RFC850 or ANSIC format for If-Modified-Since and If-Unmodified-Since. Root cause: parseConditionalHeaders used time.Parse(time.RFC1123), accepting only one HTTP-date representation instead of the standard formats accepted by net/http.ParseTime. Fix: Parse conditional date headers with http.ParseTime so RFC1123, RFC850, and ANSIC HTTP-date forms are accepted. Reproduction: go test ./weed/s3api -run TestParseConditionalHeadersAcceptsHTTPDateFormats -count=1 failed before the fix with ErrInvalidRequest for RFC850 and ANSIC date values. Validation: env GOCACHE=/private/tmp/seaweedfs-go-cache go test ./weed/s3api -run TestParseConditionalHeadersAcceptsHTTPDateFormats -count=1; env GOCACHE=/private/tmp/seaweedfs-go-cache go test ./weed/s3api -count=1; git diff --check; git diff --cached --check * fix(s3api): accept HTTP-date copy-source conditionals Mirror the put-path http.ParseTime switch onto the copy-source If-Modified-Since / If-Unmodified-Since headers, which still rejected valid RFC850 and ANSIC dates. * fix(s3api): keep RFC1123 UTC-zone dates working alongside http.ParseTime http.ParseTime rejects the "UTC" zone that Go clients emit via t.UTC().Format(time.RFC1123), which the old RFC1123 parser accepted. Add a parseHTTPDate helper that tries http.ParseTime first and falls back to RFC1123, so the put and copy-source conditional date headers accept the union of HTTP-date formats plus the UTC zone. --------- Co-authored-by: Chris Lu --- weed/s3api/s3api_object_handlers_copy.go | 4 +- weed/s3api/s3api_object_handlers_put.go | 15 +++- weed/s3api/s3api_object_routed_write_test.go | 88 ++++++++++++++++++++ 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/weed/s3api/s3api_object_handlers_copy.go b/weed/s3api/s3api_object_handlers_copy.go index 2286f6171..610f00d0b 100644 --- a/weed/s3api/s3api_object_handlers_copy.go +++ b/weed/s3api/s3api_object_handlers_copy.go @@ -1447,7 +1447,7 @@ func (s3a *S3ApiServer) validateConditionalCopyHeaders(r *http.Request, entry *f // Check X-Amz-Copy-Source-If-Modified-Since if ifModifiedSince := r.Header.Get(s3_constants.AmzCopySourceIfModifiedSince); ifModifiedSince != "" { - t, err := time.Parse(time.RFC1123, ifModifiedSince) + t, err := parseHTTPDate(ifModifiedSince) if err != nil { glog.V(3).Infof("CopyObjectHandler: Invalid If-Modified-Since header: %v", err) return s3err.ErrInvalidRequest @@ -1460,7 +1460,7 @@ func (s3a *S3ApiServer) validateConditionalCopyHeaders(r *http.Request, entry *f // Check X-Amz-Copy-Source-If-Unmodified-Since if ifUnmodifiedSince := r.Header.Get(s3_constants.AmzCopySourceIfUnmodifiedSince); ifUnmodifiedSince != "" { - t, err := time.Parse(time.RFC1123, ifUnmodifiedSince) + t, err := parseHTTPDate(ifUnmodifiedSince) if err != nil { glog.V(3).Infof("CopyObjectHandler: Invalid If-Unmodified-Since header: %v", err) return s3err.ErrInvalidRequest diff --git a/weed/s3api/s3api_object_handlers_put.go b/weed/s3api/s3api_object_handlers_put.go index afbf54bbd..ca6b23696 100644 --- a/weed/s3api/s3api_object_handlers_put.go +++ b/weed/s3api/s3api_object_handlers_put.go @@ -1971,6 +1971,17 @@ type conditionalHeaders struct { isSet bool // true if any conditional headers are present } +// parseHTTPDate parses a conditional date header. It accepts the three HTTP-date +// formats required by RFC 9110 via http.ParseTime, then falls back to RFC1123 so +// the non-standard "UTC" zone that Go clients emit with t.UTC().Format(time.RFC1123) +// keeps working as it did before http.ParseTime was adopted. +func parseHTTPDate(value string) (time.Time, error) { + if t, err := http.ParseTime(value); err == nil { + return t, nil + } + return time.Parse(time.RFC1123, value) +} + // parseConditionalHeaders extracts and validates conditional headers from the request func parseConditionalHeaders(r *http.Request) (conditionalHeaders, s3err.ErrorCode) { headers := conditionalHeaders{ @@ -1992,7 +2003,7 @@ func parseConditionalHeaders(r *http.Request) (conditionalHeaders, s3err.ErrorCo // Parse date headers with validation var err error if ifModifiedSinceStr != "" { - headers.ifModifiedSince, err = time.Parse(time.RFC1123, ifModifiedSinceStr) + headers.ifModifiedSince, err = parseHTTPDate(ifModifiedSinceStr) if err != nil { glog.V(3).Infof("parseConditionalHeaders: Invalid If-Modified-Since format: %v", err) return headers, s3err.ErrInvalidRequest @@ -2000,7 +2011,7 @@ func parseConditionalHeaders(r *http.Request) (conditionalHeaders, s3err.ErrorCo } if ifUnmodifiedSinceStr != "" { - headers.ifUnmodifiedSince, err = time.Parse(time.RFC1123, ifUnmodifiedSinceStr) + headers.ifUnmodifiedSince, err = parseHTTPDate(ifUnmodifiedSinceStr) if err != nil { glog.V(3).Infof("parseConditionalHeaders: Invalid If-Unmodified-Since format: %v", err) return headers, s3err.ErrInvalidRequest diff --git a/weed/s3api/s3api_object_routed_write_test.go b/weed/s3api/s3api_object_routed_write_test.go index 1efc6078c..a3fc71181 100644 --- a/weed/s3api/s3api_object_routed_write_test.go +++ b/weed/s3api/s3api_object_routed_write_test.go @@ -3,9 +3,11 @@ package s3api import ( "net/http" "testing" + "time" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" ) func reqWith(headers map[string]string) *http.Request { @@ -101,6 +103,92 @@ func TestBuildWriteCondition(t *testing.T) { }) } +func TestParseConditionalHeadersAcceptsHTTPDateFormats(t *testing.T) { + testCases := []struct { + name string + header string + value string + expected time.Time + }{ + { + name: "If-Modified-Since RFC850", + header: s3_constants.IfModifiedSince, + value: "Sunday, 06-Nov-94 08:49:37 GMT", + expected: time.Date(1994, time.November, 6, 8, 49, 37, 0, time.UTC), + }, + { + name: "If-Unmodified-Since ANSIC", + header: s3_constants.IfUnmodifiedSince, + value: "Sun Nov 6 08:49:37 1994", + expected: time.Date(1994, time.November, 6, 8, 49, 37, 0, time.UTC), + }, + { + // Go clients build this with t.UTC().Format(time.RFC1123); the "UTC" + // zone is rejected by http.ParseTime but was accepted before, so the + // RFC1123 fallback must keep it working. + name: "If-Modified-Since RFC1123 UTC zone", + header: s3_constants.IfModifiedSince, + value: "Wed, 21 Oct 2015 07:28:00 UTC", + expected: time.Date(2015, time.October, 21, 7, 28, 0, 0, time.UTC), + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + r := reqWith(map[string]string{testCase.header: testCase.value}) + + headers, errCode := parseConditionalHeaders(r) + if errCode != s3err.ErrNone { + t.Fatalf("expected %s to be accepted, got %v", testCase.header, errCode) + } + if !headers.isSet { + t.Fatal("expected conditional headers to be marked set") + } + parsed := headers.ifModifiedSince + if testCase.header == s3_constants.IfUnmodifiedSince { + parsed = headers.ifUnmodifiedSince + } + if !parsed.Equal(testCase.expected) { + t.Fatalf("expected parsed time %v, got %v", testCase.expected, parsed) + } + }) + } +} + +func TestValidateConditionalCopyHeadersAcceptsHTTPDateFormats(t *testing.T) { + testCases := []struct { + name string + header string + value string + mtime int64 // source mtime chosen so the condition passes + }{ + { + name: "X-Amz-Copy-Source-If-Modified-Since RFC850", + header: s3_constants.AmzCopySourceIfModifiedSince, + value: "Sunday, 06-Nov-94 08:49:37 GMT", + mtime: 1577836800, // 2020-01-01, modified after the 1994 header + }, + { + name: "X-Amz-Copy-Source-If-Unmodified-Since ANSIC", + header: s3_constants.AmzCopySourceIfUnmodifiedSince, + value: "Sun Nov 6 08:49:37 1994", + mtime: 631152000, // 1990-01-01, not modified after the 1994 header + }, + } + + var s3a *S3ApiServer // method does not use the receiver + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + r := reqWith(map[string]string{testCase.header: testCase.value}) + entry := &filer_pb.Entry{Attributes: &filer_pb.FuseAttributes{Mtime: testCase.mtime}} + + if errCode := s3a.validateConditionalCopyHeaders(r, entry); errCode != s3err.ErrNone { + t.Fatalf("expected %s to be accepted, got %v", testCase.header, errCode) + } + }) + } +} + func TestBuildDeleteCondition(t *testing.T) { t.Run("no If-Match is unconditional", func(t *testing.T) { cond, ok := buildDeleteCondition(reqWith(nil))