Files
rclone/cmd/serve/s3/server.go
T
Nick Craig-Wood e8e883c35e serve s3: fix auth proxy accepting any request signed with an empty secret GHSA-xwwr-4h3p-r22c CVE-PENDING
With --auth-proxy set and --auth-key unset, serve s3 registered every client
supplied access key ID with an empty secret and verified the SigV4 signature
against that, so anyone could sign a request for an arbitrary access key ID with
an empty secret and be let in. The proxy program was only ever given the access
key ID (as both user and pass) so it had nothing with which to authenticate the
client either.

An S3 client never sends its secret, only a signature made with it, so the
server has to know the secret to check the request. The auth proxy protocol as
been changed to handle this. For serve s3 the proxy program is given just the
access key ID as the user (no pass or public_key) and must return the matching
secret as _secret_access_key in its output. rclone verifies the request's
signature against that secret, refusing the request if the proxy rejects the
access key ID, doesn't return a secret or returns an empty one, or the signature
doesn't match. The secret is only used for this server's own verification and is
never registered with gofakes3, so other serve s3 instances in the same process
don't honour it.

The proxy's answers are cached. If a signature fails against a cached secret the
proxy is consulted again so a rotated secret takes effect immediately - but only
for a signature mismatch, and at most once every 10 seconds per access key ID
and client IP, so a stream of bad signatures can't make the proxy program run
for every request. A rotation never shuts down the cached backend under requests
still using it. A cached answer is checked with the proxy again once it is 5
minutes old even if in constant use, so revoking an access key ID takes effect
within 5 minutes.

This means --auth-key is no longer needed with --auth-proxy: it is ignored and a
warning is given at startup if both are set. The proxy is the source of truth
for both the credentials and the backend they map to. Presigned URLs (credential
in the query string) are now recognised by the proxy middleware too. The auth
proxy docs are added to serve s3.

Note that the serve s3 auth proxy protocol has changed. The proxy program is now
given the access key ID as "user" (it was previously given an MD5 hash of it,
with the access key ID as "pass") and must return the matching secret as
"_secret_access_key".

This needs gofakes3 v0.0.9 for signature.V4SignVerifyWithSecret.
2026-09-04 19:00:22 +01:00

254 lines
7.1 KiB
Go

