Skip to content
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ See [docs/metrics.md](docs/metrics.md) for complete metrics reference.

TAG supports transparent proxy mode (default) with local SigV4 validation and per-bucket authorization caching, as well as signing mode with local credential stores.

For requests routed upstream through the signing forwarder, header-authenticated `STREAMING-AWS4-HMAC-SHA256-PAYLOAD` uploads are verified before their decoded bodies are forwarded from temporary storage; concurrent stages account for at most half of the temporary-filesystem space available when the first stage is admitted. This guarantee does not cover tiered-mode PUTs stored directly in the local cache. Presigned URL validation retains its existing `UNSIGNED-PAYLOAD` behavior.

See [docs/security.md](docs/security.md) for authentication, access control, and security architecture.

## Contributing
Expand Down
3 changes: 2 additions & 1 deletion auth/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ func NewRequestValidator(keyProvider KeyProvider) *RequestValidator {

// ValidateRequest validates the AWS SigV4 signature of an incoming request.
// This requires the X-Amz-Content-Sha256 header to be present (all AWS SDKs set this).
// This enables zero-copy streaming of the request body.
// It validates the request header without reading r.Body; callers choose the body handling
// appropriate to the payload marker.
// Returns the access key if validation succeeds, or an error if it fails.
func (v *RequestValidator) ValidateRequest(r *http.Request) (string, error) {
// Require X-Amz-Content-Sha256 header - all AWS SDKs set this
Expand Down
10 changes: 8 additions & 2 deletions docs/s3-compatibility.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# S3 API Compatibility

TAG implements all the commonly used bucket and object APIs from the Amazon S3 REST API, acting as a caching proxy between S3 clients and Tigris object storage. By default, TAG operates in transparent proxy mode where client requests are forwarded as-is with proxy headers. In signing mode, requests are validated, re-signed, and forwarded to the Tigris upstream endpoint.
TAG implements all the commonly used bucket and object APIs from the Amazon S3 REST API, acting as a caching proxy between S3 clients and Tigris object storage. By default, TAG operates in transparent proxy mode where client requests are forwarded as-is with proxy headers. Requests that signing mode routes upstream are validated, re-signed, and forwarded to the configured endpoint.

## Supported Operations

Expand Down Expand Up @@ -63,7 +63,13 @@ TAG supports AWS chunked transfer encoding (streaming SigV4), used by tools like

In **transparent proxy mode** (default), chunked uploads are forwarded as-is since the original signatures remain valid (the Host header is not changed).

In **signing mode**, when a client sends a PUT with `X-Amz-Content-Sha256: STREAMING-AWS4-HMAC-SHA256-PAYLOAD`, the body is wrapped in an AWS-proprietary chunked format where each chunk includes a signature chained from the request-level seed signature. Because TAG re-signs requests with the upstream Tigris hostname (changing the `Host` header), these per-chunk signatures become invalid. TAG handles this by decoding the chunked body on-the-fly — stripping the chunk framing and signatures — and forwarding the raw payload as `UNSIGNED-PAYLOAD`. The decoded content length is read from the `X-Amz-Decoded-Content-Length` header. This is a streaming operation with no buffering of the full object.
On the **signing-mode upstream-forwarding path**, a header-authenticated PUT with `X-Amz-Content-Sha256: STREAMING-AWS4-HMAC-SHA256-PAYLOAD` carries chunk signatures chained from the request-level signature. TAG validates every chunk and the terminal signature before forwarding any payload. Tiered-mode PUTs stored directly in the local cache bypass this forwarding path and are not covered by this guarantee.

Because the upstream request has a different `Host` header, TAG re-signs the decoded bytes as `UNSIGNED-PAYLOAD`. It stages those bytes in a temporary file first. Memory use is bounded, but temporary storage proportional to the upload is required and upstream dispatch waits until verification completes.

Before copying data, TAG reserves the declared decoded length. If no nonnegative decoded length is available, it reserves each next chunk. The process-wide pool admits reservations up to half of the temporary-filesystem space available when the first stage is admitted. The other half is left unclaimed by signed-stream staging. This accounting is process-local; other users can still consume available space. Insufficient capacity rejects the request before the unreserved chunk is written. Reservations remain held until the staged file is removed. If staging aborts before the request body is fully consumed, TAG closes the HTTP/1 connection instead of reusing it. A nonnegative `X-Amz-Decoded-Content-Length` must match the staged body.

Presigned URL validation continues to use `UNSIGNED-PAYLOAD`; it does not establish the forwarded-path chunk-signature guarantee described above.

## Caching Behavior

Expand Down
9 changes: 5 additions & 4 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ Tigris independently validates both the client's SigV4 signature and TAG's proxy

### Signing Mode

In signing mode, TAG terminates the client signature and re-issues the upstream one itself:
For requests routed upstream in signing mode, TAG terminates the client signature and re-issues the upstream one itself:

1. The client signs its request with its own SigV4 credentials.
2. TAG looks up the secret for the client's access key in its **local credential store** and cryptographically validates the incoming signature.
3. TAG re-signs the (possibly transformed) request for the upstream endpoint using standard AWS SigV4 with **the same access key and secret**, then streams it upstream.
2. TAG looks up the secret for the client's access key in its **local credential store** and cryptographically validates the incoming request signature.
3. On this upstream-forwarding path, for header-authenticated requests with `STREAMING-AWS4-HMAC-SHA256-PAYLOAD`, TAG reserves temporary storage, verifies each chained chunk signature and the terminal signature, checks a declared decoded length against the staged bytes, and stages the payload before forwarding.
4. TAG re-signs the (possibly transformed) request for the upstream endpoint using standard AWS SigV4 with **the same access key and secret**, then streams it upstream.

TAG re-signs rather than forwarding the original signature because it may transform the request — for example decoding AWS chunked transfer encoding to `UNSIGNED-PAYLOAD` — which would otherwise invalidate the client's signature. The upstream sees the **same identity** as the client; this is not identity translation.
TAG re-signs rather than forwarding the original signature because it may transform the request — for example decoding AWS chunked transfer encoding to `UNSIGNED-PAYLOAD` — which would otherwise invalidate the client's signature. These upstream-forwarded header-authenticated streaming uploads require temporary storage proportional to the payload and are not dispatched upstream until the full chain has been verified. Concurrent signed-stream stages reserve at most half of the temporary-filesystem space available when the first stage is admitted; other filesystem users are outside this process-local accounting. Tiered-mode PUTs stored directly in the local cache bypass this upstream-forwarding path and are not covered by this chunk-signature guarantee. Presigned URL validation continues to use `UNSIGNED-PAYLOAD` and does not establish this guarantee. The upstream sees the **same identity** as the client; this is not identity translation.

Because TAG must know the secret for every access key it serves, those credentials must be present in its local credential store. In production the store is populated only from `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (a single pair), so **clients must authenticate with those same credentials**, and TAG re-signs upstream with them. If the store is empty, TAG rejects all requests.

Expand Down
30 changes: 30 additions & 0 deletions handlers/response_tracker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package handlers

import (
"net/http"
"net/http/httptest"
"testing"
"time"
)

type responseTrackerDeadlineWriter struct {
http.ResponseWriter
deadline time.Time
}

func (w *responseTrackerDeadlineWriter) SetReadDeadline(deadline time.Time) error {
w.deadline = deadline
return nil
}

func TestResponseTrackerUnwrapsReadDeadline(t *testing.T) {
writer := &responseTrackerDeadlineWriter{ResponseWriter: httptest.NewRecorder()}
wrapped := &responseTracker{ResponseWriter: writer}
deadline := time.Now()
if err := http.NewResponseController(wrapped).SetReadDeadline(deadline); err != nil {
t.Fatalf("set read deadline through responseTracker: %v", err)
}
if !writer.deadline.Equal(deadline) {
t.Fatalf("underlying read deadline = %v, want %v", writer.deadline, deadline)
}
}
5 changes: 5 additions & 0 deletions handlers/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,11 @@ type responseTracker struct {
committed bool
}

// Unwrap lets http.ResponseController reach connection controls through this wrapper.
func (rt *responseTracker) Unwrap() http.ResponseWriter {
return rt.ResponseWriter
}

func (rt *responseTracker) WriteHeader(code int) {
rt.committed = true
rt.ResponseWriter.WriteHeader(code)
Expand Down
12 changes: 7 additions & 5 deletions proxy/chunked_reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ func IsStreamingPayload(bodyHash string) bool {
//
// 0[;chunk-signature=<signature>]\r\n
//
// The reader strips the framing and returns only the raw chunk data.
// Chunk signatures and trailing checksums are not validated (the
// request-level signature was already verified by the auth validator).
// The reader strips the framing and returns only the raw chunk data. It does
// not validate chunk signatures or trailing checksums. The signing forwarder
// verifies and stages the standard HMAC streaming-payload marker separately.
type awsChunkedReader struct {
reader *bufio.Reader
remaining int
Expand Down Expand Up @@ -191,8 +191,10 @@ func (r *awsChunkedReader) readTrailingCRLF() error {
// decoded content length, and chunked=true. Otherwise returns the original values
// unchanged with chunked=false.
//
// Supports both signed (STREAMING-AWS4-HMAC-SHA256-PAYLOAD) and unsigned
// (STREAMING-UNSIGNED-PAYLOAD-TRAILER) chunked formats.
// Supports the framing of both signed (STREAMING-AWS4-HMAC-SHA256-PAYLOAD) and
// unsigned (STREAMING-UNSIGNED-PAYLOAD-TRAILER) chunked formats. This low-level
// decoder does not authenticate signed chunks; the signing forwarder handles the
// standard HMAC streaming-payload marker before forwarding.
func decodeChunkedIfNeeded(r *http.Request) (body io.ReadCloser, bodyHash string, contentLength int64, chunked bool) {
bodyHash = r.Header.Get("X-Amz-Content-Sha256")

Expand Down
11 changes: 11 additions & 0 deletions proxy/forwarder.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,17 @@ func NewForwarder(credStore *auth.CredentialStore, tigrisEndpoint, region string
}
}

// headersForForwardedSigning excludes headers that are removed after decoding, so
// the generated Authorization header matches the request sent to the upstream.
func headersForForwardedSigning(headers http.Header, chunked bool) http.Header {
if !chunked {
return headers
}
forwarded := headers.Clone()
forwarded.Del("X-Amz-Decoded-Content-Length")
return forwarded
}

// prepareForwardedRequest sets Content-Length on the forwarded request and strips
// AWS chunked-encoding headers when the request body was decoded from chunked format.
func prepareForwardedRequest(fwdReq *http.Request, contentLength int64, chunked bool) {
Expand Down
Loading
Loading