Files
versitygw/s3api/server.go
T
niksis02 ca8d434f8a fix: drain unread request bodies before closing the connection
The `UnsignedStreamingPayloadTrailer_invalid_chunk_size` integration test failed intermittently in CI with `write: connection reset by peer` instead of the expected `InvalidChunkSizeError`, and needed a rerun to pass. Its tenth case sends a 36KB `aws-chunked` payload that the chunk reader rejects roughly 8KB in, so the gateway answered and closed the connection while the client was still writing the remaining 28KB.

fasthttp streams request bodies (`StreamRequestBody`) and never drains what a handler leaves behind: after the handler returns it only calls `releaseRequestStream`, and with `DisableKeepalive` it breaks out of the serve loop and closes the socket with unread bytes still queued, so the kernel answers the client's in-flight writes with an RST. Go's `net/http` transport prefers a request body write error over an already received response, so on the losing side of that race the client never sees the S3 error XML at all. The same exposure applies to every early rejection, not just chunk framing: signature failures, missing buckets and policy denials are all decided before the payload is read.

The new `DrainRequestBody` middleware reads and discards whatever is left of the body once the handler chain is done with it, so the client can finish its write and read the real error. It is registered right after the panic recovery middleware and before every route, so it wraps all of them. The drain is capped at 256KB, matching net/http's `maxPostHandlerReadBytes`, so a rejected multi-gigabyte upload is not streamed through the gateway just to be thrown away, and it is bounded by a one second idle deadline and a five second total deadline so a client that stops sending cannot pin a worker. Bodies with more than 256KB still unread are deliberately left alone, and those clients can still see a reset.

Only `Content-Length` framed bodies are drained. fasthttp's `requestStream` reports EOF idempotently for those, but for a chunked body it goes back to the socket for another chunk header past the terminating chunk, so re-reading one the handler had already finished would block until the deadline and hold a successful response back with it.

This also fixes a second problem found while testing the first. With `--keep-alive`, nothing in fasthttp sets `connectionClose` when a streamed body is left unread, and the same `bufio.Reader` is reused for the next request on that connection, so leftover upload bytes were parsed and served as a separate HTTP request: a `GET` placed inside a `PUT` body was routed, answered and written to the access log. The middleware now sets `Connection: close` whenever the drain does not reach EOF, so a connection that may still hold body bytes is never reused. `--keep-alive` is off by default and is not set by any shipped deployment artifact.
2026-09-02 18:07:31 +04:00

460 lines
14 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/v3"
"github.com/gofiber/fiber/v3/middleware/logger"
"github.com/gofiber/fiber/v3/middleware/recover"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/internal/netutil"
"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
requestHeaderMaxSize = 8 * 1024
)
type S3ApiServer struct {
Router *S3ApiRouter
app *fiber.App
backend backend.Backend
CertStorage *netutil.CertStorage
quiet bool
keepAlive bool
health string
maxConnections int
maxRequests int
webuiMountPrefix string
webuiSrvCfg *webui.ServerConfig
routes []routeMount
middlewares []middlewareMount
socketPerm os.FileMode
onListen func()
}
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,
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: requestHeaderMaxSize,
})
server.app = app
server.Router.app = app
// initialize the panic recovery middleware
app.Use("*", recover.New(
recover.Config{
EnableStackTrace: true,
StackTraceHandler: stackTraceHandler,
}))
// initialize the request body drainer. it goes right after the panic
// recovery and before every route, so it wraps all of them and a panic in
// the drain itself is still recovered
app.Use("*", middlewares.DrainRequestBody())
// Logging middlewares
if !server.quiet {
app.Use("*", logger.New(logger.Config{
Format: "${time} | vgw | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
CustomTags: map[string]logger.LogFunc{
logger.TagQueryStringParams: debuglogger.RedactedQueryParamsTag,
},
}))
}
// 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 {
if err := webui.MountOn(app, server.webuiMountPrefix, server.webuiSrvCfg); err != nil {
return nil, fmt.Errorf("mount webui: %w", err)
}
}
// 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
}
handlers := make([]any, len(route.handlers)-1)
for i := range handlers {
handlers[i] = route.handlers[i+1]
}
app.Add([]string{method}, route.path, route.handlers[0], 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 *netutil.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 }
}
// WithOnListen registers a callback that is invoked once the server is bound
// and ready to accept requests. It is called from the fiber OnListen hook,
// which fires immediately before the server begins serving the first
// connection. Use this instead of a fixed sleep to detect readiness reliably.
func WithOnListen(fn func()) Option {
return func(s *S3ApiServer) { s.onListen = fn }
}
// 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 = netutil.NewMultiAddrTLSListener(fiber.NetworkTCP, portSpec, sa.CertStorage.GetCertificate, netutil.ListenerOptions{SocketPerm: sa.socketPerm})
} else {
ln, err = netutil.NewMultiAddrListener(fiber.NetworkTCP, portSpec, netutil.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 := netutil.NewMultiListener(listeners...)
if sa.onListen != nil {
fn := sa.onListen
sa.app.Hooks().OnListen(func(fiber.ListenData) error {
fn()
return nil
})
}
return sa.app.Listener(finalListener, fiber.ListenConfig{
DisableStartupMessage: true,
})
}
// 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")
err := s3err.GetRequestHeaderSectionTooLargeErr(requestHeaderMaxSize)
return ctx.Status(err.StatusCode()).Send(err.XMLBody(requestID, hostID))
}
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))
}