// Package s3 implements a fake s3 server for rclone
package s3
import (
"context"
"errors"
"fmt"
"math/rand"
"net"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/rclone/gofakes3"
"github.com/rclone/gofakes3/signature"
"github.com/rclone/rclone/cmd/serve/proxy"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/hash"
httplib "github.com/rclone/rclone/lib/http"
"github.com/rclone/rclone/vfs"
"github.com/rclone/rclone/vfs/vfscommon"
)
type ctxKey int
const (
ctxKeyID ctxKey = iota
)
// Server is a s3.FileSystem interface
type Server struct {
server *httplib.Server
opt Options
f fs.Fs
provider *proxy.Provider
faker *gofakes3.GoFakeS3
backend *s3Backend
handler http.Handler
ctx context.Context // for global config
etagHashType hash.Type
}
// Make a new S3 Server to serve the remote
func newServer(ctx context.Context, f fs.Fs, opt *Options, vfsOpt *vfscommon.Options, proxyOpt *proxy.Options) (s *Server, err error) {
w := &Server{
f: f,
ctx: ctx,
opt: *opt,
provider: proxy.NewProvider(ctx, f, vfsOpt, proxyOpt),
etagHashType: hash.None,
}
defer func() {
if err != nil {
if w.backend != nil {
w.backend.stopReaper()
}
w.provider.Shutdown()
}
}()
if w.opt.EtagHash == "auto" {
w.etagHashType = f.Hashes().GetOne()
} else if w.opt.EtagHash != "" {
err := w.etagHashType.Set(w.opt.EtagHash)
if err != nil {
return nil, err
}
}
if w.etagHashType != hash.None {
fs.Debugf(f, "Using hash %v for ETag", w.etagHashType)
}
if len(opt.AuthKey) == 0 && !w.provider.IsProxy() {
fs.Logf("serve s3", "No auth provided so allowing anonymous access")
}
authList, err := authlistResolver(opt.AuthKey)
if err != nil {
return nil, fmt.Errorf("parsing auth list failed: %q", err)
}
if w.provider.IsProxy() {
// The proxy middleware authenticates every request itself so
// gofakes3's own auth must be left empty.
if len(authList) > 0 {
fs.Logf("serve s3", "--auth-key is ignored when --auth-proxy is set - the proxy must supply the secret for each access key ID")
}
authList = nil
}
w.backend = newBackend(w)
if w.opt.MultipartExpiry > 0 {
w.backend.startReaper(time.Duration(w.opt.MultipartExpiry))
}
var newLogger logger
w.faker = gofakes3.New(
w.backend,
gofakes3.WithHostBucket(!opt.ForcePathStyle),
gofakes3.WithLogger(newLogger),
gofakes3.WithRequestID(rand.Uint64()),
gofakes3.WithoutVersioning(),
gofakes3.WithV4Auth(authList),
gofakes3.WithIntegrityCheck(true), // Check Content-MD5 if supplied
)
w.handler = w.faker.Server()
if w.provider.IsProxy() {
w.handler = proxyAuthMiddleware(w.handler, w)
} else if len(opt.AuthKey) > 0 {
w.faker.AddAuthKeys(authList)
}
w.server, err = httplib.NewServer(ctx,
httplib.WithConfig(opt.HTTP),
httplib.WithAuth(opt.Auth),
)
if err != nil {
return nil, fmt.Errorf("failed to init server: %w", err)
}
router := w.server.Router()
w.Bind(router)
return w, nil
}
func (w *Server) getVFS(ctx context.Context) (VFS *vfs.VFS, err error) {
if w.provider.VFS() != nil {
return w.provider.VFS(), nil
}
value := ctx.Value(ctxKeyID)
if value == nil {
return nil, errors.New("no VFS found in context")
}
VFS, ok := value.(*vfs.VFS)
if !ok {
return nil, fmt.Errorf("context value is not VFS: %#v", value)
}
return VFS, nil
}
// auth authenticates the request via the auth proxy.
//
// The proxy maps the access key ID to a VFS and a secret access key
// and the request's signature is verified against that secret. If it
// fails against a cached secret the proxy is consulted again in case
// the secret has been rotated.
//
// The secret is only ever used here and is never registered with
// gofakes3, whose key store is shared by every instance in the
// process.
func (w *Server) auth(r *http.Request, accessKeyID string) (VFS *vfs.VFS, err error) {
p := w.provider.Proxy()
VFS, secret, err := p.CallAccessKey(accessKeyID, r.RemoteAddr, false)
if err != nil {
return nil, err
}
errCode := signature.V4SignVerifyWithSecret(r, secret)
if errCode == signature.ErrNone {
return VFS, nil
}
// Only a signature mismatch can be cured by a rotated secret, so
// only then is the proxy worth consulting again - other failures
// (expired request, bad date, missing headers) must not make
// every bad request run the proxy.
if signature.GetAPIError(errCode).Code == "SignatureDoesNotMatch" {
VFS, secret, err = p.CallAccessKey(accessKeyID, r.RemoteAddr, true)
if err != nil {
return nil, err
}
errCode = signature.V4SignVerifyWithSecret(r, secret)
if errCode == signature.ErrNone {
return VFS, nil
}
}
return nil, fmt.Errorf("signature verification failed: %s", signature.GetAPIError(errCode).Code)
}
// Bind register the handler to http.Router
func (w *Server) Bind(router chi.Router) {
router.Handle("/*", w.handler)
}
// Serve serves the s3 server until the server is shutdown
func (w *Server) Serve() error {
w.server.Serve()
fs.Logf(w.f, "Starting s3 server on %s", w.server.URLs())
w.server.Wait()
return nil
}
// Addr returns the first address of the server
func (w *Server) Addr() net.Addr {
return w.server.Addr()
}
// Shutdown the server
func (w *Server) Shutdown() error {
w.backend.stopReaper()
err := w.server.Shutdown()
w.provider.Shutdown()
return err
}
// proxyAuthMiddleware authenticates each request via the auth proxy,
// storing the VFS it returns in the request context, and refuses
// requests the proxy does not accept.
func proxyAuthMiddleware(next http.Handler, ws *Server) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
accessKey, errCode := parseAccessKeyID(r)
if errCode != signature.ErrNone {
fs.Infof(r.URL.Path, "%s: Auth failed: no access key ID in request", r.RemoteAddr)
accessDenied(w)
return
}
VFS, err := ws.auth(r, accessKey)
if err != nil {
fs.Infof(r.URL.Path, "%s: Auth failed: %v", r.RemoteAddr, err)
accessDenied(w)
return
}
r = r.WithContext(context.WithValue(r.Context(), ctxKeyID, VFS))
next.ServeHTTP(w, r)
})
}
// accessDenied writes an S3 AccessDenied error response
func accessDenied(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>`))
}
// parseAccessKeyID returns the access key ID from the request's
// Authorization header or presigned URL query parameters
func parseAccessKeyID(r *http.Request) (accessKey string, errCode signature.ErrorCode) {
v4Auth := r.Header.Get("Authorization")
if v4Auth == "" {
// Presigned URLs carry the credential in the query string
q := r.URL.Query()
if q.Get("X-Amz-Signature") != "" {
v4Auth = fmt.Sprintf("%s Credential=%s, SignedHeaders=%s, Signature=%s", q.Get("X-Amz-Algorithm"), q.Get("X-Amz-Credential"), q.Get("X-Amz-SignedHeaders"), q.Get("X-Amz-Signature"))
}
}
req, errCode := signature.ParseSignV4(v4Auth)
if errCode != signature.ErrNone {
return "", errCode
}
return req.Credential.GetAccessKey(), signature.ErrNone
}