refactor: update handler methods to use interfaces for admin and S3 services

Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
Noooste
2026-04-17 16:48:25 +02:00
parent 5e68a77e15
commit f63ce3452e
8 changed files with 179 additions and 33 deletions
+7
View File
@@ -244,6 +244,13 @@ func (c *Config) Validate() error {
if len(c.Auth.OIDC.Scopes) == 0 { if len(c.Auth.OIDC.Scopes) == 0 {
return fmt.Errorf("oidc scopes are required when oidc is enabled") return fmt.Errorf("oidc scopes are required when oidc is enabled")
} }
// Every authenticated route on this service grants full admin
// access — there is no separate authorization layer. An empty
// admin_role would therefore promote every user in the IdP realm
// to cluster admin. Require operators to opt in explicitly.
if c.Auth.OIDC.AdminRole == "" {
return fmt.Errorf("oidc admin_role is required when oidc is enabled: leaving it empty would grant cluster-admin access to any authenticated IdP user")
}
} }
return nil return nil
+5 -5
View File
@@ -7,14 +7,14 @@ import (
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
) )
// BucketHandler handles bucket-related operations // BucketHandler handles bucket-related HTTP requests.
type BucketHandler struct { type BucketHandler struct {
adminService *services.GarageAdminService adminService services.AdminService
s3Service *services.S3Service s3Service services.S3Storage
} }
// NewBucketHandler creates a new bucket handler // NewBucketHandler creates a new bucket handler.
func NewBucketHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *BucketHandler { func NewBucketHandler(adminService services.AdminService, s3Service services.S3Storage) *BucketHandler {
return &BucketHandler{ return &BucketHandler{
adminService: adminService, adminService: adminService,
s3Service: s3Service, s3Service: s3Service,
+4 -4
View File
@@ -7,13 +7,13 @@ import (
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
) )
// ClusterHandler handles cluster management operations // ClusterHandler handles cluster-status HTTP requests.
type ClusterHandler struct { type ClusterHandler struct {
adminService *services.GarageAdminService adminService services.AdminService
} }
// NewClusterHandler creates a new cluster handler // NewClusterHandler creates a new cluster handler.
func NewClusterHandler(adminService *services.GarageAdminService) *ClusterHandler { func NewClusterHandler(adminService services.AdminService) *ClusterHandler {
return &ClusterHandler{ return &ClusterHandler{
adminService: adminService, adminService: adminService,
} }
+5 -5
View File
@@ -7,14 +7,14 @@ import (
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
) )
// MonitoringHandler handles monitoring operations // MonitoringHandler handles metrics and dashboard HTTP requests.
type MonitoringHandler struct { type MonitoringHandler struct {
adminService *services.GarageAdminService adminService services.AdminService
s3Service *services.S3Service s3Service services.S3Storage
} }
// NewMonitoringHandler creates a new monitoring handler // NewMonitoringHandler creates a new monitoring handler.
func NewMonitoringHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *MonitoringHandler { func NewMonitoringHandler(adminService services.AdminService, s3Service services.S3Storage) *MonitoringHandler {
return &MonitoringHandler{ return &MonitoringHandler{
adminService: adminService, adminService: adminService,
s3Service: s3Service, s3Service: s3Service,
+71 -9
View File
@@ -3,7 +3,10 @@ package handlers
import ( import (
"bufio" "bufio"
"io" "io"
"net/url"
"path"
"strconv" "strconv"
"strings"
"time" "time"
"Noooste/garage-ui/internal/models" "Noooste/garage-ui/internal/models"
@@ -12,13 +15,66 @@ import (
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
) )
// ObjectHandler handles object-related operations // unsafeInlineContentTypes are MIME types that a browser can execute as
type ObjectHandler struct { // JavaScript in the response's origin when rendered inline. Since the SPA is
s3Service *services.S3Service // served from the same origin as the API, any uploader could otherwise plant
// stored XSS by uploading a file with one of these Content-Types.
var unsafeInlineContentTypes = map[string]struct{}{
"text/html": {},
"application/xhtml+xml": {},
"image/svg+xml": {},
"application/xml": {},
"text/xml": {},
"application/javascript": {},
"text/javascript": {},
} }
// NewObjectHandler creates a new object handler // safeContentType rewrites Content-Types that the browser would treat as
func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler { // executable to application/octet-stream.
func safeContentType(ct string) string {
base := strings.TrimSpace(strings.ToLower(ct))
if i := strings.IndexByte(base, ';'); i >= 0 {
base = strings.TrimSpace(base[:i])
}
if _, bad := unsafeInlineContentTypes[base]; bad {
return "application/octet-stream"
}
return ct
}
// contentDispositionHeader builds an RFC 6266 / RFC 5987 Content-Disposition
// header value with the user-controlled object key safely encoded. Strips
// path components and control characters before emitting the ASCII fallback,
// then appends the percent-encoded UTF-8 filename*= for full fidelity.
func contentDispositionHeader(disposition, key string) string {
name := path.Base(key)
if name == "." || name == "/" || name == "" {
name = "download"
}
// ASCII-safe fallback: drop anything that could break the quoted value.
var asciiFallback strings.Builder
for _, r := range name {
if r < 0x20 || r == 0x7f || r == '"' || r == '\\' || r > 0x7e {
asciiFallback.WriteByte('_')
continue
}
asciiFallback.WriteRune(r)
}
fallback := asciiFallback.String()
if fallback == "" {
fallback = "download"
}
encoded := url.PathEscape(name)
return disposition + "; filename=\"" + fallback + "\"; filename*=UTF-8''" + encoded
}
// ObjectHandler handles object-related HTTP requests.
type ObjectHandler struct {
s3Service services.S3Storage
}
// NewObjectHandler creates a new object handler.
func NewObjectHandler(s3Service services.S3Storage) *ObjectHandler {
return &ObjectHandler{ return &ObjectHandler{
s3Service: s3Service, s3Service: s3Service,
} }
@@ -178,16 +234,22 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
) )
} }
// Set response headers // The uploader controls Content-Type. Rewrite executable MIME types to
c.Set("Content-Type", objectInfo.ContentType) // application/octet-stream and always disable sniffing so stored HTML/SVG
// cannot run as XSS in the SPA origin when fetched inline.
c.Set("Content-Type", safeContentType(objectInfo.ContentType))
c.Set("X-Content-Type-Options", "nosniff")
c.Set("Content-Length", strconv.FormatInt(objectInfo.Size, 10)) c.Set("Content-Length", strconv.FormatInt(objectInfo.Size, 10))
c.Set("ETag", objectInfo.ETag) c.Set("ETag", objectInfo.ETag)
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123)) c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
// Check if client wants to download or view inline // The object key is attacker-controlled — build the header via the safe
// RFC 6266 helper to avoid quote/semicolon injection into filename=.
disposition := "inline"
if c.Query("download") == "true" { if c.Query("download") == "true" {
c.Set("Content-Disposition", "attachment; filename=\""+key+"\"") disposition = "attachment"
} }
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
// Stream the object body to the client without buffering the entire file // Stream the object body to the client without buffering the entire file
return c.SendStreamWriter(func(w *bufio.Writer) { return c.SendStreamWriter(func(w *bufio.Writer) {
+4 -4
View File
@@ -9,13 +9,13 @@ import (
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
) )
// UserHandler handles user/key management operations using Garage Admin API // UserHandler handles user and access key HTTP requests.
type UserHandler struct { type UserHandler struct {
adminService *services.GarageAdminService adminService services.AdminService
} }
// NewUserHandler creates a new user handler // NewUserHandler creates a new user handler.
func NewUserHandler(adminService *services.GarageAdminService) *UserHandler { func NewUserHandler(adminService services.AdminService) *UserHandler {
return &UserHandler{ return &UserHandler{
adminService: adminService, adminService: adminService,
} }
+15 -6
View File
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"strconv"
"strings" "strings"
"Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/config"
@@ -20,10 +21,14 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
return func(c fiber.Ctx) error { return func(c fiber.Ctx) error {
origin := c.Get("Origin") origin := c.Get("Origin")
// Check if origin is allowed // Check if origin is allowed. When credentials are allowed we refuse
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins) { // to treat "*" as a match: reflecting an arbitrary Origin alongside
// Access-Control-Allow-Credentials: true lets any site read responses
// cross-origin with the user's session cookie.
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins, cfg.AllowCredentials) {
// Set CORS headers // Set CORS headers
c.Set("Access-Control-Allow-Origin", origin) c.Set("Access-Control-Allow-Origin", origin)
c.Set("Vary", "Origin")
if cfg.AllowCredentials { if cfg.AllowCredentials {
c.Set("Access-Control-Allow-Credentials", "true") c.Set("Access-Control-Allow-Credentials", "true")
@@ -41,7 +46,7 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
// Set max age for preflight cache // Set max age for preflight cache
if cfg.MaxAge > 0 { if cfg.MaxAge > 0 {
c.Set("Access-Control-Max-Age", string(rune(cfg.MaxAge))) c.Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAge))
} }
} }
@@ -54,10 +59,14 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
} }
} }
// isAllowedOrigin checks if an origin is in the allowed list // isAllowedOrigin checks if an origin is in the allowed list.
func isAllowedOrigin(origin string, allowedOrigins []string) bool { // When allowCredentials is true, "*" is NOT honored — exact match is required.
func isAllowedOrigin(origin string, allowedOrigins []string, allowCredentials bool) bool {
for _, allowed := range allowedOrigins { for _, allowed := range allowedOrigins {
if allowed == "*" || allowed == origin { if allowed == origin {
return true
}
if allowed == "*" && !allowCredentials {
return true return true
} }
} }
+68
View File
@@ -0,0 +1,68 @@
package services
import (
"context"
"io"
"time"
"Noooste/garage-ui/internal/models"
)
// AdminService is the set of Garage Admin API operations used by HTTP handlers.
// It is implemented by *GarageAdminService in admin.go. Kept narrow so that
// hand-rolled mocks in tests don't need to cover admin methods the handlers
// never call.
type AdminService interface {
// Access keys
ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error)
CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error)
GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error)
UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error)
DeleteKey(ctx context.Context, keyID string) error
// Buckets
ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error)
GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error)
GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error)
CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error)
UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error)
DeleteBucket(ctx context.Context, bucketID string) error
AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
// Cluster
GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error)
GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error)
GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error)
GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error)
GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error)
// Monitoring
HealthCheck(ctx context.Context) error
GetMetrics(ctx context.Context) (string, error)
}
// S3Storage is the set of S3 operations used by HTTP handlers. It is
// implemented by *S3Service in s3.go. Methods on *S3Service that are not
// called by handlers (ListBuckets, CreateBucket, DeleteBucket,
// GetBucketStatistics) are intentionally excluded.
type S3Storage interface {
ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error)
UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
ObjectExists(ctx context.Context, bucketName, key string) (bool, error)
DeleteObject(ctx context.Context, bucketName, key string) error
GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error
UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
Key string
Body io.Reader
ContentType string
}) []UploadResult
}
// Compile-time guarantees that the concrete services implement the interfaces.
var (
_ AdminService = (*GarageAdminService)(nil)
_ S3Storage = (*S3Service)(nil)
)