Files
versitygw/s3api/server.go
niksis02 9f786b3c2c feat: global error refactoring
Fixes #2123
Fixes #2120
Fixes #2116
Fixes #2111
Fixes #2108
Fixes #2086
Fixes #2085
Fixes #2083
Fixes #2081
Fixes #2080
Fixes #2073
Fixes #2072
Fixes #2071
Fixes #2069
Fixes #2044
Fixes #2043
Fixes #2042
Fixes #2041
Fixes #2040
Fixes #2039
Fixes #2036
Fixes #2035
Fixes #2034
Fixes #2028
Fixes #2020
Fixes #1842
Fixes #1810
Fixes #1780
Fixes #1775
Fixes #1736
Fixes #1705
Fixes #1663
Fixes #1645
Fixes #1583
Fixes #1526
Fixes #1514
Fixes #1493
Fixes #1487
Fixes #959
Fixes #779
Closes #823
Closes #85

Refactor global S3 error handling around structured error types and centralized XML response generation.

All S3 errors now share the common APIError base for the fields every error has: Code, HTTP status code, and Message. Non-traditional errors that need AWS-compatible XML fields now have dedicated typed errors in the s3err package. Each typed error implements the shared S3Error behavior so controllers and middleware can handle errors consistently while still emitting error-specific XML fields.

Add a dedicated InvalidArgumentError type because InvalidArgument is used widely across request validation, auth, copy source handling, object lock validation, multipart validation, and header parsing. The new InvalidArgument path uses explicit InvalidArgErrorCode constants with predefined descriptions and ArgumentName values, keeping call sites readable while preserving the correct InvalidArgument XML shape and optional ArgumentValue.

New structured errors added in s3err:
- `AccessForbiddenError`: Method, ResourceType
- `BadDigestError`: CalculatedDigest, ExpectedDigest
- `BucketError`: BucketName
- `ContentSHA256MismatchError`: ClientComputedContentSHA256, S3ComputedContentSHA256
- `EntityTooLargeError`: ProposedSize, MaxSizeAllowed
- `EntityTooSmallError`: ProposedSize, MinSizeAllowed
- `ExpiredPresignedURLError`: ServerTime, XAmzExpires, Expires
- `InvalidAccessKeyIdError`: AWSAccessKeyId
- `InvalidArgumentError`: Description, ArgumentName, ArgumentValue
- `InvalidChunkSizeError`: Chunk, BadChunkSize
- `InvalidDigestError`: ContentMD5
- `InvalidLocationConstraintError`: LocationConstraint
- `InvalidPartError`: UploadId, PartNumber, ETag
- `InvalidRangeError`: RangeRequested, ActualObjectSize
- `InvalidTagError`: TagKey, TagValue
- `KeyTooLongError`: Size, MaxSizeAllowed
- `MetadataTooLargeError`: Size, MaxSizeAllowed
- `MethodNotAllowedError`: Method, ResourceType, AllowedMethods
- `NoSuchUploadError`: UploadId
- `NoSuchVersionError`: Key, VersionId
- `NotImplementedError`: Header, AdditionalMessage
- `PreconditionFailedError`: Condition
- `RequestTimeTooSkewedError`: RequestTime, ServerTime, MaxAllowedSkewMilliseconds
- `SignatureDoesNotMatchError`: AWSAccessKeyId, StringToSign, SignatureProvided, StringToSignBytes, CanonicalRequest, CanonicalRequestBytes

Fix CompleteMultipartUpload validation in the Azure backend so missing or empty `ETag` values return the appropriate S3 error instead of allowing a gateway panic.

Fix presigned authentication expiration validation to compare server time in `UTC`, matching the `UTC` timestamp used by presigned URL signing.

Add request ID and host ID support across S3 requests. Each request now receives AWS S3-like identifiers, returned in response headers as `x-amz-request-id` and `x-amz-id-2` and included in all XML error responses as RequestId and HostId. The generated ID structure is designed to resemble AWS S3 request IDs and host IDs.

