* feat: drop table location mapping support Disable external metadata locations for S3 Tables and remove the table location mapping index entirely. Table metadata must live under the table bucket paths, so lookups no longer use mapping directories. Changes: - Remove mapping lookup and cache from bucket path resolution - Reject metadataLocation in CreateTable and UpdateTable - Remove mapping helpers and tests * compile * refactor * fix: accept metadataLocation in S3 Tables API requests We removed the external table location mapping feature, but still need to accept and store metadataLocation values from clients like Trino. The mapping feature was an internal implementation detail that mapped external buckets to internal table paths. The metadataLocation field itself is part of the S3 Tables API and should be preserved. * fmt * fix: handle MetadataLocation in UpdateTable requests Mirror handleCreateTable behavior by updating metadata.MetadataLocation when req.MetadataLocation is provided in UpdateTable requests. This ensures table metadata location can be updated, not just set during creation.
213 lines
6.5 KiB
Go
213 lines
6.5 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestReverseProxySignatureVerification is an integration test that exercises
|
|
// the full HTTP stack: real AWS SDK v4 signer -> real httputil.ReverseProxy ->
|
|
// real net/http server running IAM signature verification.
|
|
//
|
|
// This catches issues that unit tests miss: header normalization by net/http,
|
|
// proxy header injection, and real-world Host header handling.
|
|
func TestReverseProxySignatureVerification(t *testing.T) {
|
|
const (
|
|
accessKey = "AKIAIOSFODNN7EXAMPLE"
|
|
secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
|
)
|
|
|
|
configJSON := `{
|
|
"identities": [
|
|
{
|
|
"name": "test_user",
|
|
"credentials": [
|
|
{
|
|
"accessKey": "` + accessKey + `",
|
|
"secretKey": "` + secretKey + `"
|
|
}
|
|
],
|
|
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
|
|
}
|
|
]
|
|
}`
|
|
|
|
tests := []struct {
|
|
name string
|
|
externalUrl string // s3.externalUrl config for the backend
|
|
clientScheme string // scheme the client uses for signing
|
|
clientHost string // host the client signs against
|
|
proxyForwardsHost bool // whether proxy sets X-Forwarded-Host
|
|
expectSuccess bool
|
|
}{
|
|
{
|
|
name: "non-standard port, externalUrl matches proxy address",
|
|
externalUrl: "", // filled dynamically with proxy address
|
|
clientScheme: "http",
|
|
clientHost: "", // filled dynamically
|
|
proxyForwardsHost: true,
|
|
expectSuccess: true,
|
|
},
|
|
{
|
|
name: "externalUrl with non-standard port, client signs against external host",
|
|
externalUrl: "http://api.example.com:9000",
|
|
clientScheme: "http",
|
|
clientHost: "api.example.com:9000",
|
|
proxyForwardsHost: true,
|
|
expectSuccess: true,
|
|
},
|
|
{
|
|
name: "externalUrl with HTTPS default port stripped, client signs without port",
|
|
externalUrl: "https://api.example.com:443",
|
|
clientScheme: "https",
|
|
clientHost: "api.example.com",
|
|
proxyForwardsHost: true,
|
|
expectSuccess: true,
|
|
},
|
|
{
|
|
name: "externalUrl with HTTP default port stripped, client signs without port",
|
|
externalUrl: "http://api.example.com:80",
|
|
clientScheme: "http",
|
|
clientHost: "api.example.com",
|
|
proxyForwardsHost: true,
|
|
expectSuccess: true,
|
|
},
|
|
{
|
|
name: "proxy forwards X-Forwarded-Host correctly, no externalUrl needed",
|
|
externalUrl: "",
|
|
clientScheme: "http",
|
|
clientHost: "api.example.com:9000",
|
|
proxyForwardsHost: true,
|
|
expectSuccess: true,
|
|
},
|
|
{
|
|
name: "proxy without X-Forwarded-Host, no externalUrl: host mismatch",
|
|
externalUrl: "",
|
|
clientScheme: "http",
|
|
clientHost: "api.example.com:9000",
|
|
proxyForwardsHost: false,
|
|
expectSuccess: false,
|
|
},
|
|
{
|
|
name: "proxy without X-Forwarded-Host, externalUrl saves the day",
|
|
externalUrl: "http://api.example.com:9000",
|
|
clientScheme: "http",
|
|
clientHost: "api.example.com:9000",
|
|
proxyForwardsHost: false,
|
|
expectSuccess: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// --- Write config to temp file ---
|
|
tmpFile := t.TempDir() + "/s3.json"
|
|
require.NoError(t, os.WriteFile(tmpFile, []byte(configJSON), 0644))
|
|
|
|
// --- Set up backend ---
|
|
var iam *IdentityAccessManagement
|
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, errCode := iam.authRequest(r, "Read")
|
|
if errCode != 0 {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
fmt.Fprintf(w, "error: %d", int(errCode))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, "OK")
|
|
}))
|
|
defer backend.Close()
|
|
|
|
// --- Set up reverse proxy ---
|
|
backendURL, _ := url.Parse(backend.URL)
|
|
proxy := httputil.NewSingleHostReverseProxy(backendURL)
|
|
|
|
forwardsHost := tt.proxyForwardsHost
|
|
originalDirector := proxy.Director
|
|
proxy.Director = func(req *http.Request) {
|
|
originalHost := req.Host
|
|
originalScheme := req.URL.Scheme
|
|
if originalScheme == "" {
|
|
originalScheme = "http"
|
|
}
|
|
originalDirector(req)
|
|
// Simulate real proxy behavior: rewrite Host to backend address
|
|
// (nginx proxy_pass and Kong both do this by default)
|
|
req.Host = backendURL.Host
|
|
if forwardsHost {
|
|
req.Header.Set("X-Forwarded-Host", originalHost)
|
|
req.Header.Set("X-Forwarded-Proto", originalScheme)
|
|
}
|
|
}
|
|
|
|
proxyServer := httptest.NewServer(proxy)
|
|
defer proxyServer.Close()
|
|
|
|
// --- Configure IAM ---
|
|
externalUrl := tt.externalUrl
|
|
clientHost := tt.clientHost
|
|
clientScheme := tt.clientScheme
|
|
if externalUrl == "" && clientHost == "" {
|
|
// Dynamic: use the proxy's actual address
|
|
proxyURL, _ := url.Parse(proxyServer.URL)
|
|
externalUrl = proxyServer.URL
|
|
clientHost = proxyURL.Host
|
|
clientScheme = proxyURL.Scheme
|
|
}
|
|
|
|
option := &S3ApiServerOption{
|
|
Config: tmpFile,
|
|
ExternalUrl: externalUrl,
|
|
}
|
|
iam = NewIdentityAccessManagementWithStore(option, nil, "memory")
|
|
require.True(t, iam.isEnabled())
|
|
|
|
// --- Sign the request using real AWS SDK v4 signer ---
|
|
clientURL := fmt.Sprintf("%s://%s/test-bucket/test-object", clientScheme, clientHost)
|
|
req, err := http.NewRequest(http.MethodGet, clientURL, nil)
|
|
require.NoError(t, err)
|
|
req.Host = clientHost
|
|
|
|
signer := v4.NewSigner()
|
|
payloadHash := fmt.Sprintf("%x", sha256.Sum256([]byte{}))
|
|
err = signer.SignHTTP(
|
|
context.Background(),
|
|
aws.Credentials{AccessKeyID: accessKey, SecretAccessKey: secretKey},
|
|
req, payloadHash, "s3", "us-east-1", time.Now(),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// --- Send the signed request through the proxy ---
|
|
// Rewrite destination to the proxy, but keep signed headers and Host intact
|
|
proxyURL, _ := url.Parse(proxyServer.URL)
|
|
req.URL.Scheme = proxyURL.Scheme
|
|
req.URL.Host = proxyURL.Host
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
if tt.expectSuccess {
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode,
|
|
"Expected signature verification to succeed through reverse proxy")
|
|
} else {
|
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode,
|
|
"Expected signature verification to fail (host mismatch)")
|
|
}
|
|
})
|
|
}
|
|
}
|