Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ Two version streams move independently:

## [Unreleased]

### Fixed
- **s3cmd `get` compatibility**: intercepted `GetObject` responses now
carry a `Content-Length` header reflecting the decrypted body size.
Previously the proxy streamed decrypted objects without a length, so
Go fell back to chunked transfer encoding and s3cmd 2.4.0 downloaded
an empty file. Downloads now complete with the correct size.

### Chart

- **`chart/1.9.3`** — Dashboard usability + Go runtime panels.
Expand Down
6 changes: 6 additions & 0 deletions s3proxy/internal/router/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"net/http"
"net/url"
"runtime/debug"
"strconv"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -157,6 +158,11 @@ func (o object) get(w http.ResponseWriter, r *http.Request) {
releaseLargeBuffer(&plaintext)
return
default:
// The full plaintext is already buffered, so emit an explicit Content-Length
// (the upstream value describes the ciphertext, not the decrypted body) so the
// server uses identity encoding instead of chunked. Some clients (e.g. s3cmd)
// require a Content-Length header on the response.
w.Header().Set("Content-Length", strconv.Itoa(len(plaintext)))
w.WriteHeader(http.StatusOK)
_, writeErr := w.Write(plaintext)
releaseLargeBuffer(&plaintext)
Expand Down
30 changes: 30 additions & 0 deletions s3proxy/internal/router/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -198,6 +199,35 @@ func TestGetObjectUsesRouterKEK(t *testing.T) {
assert.Equal(t, "secret payload", rec.Body.String())
}

func TestGetObjectSetsPlaintextContentLength(t *testing.T) {
keks := newTestKEKs(t, "expected encryption key")
version, curKEK := keks.Current()
client := newEncryptedGetObjectClient(t, curKEK, version, []byte("secret payload"))
router := Router{keks: keks, log: testLogger()}
req := httptest.NewRequest(http.MethodGet, "/bucket/key", nil)
rec := httptest.NewRecorder()

router.getHandler(req, client, true, "key", "bucket").ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)
// The header must reflect the plaintext length, not the +28-byte ciphertext.
assert.Equal(t, strconv.Itoa(len("secret payload")), rec.Result().Header.Get("Content-Length"))
}

func TestGetObjectSetsZeroContentLengthForEmptyObject(t *testing.T) {
keks := newTestKEKs(t, "expected encryption key")
version, curKEK := keks.Current()
client := newEncryptedGetObjectClient(t, curKEK, version, []byte{})
router := Router{keks: keks, log: testLogger()}
req := httptest.NewRequest(http.MethodGet, "/bucket/key", nil)
rec := httptest.NewRecorder()

router.getHandler(req, client, true, "key", "bucket").ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "0", rec.Result().Header.Get("Content-Length"))
}

func TestGetObjectFailsWithWrongRouterKEK(t *testing.T) {
storedKeks := newTestKEKs(t, "old encryption key")
routerKeks := newTestKEKs(t, "new encryption key")
Expand Down