The request signature calculation/validation for streaming uploads was previously delayed until the request body was fully read, both for Authorization header authentication and presigned URLs.
Now, the signature is validated immediately in the authorization middlewares without reading the request body, since the signature calculation itself does not depend on the request body. Instead, only the `x-amz-content-sha256` SHA-256 hash calculation is delayed.
2026-05-21 23:49:34 +04:00

427 lines
12 KiB
Go

// Copyright 2023 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package s3api
import (
"errors"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/metrics"
"github.com/versity/versitygw/s3api/controllers"
"github.com/versity/versitygw/s3api/middlewares"
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3event"
"github.com/versity/versitygw/s3log"
"github.com/versity/versitygw/webui"
)
const (
shutDownDuration = time.Second * 10
)
type S3ApiServer struct {
Router *S3ApiRouter
app *fiber.App
backend backend.Backend
CertStorage *utils.CertStorage
quiet bool
keepAlive bool
health string
maxConnections int
maxRequests int
webuiMountPrefix string
webuiSrvCfg *webui.ServerConfig
routes []routeMount
middlewares []middlewareMount
socketPerm os.FileMode
}
type routeMount struct {
method string
path string
handlers []fiber.Handler
}
type middlewareMount struct {
prefix string
handler fiber.Handler
}
func New(
be backend.Backend,
root middlewares.RootUserConfig,
region string,
iam auth.IAMService,
l s3log.AuditLogger,
adminLogger s3log.AuditLogger,
evs s3event.S3EventSender,
mm metrics.Manager,
opts ...Option,
) (*S3ApiServer, error) {
server := &S3ApiServer{
backend: be,
Router: &S3ApiRouter{
be: be,
iam: iam,
logger: l,
aLogger: adminLogger,
evs: evs,
mm: mm,
root: root,
region: region,
},
}
for _, opt := range opts {
opt(server)
}
app := fiber.New(fiber.Config{
AppName: "versitygw",
ServerHeader: "VERSITYGW",
StreamRequestBody: true,
DisableKeepalive: !server.keepAlive,
Network: fiber.NetworkTCP,
DisableStartupMessage: true,
ErrorHandler: globalErrorHandler,
Concurrency: server.maxConnections,
// Sets buffer limit to read/parse incoming requests
// if the limit is reached, fiber/fasthttp will throw an error
// in the global error handler
ReadBufferSize: 8 * 1024, // 8 KB
})
server.app = app
server.Router.app = app
// initialize the panic recovery middleware
app.Use(recover.New(
recover.Config{
EnableStackTrace: true,
StackTraceHandler: stackTraceHandler,
}))
// Logging middlewares
if !server.quiet {
app.Use(logger.New(logger.Config{
Format: "${time} | vgw | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
}))
}
// initialize requestId middleware
app.Use(middlewares.RequestIDs())
// Set up health endpoint if specified
if server.health != "" {
app.Get(server.health, func(ctx *fiber.Ctx) error {
return ctx.SendStatus(http.StatusOK)
})
}
// Set up WebUI on the S3 port if configured
if server.webuiSrvCfg != nil {
webui.MountOn(app, server.webuiMountPrefix, server.webuiSrvCfg)
}
// initialize total requests cap limiter middleware
app.Use(middlewares.RateLimiter(server.maxRequests, mm, l))
for _, route := range server.routes {
method, err := validateRouteMount(route)
if err != nil {
return nil, err
}
app.Add(method, route.path, route.handlers...)
}
for _, mount := range server.middlewares {
if err := validateMiddlewareMount(mount); err != nil {
return nil, err
}
app.Use(mount.prefix, mount.handler)
}
// initilaze the default value setter middleware
app.Use(middlewares.SetDefaultValues(root, region))
// initialize the 'DecodeURL' middleware which
// path unescapes the url
app.Use(controllers.WrapMiddleware(middlewares.DecodeURL, l, mm))
// initialize the debug logger in debug mode
if debuglogger.IsDebugEnabled() {
app.Use(middlewares.DebugLogger())
}
server.Router.Init()
return server, nil
}
func validateRouteMount(route routeMount) (string, error) {
if route.method == "" {
return "", fmt.Errorf("invalid route for path %q: empty method", route.path)
}
method := strings.ToUpper(route.method)
if !isStandardHTTPMethod(method) {
return "", fmt.Errorf("invalid HTTP method %q for route path %q: must be one of %s",
route.method, route.path, strings.Join(fiber.DefaultMethods, ", "))
}
if route.path == "" || route.path[0] != '/' {
return "", fmt.Errorf("invalid route path %q: must start with /", route.path)
}
if len(route.handlers) == 0 {
return "", fmt.Errorf("invalid route for %s %s: no handlers", method, route.path)
}
for i, handler := range route.handlers {
if handler == nil {
return "", fmt.Errorf("invalid route for %s %s: nil handler at index %d", method, route.path, i)
}
}
return method, nil
}
func isStandardHTTPMethod(method string) bool {
for _, valid := range fiber.DefaultMethods {
if method == valid {
return true
}
}
return false
}
func validateMiddlewareMount(mount middlewareMount) error {
if mount.prefix == "" || mount.prefix[0] != '/' {
return fmt.Errorf("invalid middleware prefix %q: must start with /", mount.prefix)
}
if mount.handler == nil {
return fmt.Errorf("invalid middleware for prefix %q: nil handler", mount.prefix)
}
return nil
}
// Option sets various options for New()
type Option func(*S3ApiServer)
// WithTLS sets TLS Credentials
func WithTLS(cs *utils.CertStorage) Option {
return func(s *S3ApiServer) { s.CertStorage = cs }
}
// WithAdminServer runs admin endpoints with the gateway in the same network
func WithAdminServer() Option {
return func(s *S3ApiServer) { s.Router.WithAdmSrv = true }
}
// WithQuiet silences default logging output
func WithQuiet() Option {
return func(s *S3ApiServer) { s.quiet = true }
}
// WithHealth sets up a GET health endpoint
func WithHealth(health string) Option {
return func(s *S3ApiServer) { s.health = health }
}
func WithReadOnly() Option {
return func(s *S3ApiServer) { s.Router.readonly = true }
}
// WithMpMaxParts sets the maximum number of parts allowed in a multipart upload.
func WithMpMaxParts(n int) Option {
return func(s *S3ApiServer) { s.Router.mpMaxParts = n }
}
// WithHostStyle enabled host-style bucket addressing on the server
func WithHostStyle(virtualDomain string) Option {
return func(s *S3ApiServer) {
s.Router.virtualDomain = virtualDomain
}
}
// WithKeepAlive enables the server keep alive
func WithKeepAlive() Option {
return func(s *S3ApiServer) { s.keepAlive = true }
}
// WithCORSAllowOrigin sets the default CORS Access-Control-Allow-Origin value.
// This is applied when no bucket CORS configuration exists, and for admin APIs.
func WithCORSAllowOrigin(origin string) Option {
return func(s *S3ApiServer) { s.Router.corsAllowOrigin = origin }
}
// WithWebUI mounts the WebUI on the S3 server's Fiber app at the given path prefix,
// before S3 routes are registered. The prefix must start with "/" and must not be
// empty or just "/".
func WithWebUI(prefix string, cfg *webui.ServerConfig) Option {
return func(s *S3ApiServer) {
s.webuiMountPrefix = prefix
s.webuiSrvCfg = cfg
}
}
// WithRoute registers a top-level Fiber route after the gateway rate limiter
// and before S3 routes are registered. Handlers are terminal unless they
// explicitly call ctx.Next().
func WithRoute(method, path string, handlers ...fiber.Handler) Option {
return func(s *S3ApiServer) {
copied := append([]fiber.Handler(nil), handlers...)
s.routes = append(s.routes, routeMount{
method: method,
path: path,
handlers: copied,
})
}
}
// WithMiddleware mounts a Fiber middleware after the gateway rate limiter and
// before the S3 route table is registered. The middleware must call ctx.Next()
// for requests it does not fully handle.
func WithMiddleware(prefix string, handler fiber.Handler) Option {
return func(s *S3ApiServer) {
s.middlewares = append(s.middlewares, middlewareMount{
prefix: prefix,
handler: handler,
})
}
}
// WithConcurrencyLimiter sets the server's maximum connection limit
// and the hard limit for in-flight requests.
func WithConcurrencyLimiter(maxConnections, maxRequests int) Option {
return func(s *S3ApiServer) {
s.maxConnections = maxConnections
s.maxRequests = maxRequests
}
}
// WithSocketPerm sets the file-mode permissions applied to file-backed UNIX
// domain sockets after binding. It has no effect on TCP/IP or abstract
// namespace sockets.
func WithSocketPerm(perm os.FileMode) Option {
return func(s *S3ApiServer) { s.socketPerm = perm }
}
// WithDisableACL disables the s3 api server ACLs, by ignoring all
// bucket/object ACL headers
func WithDisableACL() Option {
return func(s *S3ApiServer) { s.Router.disableACL = true }
}
// ServeMultiPort creates listeners for multiple port specifications and serves
// on all of them simultaneously. This supports listening on multiple ports and/or
// addresses (e.g., [":7070", "localhost:8080", "0.0.0.0:9090"]).
func (sa *S3ApiServer) ServeMultiPort(ports []string) error {
if len(ports) == 0 {
return fmt.Errorf("no ports specified")
}
// Multiple ports - create listeners for each
var listeners []net.Listener
for _, portSpec := range ports {
var ln net.Listener
var err error
if sa.CertStorage != nil {
ln, err = utils.NewMultiAddrTLSListener(sa.app.Config().Network, portSpec, sa.CertStorage.GetCertificate, utils.ListenerOptions{SocketPerm: sa.socketPerm})
} else {
ln, err = utils.NewMultiAddrListener(sa.app.Config().Network, portSpec, utils.ListenerOptions{SocketPerm: sa.socketPerm})
}
if err != nil {
return fmt.Errorf("failed to bind s3 listener %s: %w", portSpec, err)
}
listeners = append(listeners, ln)
}
if len(listeners) == 0 {
return fmt.Errorf("failed to create any s3 listeners")
}
// Combine all listeners
finalListener := utils.NewMultiListener(listeners...)
return sa.app.Listener(finalListener)
}
// ShutDown gracefully shuts down the server with a context timeout
func (sa *S3ApiServer) ShutDown() error {
return sa.app.ShutdownWithTimeout(shutDownDuration)
}
// stackTraceHandler stores the system panics
// in the context locals
func stackTraceHandler(ctx *fiber.Ctx, e any) {
utils.ContextKeyStack.Set(ctx, e)
}
// globalErrorHandler catches the errors before reaching to
// the handlers and any system panics
func globalErrorHandler(ctx *fiber.Ctx, er error) error {
requestID, hostID := utils.EnsureRequestIDs(ctx)
// set content type to application/xml
ctx.Response().Header.SetContentType(fiber.MIMEApplicationXML)
if utils.ContextKeyStack.IsSet(ctx) {
// if stack is set, it means the stack trace
// has caught a panic
// log it as a panic log
debuglogger.Panic(er)
} else {
// handle the fiber specific errors
var fiberErr *fiber.Error
if errors.As(er, &fiberErr) {
if errors.Is(fiberErr, fiber.ErrRequestHeaderFieldsTooLarge) {
debuglogger.Logf("total request headers size exceeds the allowed 8KB")
ctx.Status(http.StatusBadRequest)
return nil
}
if strings.Contains(fiberErr.Message, "cannot parse Content-Length") {
debuglogger.Logf("failed to parse Content-Length")
ctx.Status(http.StatusBadRequest)
return nil
}
if strings.Contains(fiberErr.Message, "error when reading request headers") {
// This error means fiber failed to parse the incoming request
// which is a malfoedmed one. Return a BadRequest in this case
debuglogger.Logf("failed to parse the http request")
err := s3err.GetAPIError(s3err.ErrCannotParseHTTPRequest)
return ctx.Status(err.StatusCode()).Send(err.XMLBody(requestID, hostID))
}
}
// additionally log the internal error
debuglogger.InternalError(er)
}
ctx.Status(http.StatusInternalServerError)
return ctx.Send(s3err.GetAPIError(s3err.ErrInternalError).XMLBody(requestID, hostID))
}