Skip to content
Merged
48 changes: 47 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,55 @@ Two version streams move independently:

## [Unreleased]

## [1.9.0] — 2026-07-29

Ships the S3 client-compatibility fixes (`Content-Length`, plaintext `ETag`),
plaintext sizes in `ListObjects`, and a configurable upstream operation timeout.
Chart `1.10.0` accompanies this release.

### 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.
- **`GetObject` returns the plaintext ETag.** Intercepted GETs decrypt the
body but previously returned the upstream ETag, which S3 computes over the
ciphertext at rest. Clients (or SDKs) that validate the body against the
ETag, or cache by it, saw a mismatch. The proxy now overrides the response
ETag with `md5(plaintext)` — the ETag S3 would have produced for the
unencrypted object — computed on the fly from the buffer already held in
memory, so no stored metadata or migration is needed. Pass-through objects
(no DEK tag) keep the upstream ETag, which already describes the delivered
bytes. PUT-response and HEAD ETags still reflect the ciphertext and remain
a known consistency gap.

### Added

- `ListObjects`/`ListObjectsV2` responses now report the **decrypted plaintext
size** of each object instead of the larger at-rest ciphertext size. The proxy
intercepts bucket-level list requests, subtracts the fixed 28-byte encryption
overhead (12-byte nonce + 16-byte GCM tag) from every `<Size>`, and clamps at 0.
Bucket sub-resource GETs (acl, versioning, multipart listings, `?versions`, …)
are still forwarded unchanged.
- *Known limitation:* objects **not** written through the proxy (legacy
plaintext, server-side copies, multipart) are reported 28 bytes short (or 0
after clamp), since a list response carries no per-object encryption metadata.
- Configurable timeout for upstream `GetObject` and `PutObject` operations via
`S3PROXY_S3_OPERATION_TIMEOUT`; defaults to 120 seconds and accepts values up
to 30 minutes.

### Chart

- **`chart/1.9.3`** — Dashboard usability + Go runtime panels.
- **`chart/1.10.0`** — New `deploymentAnnotations` value, rendered onto the
`Deployment` object's own `metadata.annotations`. Until now the chart only
exposed `podAnnotations`, which lands on the pod template — so controllers
that watch the workload metadata had no way in. The motivating case is
Stakater Reloader (`reloader.stakater.com/auto: "true"`): the TLS secret is
mounted with `subPath`, which kubelet never refreshes in place, so a renewed
certificate is only picked up when the pods restart. Without the annotation
the proxy keeps serving the expired certificate after cert-manager rotates
it, and every TLS client fails with `x509: certificate has expired`.
- `Job` picker now defaults to **All** (`allValue: ".*s3proxy.*"`) and
restricts its dropdown to jobs whose name contains `s3proxy` via the
template `regex: /s3proxy/`. Multi-release clusters land on a
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ All runtime configuration is sourced from environment variables. The Helm chart
| `AWS_SECRET_ACCESS_KEY` | yes | — | Idem. |
| `S3PROXY_THROTTLING_REQUESTSMAX` | no | `0` (off) | Cap on **concurrent in-flight requests** (not RPS). Excess requests are rejected. |
| `S3PROXY_PUTBODY_MAX` | no | `268435456` (256 MiB) | Per-request PutObject body size ceiling, in bytes. Up to `5368709120` (5 GiB, the S3 hard cap). |
| `S3PROXY_S3_OPERATION_TIMEOUT` | no | `120` (2 min) | Upper bound, in seconds, on a single upstream GetObject/PutObject call. Raise this for large objects on slow links. Up to `1800` (30 min). |
| `S3PROXY_DEKTAG_NAME` | no | `isec` | S3 object-metadata key used to store the encrypted DEK. |
| `S3PROXY_DEKTAG_KEKVER` | no | `<dektag>-kek-ver` | S3 object-metadata key recording which KEK derivation version wrapped the DEK. |
| `S3PROXY_INSECURE` | no | unset | Set to `1` to use plain HTTP (not HTTPS) when talking to upstream. **Dev / e2e only.** Emits a loud warning at startup. |
Expand Down
4 changes: 2 additions & 2 deletions charts/s3proxy/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ maintainers:
annotations:
org.opencontainers.image.source: "https://github.com/intrinsec/s3proxy/"
type: application
version: 1.9.3
appVersion: "1.8.1"
version: 1.10.0
appVersion: "1.9.0"
4 changes: 4 additions & 0 deletions charts/s3proxy/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ metadata:
name: {{ include "s3proxy.fullname" . }}
labels:
{{- include "s3proxy.labels" . | nindent 4 }}
{{- with .Values.deploymentAnnotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
Expand Down
1 change: 1 addition & 0 deletions charts/s3proxy/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
"type": "object",
"additionalProperties": true
},
"deploymentAnnotations": { "type": "object" },
"podAnnotations": { "type": "object" },
"podLabels": { "type": "object" },
"podSecurityContext": { "type": "object" },
Expand Down
9 changes: 9 additions & 0 deletions charts/s3proxy/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ valsSecret: {}
# secretKey: ref+vault://s3proxy/secrets/secret-key
# encryptKey: ref+vault://s3proxy/secrets/encrypt-key

# Annotations set on the Deployment object itself (not the pod template).
# Required by controllers that watch the workload metadata, e.g. Stakater
# Reloader, which restarts the pods when the TLS secret is renewed:
# deploymentAnnotations:
# reloader.stakater.com/auto: "true"
# The cert secret is mounted with subPath, so kubelet never refreshes it
# in-place — a pod restart is the only way to pick up a renewed certificate.
deploymentAnnotations: {}

podAnnotations: {}
podLabels: {}

Expand Down
19 changes: 19 additions & 0 deletions s3proxy/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"strings"
"time"

"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/v2"
Expand Down Expand Up @@ -95,6 +96,21 @@ func (c *Config) MaxPutBodySize() int64 {
return v
}

// S3OperationTimeout returns the configured upper bound on a single S3
// GetObject/PutObject call. Falls back to DefaultS3OperationTimeout when
// S3PROXY_S3_OPERATION_TIMEOUT (seconds) is unset, zero, or out of range.
func (c *Config) S3OperationTimeout() time.Duration {
const key = "s3proxy.s3.operation.timeout"
if !c.k.Exists(key) {
return DefaultS3OperationTimeout
}
v := time.Duration(c.k.Int64(key)) * time.Second
if v <= 0 || v > MaxS3OperationTimeout {
return DefaultS3OperationTimeout
}
return v
}

// DecryptionFallback reports whether GetObject should retry decryption with an
// all-zero KEK when the configured KEK fails (S3PROXY_DECRYPTION_FALLBACK=1).
// Intended for one-shot migrations away from objects written without an
Expand Down Expand Up @@ -155,5 +171,8 @@ func GetMaxPutBodySize() int64 { return Default().MaxPutBodySize() }
// GetInsecure returns whether upstream S3 traffic should use http:// from the default config.
func GetInsecure() bool { return Default().Insecure() }

// GetS3OperationTimeout returns the S3 operation timeout from the default config.
func GetS3OperationTimeout() time.Duration { return Default().S3OperationTimeout() }

// GetDecryptionFallback returns the decryption-fallback flag from the default config.
func GetDecryptionFallback() bool { return Default().DecryptionFallback() }
10 changes: 10 additions & 0 deletions s3proxy/internal/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"regexp"
"time"
)

// Validate asserts that the required s3proxy configuration is present and well-formed.
Expand Down Expand Up @@ -93,6 +94,15 @@ const MaxObjectSize = 5 * 1024 * 1024 * 1024
// streaming encryption path is introduced.
const DefaultMaxPutBodySize = 256 * 1024 * 1024

// DefaultS3OperationTimeout is the default upper bound on a single S3
// GetObject/PutObject call. Large objects on slow links can need more time;
// operators can raise this via S3PROXY_S3_OPERATION_TIMEOUT (seconds).
const DefaultS3OperationTimeout = 2 * time.Minute

// MaxS3OperationTimeout bounds how high S3PROXY_S3_OPERATION_TIMEOUT can be set,
// so a misconfiguration cannot leave requests hanging indefinitely.
const MaxS3OperationTimeout = 30 * time.Minute

// ValidateContentLength validates the content length of a request against the S3 hard cap.
func ValidateContentLength(contentLength int64) error {
if contentLength < 0 {
Expand Down
29 changes: 29 additions & 0 deletions s3proxy/internal/config/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ package config
import (
"strings"
"testing"
"time"

"github.com/knadh/koanf/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -104,3 +106,30 @@ func TestValidatePutBodySize(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "exceeds PutObject body cap")
}

func TestS3OperationTimeout(t *testing.T) {
tests := map[string]struct {
value string
want time.Duration
}{
"valid": {value: "300", want: 5 * time.Minute},
"at maximum": {value: "1800", want: MaxS3OperationTimeout},
"zero": {value: "0", want: DefaultS3OperationTimeout},
"negative": {value: "-1", want: DefaultS3OperationTimeout},
"over maximum": {value: "1801", want: DefaultS3OperationTimeout},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Setenv("S3PROXY_S3_OPERATION_TIMEOUT", tc.value)
cfg, err := Load()
require.NoError(t, err)
assert.Equal(t, tc.want, cfg.S3OperationTimeout())
})
}

t.Run("unset", func(t *testing.T) {
cfg := &Config{k: koanf.New(".")}
assert.Equal(t, DefaultS3OperationTimeout, cfg.S3OperationTimeout())
})
}
5 changes: 5 additions & 0 deletions s3proxy/internal/cryptoutil/cryptoutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import (
"github.com/tink-crypto/tink-go/v2/subtle/random"
)

// EncryptionOverhead is the fixed number of bytes Encrypt adds to the plaintext:
// a 12-byte AES-GCM-SIV nonce prefix plus a 16-byte authentication tag suffix.
// Ciphertext length is always plaintext length + EncryptionOverhead.
const EncryptionOverhead = 28

// Encrypt generates a random key to encrypt a plaintext using AES-256-GCM.
// The generated key is encrypted using the supplied key encryption key (KEK).
// The ciphertext and encrypted data encryption key (DEK) are returned.
Expand Down
22 changes: 22 additions & 0 deletions s3proxy/internal/cryptoutil/cryptoutil_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,25 @@ func TestEncryptDecrypt(t *testing.T) {
})
}
}

// TestEncryptionOverhead asserts that Encrypt adds exactly EncryptionOverhead bytes to the
// plaintext, regardless of plaintext size. This invariant is what lets the router subtract a
// constant to report decrypted sizes in LIST responses; it fails loudly if the envelope changes.
func TestEncryptionOverhead(t *testing.T) {
for _, size := range []int{0, 1, 28, 29, 1024, 1610862} {
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
kek := [32]byte{}
_, err := rand.Read(kek[:])
require.NoError(t, err)

plaintext := make([]byte, size)
_, err = rand.Read(plaintext)
require.NoError(t, err)

ciphertext, _, err := Encrypt(plaintext, kek)
require.NoError(t, err)

assert.Equal(t, size+EncryptionOverhead, len(ciphertext))
})
}
}
16 changes: 16 additions & 0 deletions s3proxy/internal/e2e/proxy_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,22 @@ func TestProxyRoundtripAgainstMinio(t *testing.T) {
require.NoError(t, got.Body.Close())
assert.Equal(t, plaintext, gotBody)

// Listing through the proxy must report the decrypted plaintext size, not the larger
// ciphertext size that actually sits at rest. Cover both ListObjectsV2 and ListObjects v1.
listV2, err := proxyClient.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{
Bucket: aws.String(bucket),
})
require.NoError(t, err)
require.Len(t, listV2.Contents, 1)
assert.EqualValues(t, len(plaintext), *listV2.Contents[0].Size, "ListObjectsV2 must report plaintext size")

listV1, err := proxyClient.ListObjects(ctx, &awss3.ListObjectsInput{
Bucket: aws.String(bucket),
})
require.NoError(t, err)
require.Len(t, listV1.Contents, 1)
assert.EqualValues(t, len(plaintext), *listV1.Contents[0].Size, "ListObjects v1 must report plaintext size")

// Housekeeping endpoints stay reachable while traffic is flowing.
healthResp, err := http.Get(proxyURL.String() + "/healthz")
require.NoError(t, err)
Expand Down
Loading
Loading