diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7709bc6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "jira-plugin.workingProject": "" +} \ No newline at end of file diff --git a/README.md b/README.md index 1b2f6ce..19ed952 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ + # go-akavelink A Go-based HTTP server that wraps the Akave SDK, exposing Akave APIs over REST. The previous version of this repository was a CLI wrapper around the Akave SDK; refer to [akavelink](https://github.com/akave-ai/akavelink). @@ -102,9 +103,76 @@ Implemented routes (see `internal/handlers/`): --- -## Project Structure +## Input Validation & Security + +The API implements comprehensive input validation and sanitization to protect against common attacks: + +### Bucket Name Validation + +- **Length**: 1-63 characters +- **Allowed characters**: Alphanumeric (a-z, A-Z, 0-9), hyphens (-), underscores (_) +- **Format**: Must start and end with an alphanumeric character +- **Security**: Prevents path traversal attacks, special characters, and malicious patterns + +**Examples:** +- ✅ Valid: `my-bucket`, `data_store_123`, `bucket1` +- ❌ Invalid: `../etc`, `my bucket`, `bucket!@#`, `-bucket`, `bucket-` + +### File Name Validation + +- **Length**: Maximum 255 characters +- **Allowed characters**: Alphanumeric (a-z, A-Z, 0-9), dots (.), hyphens (-), underscores (_) +- **Format**: Must start and end with an alphanumeric character +- **Security**: Prevents path traversal, null bytes, and invalid filename characters + +**Examples:** +- ✅ Valid: `document.pdf`, `my-file_v2.txt`, `data.backup.tar.gz` +- ❌ Invalid: `../../../etc/passwd`, `file<>name.txt`, `.hidden`, `file.` + +### File Upload Validation + +- **Maximum file size**: 100 MB (104,857,600 bytes) +- **Minimum file size**: Must not be empty (> 0 bytes) +- **Allowed MIME types**: + - `application/octet-stream` (binary/default) + - `text/plain`, `text/csv` + - `application/json`, `application/pdf` + - `image/jpeg`, `image/png`, `image/gif`, `image/webp` + - `video/mp4` + - `audio/mpeg` +**Security features:** +- Content-Length validation before parsing +- MIME type verification +- File name sanitization +- Protection against malicious file uploads + +### Error Responses + +Validation errors return HTTP 400 (Bad Request) with a JSON response: + +```json +{ + "error": "Validation Error", + "field": "bucketName", + "message": "bucket name must contain only alphanumeric characters, hyphens, and underscores" +} ``` + +### Security Headers + +All responses include security headers: +- `X-Content-Type-Options: nosniff` - Prevents MIME type sniffing +- `X-Frame-Options: DENY` - Prevents clickjacking +- `X-XSS-Protection: 1; mode=block` - Enables XSS protection +- `Content-Security-Policy: default-src 'self'` - Restricts resource loading + +--- + +## Project Structure + +```markdown + go-akavelink/ ├── CONTRIBUTING.md ├── LICENSE @@ -118,11 +186,15 @@ go-akavelink/ │ └── architecture.md ├── internal/ │ ├── handlers/ -│ │ ├── router.go # Wires routes only +│ │ ├── router.go # Wires routes and middleware │ │ ├── response.go # JSON envelope + helpers │ │ ├── health.go # /health │ │ ├── buckets.go # bucket endpoints │ │ └── files.go # file endpoints +│ ├── middleware/ +│ │ └── validation.go # Validation middleware +│ ├── validation/ +│ │ └── validator.go # Input validation & sanitization │ ├── sdk/ │ │ └── sdk.go │ └── utils/ @@ -131,7 +203,10 @@ go-akavelink/ ├── http_endpoints_test.go ├── integration_sdk_test.go ├── main_test.go - └── sdk_test.go + ├── sdk_test.go + ├── validation_test.go + ├── middleware_test.go + └── validation_integration_test.go ``` --- diff --git a/go.mod b/go.mod index ee35346..062376d 100644 --- a/go.mod +++ b/go.mod @@ -63,6 +63,7 @@ require ( github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect github.com/spacemonkeygo/monkit/v3 v3.0.24 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/supranational/blst v0.3.14 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect diff --git a/go.sum b/go.sum index f46aa31..b405993 100644 --- a/go.sum +++ b/go.sum @@ -357,6 +357,8 @@ github.com/spacemonkeygo/monkit/v3 v3.0.24/go.mod h1:XkZYGzknZwkD0AKUnZaSXhRiVTL github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= diff --git a/internal/handlers/buckets.go b/internal/handlers/buckets.go index fd42345..64b79ca 100644 --- a/internal/handlers/buckets.go +++ b/internal/handlers/buckets.go @@ -5,6 +5,7 @@ import ( "log" "net/http" + "github.com/akave-ai/go-akavelink/internal/validation" "github.com/gorilla/mux" ) @@ -17,11 +18,16 @@ func (s *Server) createBucketHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) bucketName := vars["bucketName"] - if bucketName == "" { - s.writeErrorResponse(w, http.StatusBadRequest, "bucketName is required") + + // Validate bucket name + if err := validation.ValidateBucketName(bucketName); err != nil { + s.writeErrorResponse(w, http.StatusBadRequest, err.Error()) return } + // Sanitize bucket name for extra safety + bucketName = validation.SanitizeBucketName(bucketName) + if err := s.client.CreateBucket(r.Context(), bucketName); err != nil { s.writeErrorResponse(w, http.StatusInternalServerError, "Failed to create bucket") log.Printf("create bucket error: %v", err) @@ -42,11 +48,16 @@ func (s *Server) deleteBucketHandler(w http.ResponseWriter, r *http.Request) { } vars := mux.Vars(r) bucketName := vars["bucketName"] - if bucketName == "" { - s.writeErrorResponse(w, http.StatusBadRequest, "bucketName is required") + + // Validate bucket name + if err := validation.ValidateBucketName(bucketName); err != nil { + s.writeErrorResponse(w, http.StatusBadRequest, err.Error()) return } + // Sanitize bucket name for extra safety + bucketName = validation.SanitizeBucketName(bucketName) + ctx := r.Context() files, err := s.client.ListFiles(ctx, bucketName) if err != nil { diff --git a/internal/handlers/files.go b/internal/handlers/files.go index d7d8fa5..69c1c12 100644 --- a/internal/handlers/files.go +++ b/internal/handlers/files.go @@ -4,6 +4,7 @@ import ( "log" "net/http" + "github.com/akave-ai/go-akavelink/internal/validation" "github.com/gorilla/mux" ) @@ -12,11 +13,23 @@ func (s *Server) fileInfoHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) bucketName := vars["bucketName"] fileName := vars["fileName"] - if bucketName == "" || fileName == "" { - s.writeErrorResponse(w, http.StatusBadRequest, "bucketName and fileName are required") + + // Validate bucket name + if err := validation.ValidateBucketName(bucketName); err != nil { + s.writeErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + // Validate file name + if err := validation.ValidateFileName(fileName); err != nil { + s.writeErrorResponse(w, http.StatusBadRequest, err.Error()) return } + // Sanitize inputs + bucketName = validation.SanitizeBucketName(bucketName) + fileName = validation.SanitizeFileName(fileName) + info, err := s.client.FileInfo(r.Context(), bucketName, fileName) if err != nil { s.writeErrorResponse(w, http.StatusInternalServerError, "Failed to retrieve file info") @@ -31,11 +44,16 @@ func (s *Server) fileInfoHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) listFilesHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) bucketName := vars["bucketName"] - if bucketName == "" { - s.writeErrorResponse(w, http.StatusBadRequest, "bucketName is required") + + // Validate bucket name + if err := validation.ValidateBucketName(bucketName); err != nil { + s.writeErrorResponse(w, http.StatusBadRequest, err.Error()) return } + // Sanitize bucket name + bucketName = validation.SanitizeBucketName(bucketName) + files, err := s.client.ListFiles(r.Context(), bucketName) if err != nil { s.writeErrorResponse(w, http.StatusInternalServerError, "Failed to list files") @@ -50,8 +68,19 @@ func (s *Server) listFilesHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) uploadHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) bucketName := vars["bucketName"] - if bucketName == "" { - s.writeErrorResponse(w, http.StatusBadRequest, "bucketName is required") + + // Validate bucket name + if err := validation.ValidateBucketName(bucketName); err != nil { + s.writeErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + // Sanitize bucket name + bucketName = validation.SanitizeBucketName(bucketName) + + // Validate content length before parsing + if err := validation.ValidateContentLength(r.ContentLength); err != nil { + s.writeErrorResponse(w, http.StatusBadRequest, err.Error()) return } @@ -73,8 +102,17 @@ func (s *Server) uploadHandler(w http.ResponseWriter, r *http.Request) { } }() + // Validate file upload (size, MIME type, filename) + if err := validation.ValidateFileUpload(handler); err != nil { + s.writeErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + // Sanitize filename + fileName := validation.SanitizeFileName(handler.Filename) + ctx := r.Context() - upload, err := s.client.CreateFileUpload(ctx, bucketName, handler.Filename) + upload, err := s.client.CreateFileUpload(ctx, bucketName, fileName) if err != nil { s.writeErrorResponse(w, http.StatusInternalServerError, "Failed to create file upload stream") log.Printf("create upload error: %v", err) @@ -89,14 +127,16 @@ func (s *Server) uploadHandler(w http.ResponseWriter, r *http.Request) { } resp := map[string]interface{}{ - "message": "File uploaded successfully", - "rootCID": meta.RootCID, - "bucketName": meta.BucketName, - "fileName": meta.Name, - "size": meta.Size, - "encodedSize": meta.EncodedSize, - "createdAt": meta.CreatedAt, - "committedAt": meta.CommittedAt, + "message": "File uploaded successfully", + "rootCID": meta.RootCID, + "bucketName": meta.BucketName, + "fileName": meta.Name, + "originalName": handler.Filename, + "sanitizedName": fileName, + "size": meta.Size, + "encodedSize": meta.EncodedSize, + "createdAt": meta.CreatedAt, + "committedAt": meta.CommittedAt, } s.writeSuccessResponse(w, http.StatusCreated, resp) } @@ -106,11 +146,23 @@ func (s *Server) downloadHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) bucketName := vars["bucketName"] fileName := vars["fileName"] - if bucketName == "" || fileName == "" { - s.writeErrorResponse(w, http.StatusBadRequest, "bucketName and fileName are required") + + // Validate bucket name + if err := validation.ValidateBucketName(bucketName); err != nil { + s.writeErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + // Validate file name + if err := validation.ValidateFileName(fileName); err != nil { + s.writeErrorResponse(w, http.StatusBadRequest, err.Error()) return } + // Sanitize inputs + bucketName = validation.SanitizeBucketName(bucketName) + fileName = validation.SanitizeFileName(fileName) + dl, err := s.client.CreateFileDownload(r.Context(), bucketName, fileName) if err != nil { s.writeErrorResponse(w, http.StatusNotFound, "Failed to create file download") @@ -131,11 +183,23 @@ func (s *Server) fileDeleteHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) bucketName := vars["bucketName"] fileName := vars["fileName"] - if bucketName == "" || fileName == "" { - s.writeErrorResponse(w, http.StatusBadRequest, "bucketName and fileName are required") + + // Validate bucket name + if err := validation.ValidateBucketName(bucketName); err != nil { + s.writeErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + // Validate file name + if err := validation.ValidateFileName(fileName); err != nil { + s.writeErrorResponse(w, http.StatusBadRequest, err.Error()) return } + // Sanitize inputs + bucketName = validation.SanitizeBucketName(bucketName) + fileName = validation.SanitizeFileName(fileName) + if err := s.client.FileDelete(r.Context(), bucketName, fileName); err != nil { s.writeErrorResponse(w, http.StatusInternalServerError, "Failed to delete file") log.Printf("file delete error: %v", err) diff --git a/internal/handlers/router.go b/internal/handlers/router.go index 4b520f0..d794d30 100644 --- a/internal/handlers/router.go +++ b/internal/handlers/router.go @@ -6,6 +6,7 @@ import ( "io" "net/http" + "github.com/akave-ai/go-akavelink/internal/middleware" "github.com/gorilla/mux" sdksym "github.com/akave-ai/akavesdk/sdk" @@ -41,16 +42,23 @@ func NewRouter(client ClientAPI) http.Handler { r := mux.NewRouter().StrictSlash(true) s := &Server{client: client} + // Apply global middleware + r.Use(middleware.SecurityHeaders) + r.Use(middleware.LogRequest) + + // Health check (no validation needed) r.HandleFunc("/health", s.healthHandler).Methods("GET") + // Buckets r.HandleFunc("/buckets/{bucketName}", s.createBucketHandler).Methods("POST") r.HandleFunc("/buckets/{bucketName}", s.deleteBucketHandler).Methods("DELETE") - // Normalized route without trailing slash; keep both for compatibility r.HandleFunc("/buckets", s.viewBucketHandler).Methods("GET") - // r.HandleFunc("/buckets/", viewBucketHandler).Methods("GET") - // Files + + // Files - List and Upload (bucket name only) r.HandleFunc("/buckets/{bucketName}/files", s.listFilesHandler).Methods("GET") r.HandleFunc("/buckets/{bucketName}/files", s.uploadHandler).Methods("POST") + + // Files - Operations requiring both bucket and file name r.HandleFunc("/buckets/{bucketName}/files/{fileName}", s.fileInfoHandler).Methods("GET") r.HandleFunc("/buckets/{bucketName}/files/{fileName}/download", s.downloadHandler).Methods("GET") r.HandleFunc("/buckets/{bucketName}/files/{fileName}", s.fileDeleteHandler).Methods("DELETE") diff --git a/internal/middleware/validation.go b/internal/middleware/validation.go new file mode 100644 index 0000000..cd1d4fb --- /dev/null +++ b/internal/middleware/validation.go @@ -0,0 +1,158 @@ +// Package middleware provides HTTP middleware for the AkaveLink API. +package middleware + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/akave-ai/go-akavelink/internal/validation" + "github.com/gorilla/mux" +) + +// ErrorResponse represents an error response. +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` + Field string `json:"field,omitempty"` +} + +// writeValidationError writes a validation error response. +func writeValidationError(w http.ResponseWriter, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + + response := ErrorResponse{ + Error: "Validation Error", + } + + if validationErr, ok := err.(*validation.ValidationError); ok { + response.Field = validationErr.Field + response.Message = validationErr.Message + } else { + response.Message = err.Error() + } + + if encodeErr := json.NewEncoder(w).Encode(response); encodeErr != nil { + log.Printf("Error encoding validation error response: %v", encodeErr) + } +} + +// ValidateBucketName is a middleware that validates bucket name from URL parameters. +func ValidateBucketName(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + bucketName := vars["bucketName"] + + if err := validation.ValidateBucketName(bucketName); err != nil { + writeValidationError(w, err) + return + } + + // Sanitize the bucket name for extra safety + sanitized := validation.SanitizeBucketName(bucketName) + if sanitized != bucketName { + log.Printf("Warning: Bucket name was sanitized from '%s' to '%s'", bucketName, sanitized) + } + + next.ServeHTTP(w, r) + }) +} + +// ValidateFileName is a middleware that validates file name from URL parameters. +func ValidateFileName(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + fileName := vars["fileName"] + + if err := validation.ValidateFileName(fileName); err != nil { + writeValidationError(w, err) + return + } + + // Sanitize the file name for extra safety + sanitized := validation.SanitizeFileName(fileName) + if sanitized != fileName { + log.Printf("Warning: File name was sanitized from '%s' to '%s'", fileName, sanitized) + } + + next.ServeHTTP(w, r) + }) +} + +// ValidateBucketAndFileName is a middleware that validates both bucket and file names. +func ValidateBucketAndFileName(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + bucketName := vars["bucketName"] + fileName := vars["fileName"] + + // Validate bucket name + if err := validation.ValidateBucketName(bucketName); err != nil { + writeValidationError(w, err) + return + } + + // Validate file name + if err := validation.ValidateFileName(fileName); err != nil { + writeValidationError(w, err) + return + } + + next.ServeHTTP(w, r) + }) +} + +// ValidateContentLength is a middleware that validates Content-Length header. +func ValidateContentLength(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + contentLength := r.ContentLength + + if err := validation.ValidateContentLength(contentLength); err != nil { + writeValidationError(w, err) + return + } + + next.ServeHTTP(w, r) + }) +} + +// RateLimitByIP is a placeholder for rate limiting middleware. +// This can be implemented with a proper rate limiting library like golang.org/x/time/rate +func RateLimitByIP(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // TODO: Implement rate limiting based on IP address + // For now, just pass through + next.ServeHTTP(w, r) + }) +} + +// SecurityHeaders adds security-related HTTP headers. +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Prevent MIME type sniffing + w.Header().Set("X-Content-Type-Options", "nosniff") + + // Prevent clickjacking + w.Header().Set("X-Frame-Options", "DENY") + + // Enable XSS protection + w.Header().Set("X-XSS-Protection", "1; mode=block") + + // Enforce HTTPS (if applicable) + // w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + + // Content Security Policy + w.Header().Set("Content-Security-Policy", "default-src 'self'") + + next.ServeHTTP(w, r) + }) +} + +// LogRequest logs incoming HTTP requests. +func LogRequest(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s %s", r.Method, r.RequestURI, r.RemoteAddr) + next.ServeHTTP(w, r) + }) +} diff --git a/internal/validation/validator.go b/internal/validation/validator.go new file mode 100644 index 0000000..60b0f7c --- /dev/null +++ b/internal/validation/validator.go @@ -0,0 +1,284 @@ +// Package validation provides input validation and sanitization for the AkaveLink API. +package validation + +import ( + "fmt" + "mime/multipart" + "path/filepath" + "regexp" + "strings" +) + +const ( + // Bucket name constraints + MinBucketNameLength = 1 + MaxBucketNameLength = 63 + + // File name constraints + MaxFileNameLength = 255 + MaxFileSize = 100 * 1024 * 1024 // 100 MB + + // Allowed MIME types for uploads + AllowedMIMETypes = "application/octet-stream|text/plain|text/csv|application/json|application/pdf|image/jpeg|image/png|image/gif|image/webp|video/mp4|audio/mpeg" +) + +var ( + // bucketNameRegex validates bucket names: alphanumeric, hyphens, underscores only + bucketNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*[a-zA-Z0-9]$`) + + // fileNameRegex validates file names: alphanumeric, hyphens, underscores, dots only + fileNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]$`) + + // pathTraversalPatterns detects path traversal attempts + pathTraversalPatterns = []*regexp.Regexp{ + regexp.MustCompile(`\.\.`), // Parent directory reference + regexp.MustCompile(`^/`), // Absolute path + regexp.MustCompile(`\\`), // Windows path separator + regexp.MustCompile(`%2e%2e`), // URL encoded .. + regexp.MustCompile(`%2f`), // URL encoded / + regexp.MustCompile(`%5c`), // URL encoded \ + regexp.MustCompile(`\x00`), // Null byte + regexp.MustCompile(`[<>:"|?*\x00-\x1f]`), // Invalid filename characters + } + + // allowedMIMETypesMap for quick lookup + allowedMIMETypesMap = buildMIMETypeMap() +) + +// ValidationError represents a validation error with details. +type ValidationError struct { + Field string + Message string +} + +// Error implements the error interface. +func (e *ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Field, e.Message) +} + +// buildMIMETypeMap creates a map of allowed MIME types. +func buildMIMETypeMap() map[string]bool { + types := strings.Split(AllowedMIMETypes, "|") + m := make(map[string]bool, len(types)) + for _, t := range types { + m[t] = true + } + return m +} + +// ValidateBucketName validates bucket name according to rules: +// - Length between 1-63 characters +// - Alphanumeric characters, hyphens, and underscores only +// - Must start and end with alphanumeric character +// - No path traversal patterns +func ValidateBucketName(name string) error { + if name == "" { + return &ValidationError{Field: "bucketName", Message: "bucket name is required"} + } + + if len(name) < MinBucketNameLength || len(name) > MaxBucketNameLength { + return &ValidationError{ + Field: "bucketName", + Message: fmt.Sprintf("bucket name must be between %d and %d characters", MinBucketNameLength, MaxBucketNameLength), + } + } + + // Check for path traversal patterns first + if containsPathTraversal(name) { + return &ValidationError{ + Field: "bucketName", + Message: "bucket name contains invalid characters or patterns", + } + } + + // For single character names, just check if alphanumeric + if len(name) == 1 { + if !((name[0] >= 'a' && name[0] <= 'z') || (name[0] >= 'A' && name[0] <= 'Z') || (name[0] >= '0' && name[0] <= '9')) { + return &ValidationError{ + Field: "bucketName", + Message: "bucket name must contain only alphanumeric characters, hyphens, and underscores, and must start and end with an alphanumeric character", + } + } + return nil + } + + if !bucketNameRegex.MatchString(name) { + return &ValidationError{ + Field: "bucketName", + Message: "bucket name must contain only alphanumeric characters, hyphens, and underscores, and must start and end with an alphanumeric character", + } + } + + return nil +} + +// ValidateFileName validates file name according to rules: +// - Not empty +// - Length <= 255 characters +// - No path traversal patterns +// - Valid characters only +func ValidateFileName(name string) error { + if name == "" { + return &ValidationError{Field: "fileName", Message: "file name is required"} + } + + if len(name) > MaxFileNameLength { + return &ValidationError{ + Field: "fileName", + Message: fmt.Sprintf("file name must not exceed %d characters", MaxFileNameLength), + } + } + + // Check for path traversal patterns + if containsPathTraversal(name) { + return &ValidationError{ + Field: "fileName", + Message: "file name contains invalid characters or path traversal patterns", + } + } + + // Validate file name format + if !fileNameRegex.MatchString(name) { + return &ValidationError{ + Field: "fileName", + Message: "file name must contain only alphanumeric characters, dots, hyphens, and underscores, and must start and end with an alphanumeric character", + } + } + + return nil +} + +// ValidateFileUpload validates file upload including size and MIME type. +func ValidateFileUpload(header *multipart.FileHeader) error { + if header == nil { + return &ValidationError{Field: "file", Message: "file is required"} + } + + // Validate file name + if err := ValidateFileName(header.Filename); err != nil { + return err + } + + // Validate file size + if header.Size > MaxFileSize { + return &ValidationError{ + Field: "file", + Message: fmt.Sprintf("file size exceeds maximum allowed size of %d bytes", MaxFileSize), + } + } + + if header.Size == 0 { + return &ValidationError{Field: "file", Message: "file is empty"} + } + + // Validate MIME type + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" // Default + } + + // Extract base MIME type (remove charset and other parameters) + baseMIMEType := strings.Split(contentType, ";")[0] + baseMIMEType = strings.TrimSpace(baseMIMEType) + + if !allowedMIMETypesMap[baseMIMEType] { + return &ValidationError{ + Field: "file", + Message: fmt.Sprintf("file type '%s' is not allowed", baseMIMEType), + } + } + + return nil +} + +// SanitizeBucketName sanitizes bucket name by removing invalid characters. +// This should be used after validation for extra safety. +func SanitizeBucketName(name string) string { + // Remove any whitespace + name = strings.TrimSpace(name) + + // Convert to lowercase for consistency + name = strings.ToLower(name) + + // Remove any characters that aren't alphanumeric, hyphen, or underscore + var sanitized strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + sanitized.WriteRune(r) + } + } + + result := sanitized.String() + + // Ensure it doesn't exceed max length + if len(result) > MaxBucketNameLength { + result = result[:MaxBucketNameLength] + } + + return result +} + +// SanitizeFileName sanitizes file name by removing path components and invalid characters. +func SanitizeFileName(name string) string { + // Extract just the filename (remove any path components) + name = filepath.Base(name) + + // Remove any null bytes + name = strings.ReplaceAll(name, "\x00", "") + + // Remove leading/trailing whitespace + name = strings.TrimSpace(name) + + // Remove any path traversal patterns + name = strings.ReplaceAll(name, "..", "") + name = strings.ReplaceAll(name, "/", "") + name = strings.ReplaceAll(name, "\\", "") + + // Ensure it doesn't exceed max length + if len(name) > MaxFileNameLength { + // Try to preserve the extension + ext := filepath.Ext(name) + if len(ext) > 0 && len(ext) < 10 { + baseName := name[:len(name)-len(ext)] + maxBaseLength := MaxFileNameLength - len(ext) + if len(baseName) > maxBaseLength { + baseName = baseName[:maxBaseLength] + } + name = baseName + ext + } else { + name = name[:MaxFileNameLength] + } + } + + return name +} + +// containsPathTraversal checks if a string contains path traversal patterns. +func containsPathTraversal(s string) bool { + // Convert to lowercase for case-insensitive matching + lower := strings.ToLower(s) + + for _, pattern := range pathTraversalPatterns { + if pattern.MatchString(lower) { + return true + } + } + + return false +} + +// ValidateContentLength validates the Content-Length header for uploads. +func ValidateContentLength(contentLength int64) error { + if contentLength < 0 { + return &ValidationError{Field: "Content-Length", Message: "invalid content length"} + } + + if contentLength > MaxFileSize { + return &ValidationError{ + Field: "Content-Length", + Message: fmt.Sprintf("content length exceeds maximum allowed size of %d bytes", MaxFileSize), + } + } + + return nil +} diff --git a/test/middleware_test.go b/test/middleware_test.go new file mode 100644 index 0000000..9196334 --- /dev/null +++ b/test/middleware_test.go @@ -0,0 +1,261 @@ +package test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/akave-ai/go-akavelink/internal/middleware" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" +) + +func TestValidateBucketNameMiddleware(t *testing.T) { + tests := []struct { + name string + bucketName string + expectedStatus int + }{ + { + name: "valid bucket name", + bucketName: "my-bucket-123", + expectedStatus: http.StatusOK, + }, + { + name: "invalid bucket name with special chars", + bucketName: "my-bucket!@#", + expectedStatus: http.StatusBadRequest, + }, + { + name: "invalid bucket name starting with hyphen", + bucketName: "-bucket", + expectedStatus: http.StatusBadRequest, + }, + { + name: "bucket name too long", + bucketName: "a1234567890123456789012345678901234567890123456789012345678901234567890", + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a test handler + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Wrap with middleware + r := mux.NewRouter() + r.Handle("/buckets/{bucketName}", middleware.ValidateBucketName(handler)) + + // Create test request + req := httptest.NewRequest("GET", "/buckets/"+tt.bucketName, nil) + rr := httptest.NewRecorder() + + // Serve the request + r.ServeHTTP(rr, req) + + // Check status code + assert.Equal(t, tt.expectedStatus, rr.Code) + }) + } +} + +func TestValidateFileNameMiddleware(t *testing.T) { + tests := []struct { + name string + fileName string + expectedStatus int + }{ + { + name: "valid file name", + fileName: "document.pdf", + expectedStatus: http.StatusOK, + }, + { + name: "invalid file name starting with dot", + fileName: ".hidden", + expectedStatus: http.StatusBadRequest, + }, + { + name: "invalid file name ending with dot", + fileName: "file.", + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a test handler + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Wrap with middleware + r := mux.NewRouter() + r.Handle("/files/{fileName}", middleware.ValidateFileName(handler)) + + // Create test request + req := httptest.NewRequest("GET", "/files/"+tt.fileName, nil) + rr := httptest.NewRecorder() + + // Serve the request + r.ServeHTTP(rr, req) + + // Check status code + assert.Equal(t, tt.expectedStatus, rr.Code) + }) + } +} + +func TestValidateBucketAndFileNameMiddleware(t *testing.T) { + tests := []struct { + name string + bucketName string + fileName string + expectedStatus int + }{ + { + name: "valid bucket and file names", + bucketName: "my-bucket", + fileName: "document.pdf", + expectedStatus: http.StatusOK, + }, + { + name: "invalid bucket name", + bucketName: "-bucket", + fileName: "document.pdf", + expectedStatus: http.StatusBadRequest, + }, + { + name: "invalid file name", + bucketName: "my-bucket", + fileName: ".hidden", + expectedStatus: http.StatusBadRequest, + }, + { + name: "both invalid", + bucketName: "-bucket", + fileName: "file.", + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a test handler + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Wrap with middleware + r := mux.NewRouter() + r.Handle("/buckets/{bucketName}/files/{fileName}", + middleware.ValidateBucketAndFileName(handler)) + + // Create test request + req := httptest.NewRequest("GET", + "/buckets/"+tt.bucketName+"/files/"+tt.fileName, nil) + rr := httptest.NewRecorder() + + // Serve the request + r.ServeHTTP(rr, req) + + // Check status code + assert.Equal(t, tt.expectedStatus, rr.Code) + }) + } +} + +func TestValidateContentLengthMiddleware(t *testing.T) { + tests := []struct { + name string + contentLength string + expectedStatus int + }{ + { + name: "valid content length", + contentLength: "1024", + expectedStatus: http.StatusOK, + }, + { + name: "content length at max", + contentLength: "104857600", // 100 MB + expectedStatus: http.StatusOK, + }, + { + name: "zero content length", + contentLength: "0", + expectedStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a test handler + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Wrap with middleware + wrappedHandler := middleware.ValidateContentLength(handler) + + // Create test request + req := httptest.NewRequest("POST", "/upload", nil) + if tt.contentLength != "" { + req.Header.Set("Content-Length", tt.contentLength) + } + rr := httptest.NewRecorder() + + // Serve the request + wrappedHandler.ServeHTTP(rr, req) + + // Check status code + assert.Equal(t, tt.expectedStatus, rr.Code) + }) + } +} + +func TestSecurityHeadersMiddleware(t *testing.T) { + // Create a test handler + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Wrap with middleware + wrappedHandler := middleware.SecurityHeaders(handler) + + // Create test request + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + + // Serve the request + wrappedHandler.ServeHTTP(rr, req) + + // Check security headers + assert.Equal(t, "nosniff", rr.Header().Get("X-Content-Type-Options")) + assert.Equal(t, "DENY", rr.Header().Get("X-Frame-Options")) + assert.Equal(t, "1; mode=block", rr.Header().Get("X-XSS-Protection")) + assert.Equal(t, "default-src 'self'", rr.Header().Get("Content-Security-Policy")) +} + +func TestLogRequestMiddleware(t *testing.T) { + // Create a test handler + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Wrap with middleware + wrappedHandler := middleware.LogRequest(handler) + + // Create test request + req := httptest.NewRequest("GET", "/test", nil) + rr := httptest.NewRecorder() + + // Serve the request (should not panic or error) + wrappedHandler.ServeHTTP(rr, req) + + // Check that handler was called + assert.Equal(t, http.StatusOK, rr.Code) +} diff --git a/test/validation_integration_test.go b/test/validation_integration_test.go new file mode 100644 index 0000000..99050b2 --- /dev/null +++ b/test/validation_integration_test.go @@ -0,0 +1,402 @@ +package test + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/akave-ai/go-akavelink/internal/handlers" + sdksym "github.com/akave-ai/akavesdk/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockClient is a mock implementation of handlers.ClientAPI +type MockClient struct { + mock.Mock +} + +func (m *MockClient) CreateBucket(ctx context.Context, name string) error { + args := m.Called(ctx, name) + return args.Error(0) +} + +func (m *MockClient) DeleteBucket(ctx context.Context, name string) error { + args := m.Called(ctx, name) + return args.Error(0) +} + +func (m *MockClient) ListBuckets() ([]string, error) { + args := m.Called() + return args.Get(0).([]string), args.Error(1) +} + +func (m *MockClient) ListFiles(ctx context.Context, bucket string) ([]sdksym.IPCFileListItem, error) { + args := m.Called(ctx, bucket) + return args.Get(0).([]sdksym.IPCFileListItem), args.Error(1) +} + +func (m *MockClient) CreateFileUpload(ctx context.Context, bucket, fileName string) (*sdksym.IPCFileUpload, error) { + args := m.Called(ctx, bucket, fileName) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*sdksym.IPCFileUpload), args.Error(1) +} + +func (m *MockClient) Upload(ctx context.Context, up *sdksym.IPCFileUpload, r io.Reader) (sdksym.IPCFileMetaV2, error) { + args := m.Called(ctx, up, r) + return args.Get(0).(sdksym.IPCFileMetaV2), args.Error(1) +} + +func (m *MockClient) CreateFileDownload(ctx context.Context, bucket, fileName string) (sdksym.IPCFileDownload, error) { + args := m.Called(ctx, bucket, fileName) + return args.Get(0).(sdksym.IPCFileDownload), args.Error(1) +} + +func (m *MockClient) Download(ctx context.Context, dl sdksym.IPCFileDownload, w io.Writer) error { + args := m.Called(ctx, dl, w) + return args.Error(0) +} + +func (m *MockClient) FileInfo(ctx context.Context, bucket, fileName string) (sdksym.IPCFileMeta, error) { + args := m.Called(ctx, bucket, fileName) + return args.Get(0).(sdksym.IPCFileMeta), args.Error(1) +} + +func (m *MockClient) FileDelete(ctx context.Context, bucket, fileName string) error { + args := m.Called(ctx, bucket, fileName) + return args.Error(0) +} + +func (m *MockClient) NewIPC() (*sdksym.IPC, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*sdksym.IPC), args.Error(1) +} + +func TestCreateBucketValidation(t *testing.T) { + tests := []struct { + name string + bucketName string + expectedStatus int + }{ + { + name: "valid bucket name", + bucketName: "valid-bucket-123", + expectedStatus: http.StatusCreated, + }, + { + name: "invalid bucket name with special chars", + bucketName: "invalid!bucket", + expectedStatus: http.StatusBadRequest, + }, + { + name: "invalid bucket name starting with hyphen", + bucketName: "-bucket", + expectedStatus: http.StatusBadRequest, + }, + { + name: "bucket name too long", + bucketName: "a1234567890123456789012345678901234567890123456789012345678901234567890", + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(MockClient) + if tt.expectedStatus == http.StatusCreated { + mockClient.On("CreateBucket", mock.Anything, mock.Anything).Return(nil) + } + + router := handlers.NewRouter(mockClient) + + req := httptest.NewRequest("POST", "/buckets/"+tt.bucketName, nil) + rr := httptest.NewRecorder() + + router.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + }) + } +} + +func TestDeleteBucketValidation(t *testing.T) { + tests := []struct { + name string + bucketName string + expectedStatus int + }{ + { + name: "valid bucket name", + bucketName: "valid-bucket", + expectedStatus: http.StatusOK, + }, + { + name: "invalid bucket name", + bucketName: "-bucket", + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(MockClient) + if tt.expectedStatus == http.StatusOK { + mockClient.On("ListFiles", mock.Anything, mock.Anything). + Return([]sdksym.IPCFileListItem{}, nil) + mockClient.On("DeleteBucket", mock.Anything, mock.Anything).Return(nil) + } + + router := handlers.NewRouter(mockClient) + + req := httptest.NewRequest("DELETE", "/buckets/"+tt.bucketName, nil) + rr := httptest.NewRecorder() + + router.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + }) + } +} + +func TestFileInfoValidation(t *testing.T) { + tests := []struct { + name string + bucketName string + fileName string + expectedStatus int + }{ + { + name: "valid bucket and file names", + bucketName: "my-bucket", + fileName: "document.pdf", + expectedStatus: http.StatusOK, + }, + { + name: "invalid bucket name", + bucketName: "-bucket", + fileName: "document.pdf", + expectedStatus: http.StatusBadRequest, + }, + { + name: "invalid file name starting with dot", + bucketName: "my-bucket", + fileName: ".hidden", + expectedStatus: http.StatusBadRequest, + }, + { + name: "invalid file name ending with dot", + bucketName: "my-bucket", + fileName: "file.", + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(MockClient) + if tt.expectedStatus == http.StatusOK { + mockClient.On("FileInfo", mock.Anything, mock.Anything, mock.Anything). + Return(sdksym.IPCFileMeta{}, nil) + } + + router := handlers.NewRouter(mockClient) + + req := httptest.NewRequest("GET", + "/buckets/"+tt.bucketName+"/files/"+tt.fileName, nil) + rr := httptest.NewRecorder() + + router.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + }) + } +} + +func TestFileUploadValidation(t *testing.T) { + tests := []struct { + name string + bucketName string + fileName string + fileSize int64 + contentType string + expectedStatus int + }{ + { + name: "valid file upload", + bucketName: "my-bucket", + fileName: "document.pdf", + fileSize: 1024, + contentType: "application/pdf", + expectedStatus: http.StatusCreated, + }, + { + name: "invalid bucket name", + bucketName: "-bucket", + fileName: "document.pdf", + fileSize: 1024, + contentType: "application/pdf", + expectedStatus: http.StatusBadRequest, + }, + { + name: "file too large", + bucketName: "my-bucket", + fileName: "large.bin", + fileSize: 101 * 1024 * 1024, // 101 MB + contentType: "application/octet-stream", + expectedStatus: http.StatusBadRequest, + }, + { + name: "invalid file name", + bucketName: "my-bucket", + fileName: ".hidden", + fileSize: 1024, + contentType: "text/plain", + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(MockClient) + if tt.expectedStatus == http.StatusCreated { + mockClient.On("CreateFileUpload", mock.Anything, mock.Anything, mock.Anything). + Return(&sdksym.IPCFileUpload{}, nil) + mockClient.On("Upload", mock.Anything, mock.Anything, mock.Anything). + Return(sdksym.IPCFileMetaV2{}, nil) + } + + router := handlers.NewRouter(mockClient) + + // Create multipart form + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Create file part + part, err := writer.CreateFormFile("file", tt.fileName) + assert.NoError(t, err) + + // Write file content + fileContent := make([]byte, tt.fileSize) + _, err = part.Write(fileContent) + assert.NoError(t, err) + + err = writer.Close() + assert.NoError(t, err) + + req := httptest.NewRequest("POST", "/buckets/"+tt.bucketName+"/files", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rr := httptest.NewRecorder() + + router.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + }) + } +} + +func TestFileDownloadValidation(t *testing.T) { + tests := []struct { + name string + bucketName string + fileName string + expectedStatus int + }{ + { + name: "valid download", + bucketName: "my-bucket", + fileName: "document.pdf", + expectedStatus: http.StatusOK, + }, + { + name: "invalid bucket name", + bucketName: "-bucket", + fileName: "document.pdf", + expectedStatus: http.StatusBadRequest, + }, + { + name: "invalid file name", + bucketName: "my-bucket", + fileName: ".hidden", + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(MockClient) + if tt.expectedStatus == http.StatusOK { + mockClient.On("CreateFileDownload", mock.Anything, mock.Anything, mock.Anything). + Return(sdksym.IPCFileDownload{}, nil) + mockClient.On("Download", mock.Anything, mock.Anything, mock.Anything). + Return(nil) + } + + router := handlers.NewRouter(mockClient) + + req := httptest.NewRequest("GET", + "/buckets/"+tt.bucketName+"/files/"+tt.fileName+"/download", nil) + rr := httptest.NewRecorder() + + router.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + }) + } +} + +func TestFileDeleteValidation(t *testing.T) { + tests := []struct { + name string + bucketName string + fileName string + expectedStatus int + }{ + { + name: "valid delete", + bucketName: "my-bucket", + fileName: "document.pdf", + expectedStatus: http.StatusOK, + }, + { + name: "invalid bucket name", + bucketName: "-bucket", + fileName: "document.pdf", + expectedStatus: http.StatusBadRequest, + }, + { + name: "invalid file name", + bucketName: "my-bucket", + fileName: "file.", + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(MockClient) + if tt.expectedStatus == http.StatusOK { + mockClient.On("FileDelete", mock.Anything, mock.Anything, mock.Anything). + Return(nil) + } + + router := handlers.NewRouter(mockClient) + + req := httptest.NewRequest("DELETE", + "/buckets/"+tt.bucketName+"/files/"+tt.fileName, nil) + rr := httptest.NewRecorder() + + router.ServeHTTP(rr, req) + + assert.Equal(t, tt.expectedStatus, rr.Code) + }) + } +} diff --git a/test/validation_test.go b/test/validation_test.go new file mode 100644 index 0000000..95ecf0d --- /dev/null +++ b/test/validation_test.go @@ -0,0 +1,465 @@ +package test + +import ( + "bytes" + "mime/multipart" + "net/textproto" + "testing" + + "github.com/akave-ai/go-akavelink/internal/validation" + "github.com/stretchr/testify/assert" +) + +func TestValidateBucketName(t *testing.T) { + tests := []struct { + name string + input string + wantError bool + errorMsg string + }{ + { + name: "valid bucket name", + input: "my-bucket-123", + wantError: false, + }, + { + name: "valid bucket name with underscores", + input: "my_bucket_123", + wantError: false, + }, + { + name: "empty bucket name", + input: "", + wantError: true, + errorMsg: "bucket name is required", + }, + { + name: "bucket name too long", + input: "a123456789012345678901234567890123456789012345678901234567890123456789", + wantError: true, + errorMsg: "bucket name must be between", + }, + { + name: "bucket name with spaces", + input: "my bucket", + wantError: true, + errorMsg: "bucket name must contain only alphanumeric", + }, + { + name: "bucket name with special characters", + input: "my-bucket!@#", + wantError: true, + errorMsg: "bucket name must contain only alphanumeric", + }, + { + name: "bucket name with path traversal", + input: "../etc/passwd", + wantError: true, + errorMsg: "bucket name contains invalid characters", + }, + { + name: "bucket name with forward slash", + input: "my/bucket", + wantError: true, + errorMsg: "bucket name must contain only alphanumeric", + }, + { + name: "bucket name with backslash", + input: "my\\bucket", + wantError: true, + errorMsg: "bucket name contains invalid characters", + }, + { + name: "bucket name starting with hyphen", + input: "-mybucket", + wantError: true, + errorMsg: "bucket name must contain only alphanumeric", + }, + { + name: "bucket name ending with hyphen", + input: "mybucket-", + wantError: true, + errorMsg: "bucket name must contain only alphanumeric", + }, + { + name: "single character bucket name", + input: "a", + wantError: false, + }, + { + name: "max length bucket name", + input: "a12345678901234567890123456789012345678901234567890123456789012", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validation.ValidateBucketName(tt.input) + if tt.wantError { + assert.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateFileName(t *testing.T) { + tests := []struct { + name string + input string + wantError bool + errorMsg string + }{ + { + name: "valid file name", + input: "document.pdf", + wantError: false, + }, + { + name: "valid file name with multiple dots", + input: "my.file.name.txt", + wantError: false, + }, + { + name: "empty file name", + input: "", + wantError: true, + errorMsg: "file name is required", + }, + { + name: "file name too long", + input: string(make([]byte, 256)), + wantError: true, + errorMsg: "file name must not exceed", + }, + { + name: "file name with path traversal", + input: "../../../etc/passwd", + wantError: true, + errorMsg: "file name contains invalid characters", + }, + { + name: "file name with absolute path", + input: "/etc/passwd", + wantError: true, + errorMsg: "file name contains invalid characters", + }, + { + name: "file name with null byte", + input: "file\x00name.txt", + wantError: true, + errorMsg: "file name contains invalid characters", + }, + { + name: "file name with special characters", + input: "file<>name.txt", + wantError: true, + errorMsg: "file name contains invalid characters", + }, + { + name: "file name with backslash", + input: "path\\file.txt", + wantError: true, + errorMsg: "file name contains invalid characters", + }, + { + name: "file name starting with dot", + input: ".hidden", + wantError: true, + errorMsg: "file name must contain only alphanumeric", + }, + { + name: "file name ending with dot", + input: "file.", + wantError: true, + errorMsg: "file name must contain only alphanumeric", + }, + { + name: "valid file name with hyphens and underscores", + input: "my-file_name-123.txt", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validation.ValidateFileName(tt.input) + if tt.wantError { + assert.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateFileUpload(t *testing.T) { + tests := []struct { + name string + filename string + size int64 + contentType string + wantError bool + errorMsg string + }{ + { + name: "valid file upload", + filename: "document.pdf", + size: 1024, + contentType: "application/pdf", + wantError: false, + }, + { + name: "valid text file", + filename: "data.txt", + size: 512, + contentType: "text/plain", + wantError: false, + }, + { + name: "valid image file", + filename: "photo.jpg", + size: 2048, + contentType: "image/jpeg", + wantError: false, + }, + { + name: "file too large", + filename: "large.bin", + size: 101 * 1024 * 1024, // 101 MB + contentType: "application/octet-stream", + wantError: true, + errorMsg: "file size exceeds maximum", + }, + { + name: "empty file", + filename: "empty.txt", + size: 0, + contentType: "text/plain", + wantError: true, + errorMsg: "file is empty", + }, + { + name: "invalid MIME type", + filename: "script.exe", + size: 1024, + contentType: "application/x-msdownload", + wantError: true, + errorMsg: "file type", + }, + { + name: "invalid filename with path traversal", + filename: "../../../etc/passwd", + size: 1024, + contentType: "text/plain", + wantError: true, + errorMsg: "file name contains invalid characters", + }, + { + name: "default content type", + filename: "file.bin", + size: 1024, + contentType: "", + wantError: false, + }, + { + name: "content type with charset", + filename: "data.json", + size: 1024, + contentType: "application/json; charset=utf-8", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a multipart file header + header := &multipart.FileHeader{ + Filename: tt.filename, + Size: tt.size, + } + + // Set content type in header + if tt.contentType != "" { + header.Header = make(textproto.MIMEHeader) + header.Header.Set("Content-Type", tt.contentType) + } + + err := validation.ValidateFileUpload(header) + if tt.wantError { + assert.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestSanitizeBucketName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "already clean", + input: "mybucket", + expected: "mybucket", + }, + { + name: "uppercase to lowercase", + input: "MyBucket", + expected: "mybucket", + }, + { + name: "with whitespace", + input: " my-bucket ", + expected: "my-bucket", + }, + { + name: "remove special characters", + input: "my!@#bucket", + expected: "mybucket", + }, + { + name: "preserve hyphens and underscores", + input: "my-bucket_123", + expected: "my-bucket_123", + }, + { + name: "truncate long name", + input: "a1234567890123456789012345678901234567890123456789012345678901234567890", + expected: "a12345678901234567890123456789012345678901234567890123456789012", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validation.SanitizeBucketName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSanitizeFileName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "already clean", + input: "document.pdf", + expected: "document.pdf", + }, + { + name: "remove path components", + input: "/path/to/file.txt", + expected: "file.txt", + }, + { + name: "remove path traversal", + input: "../../../etc/passwd", + expected: "passwd", // filepath.Base extracts the last element + }, + { + name: "remove null bytes", + input: "file\x00name.txt", + expected: "filename.txt", + }, + { + name: "remove backslashes", + input: "path\\to\\file.txt", + expected: "pathtofile.txt", + }, + { + name: "trim whitespace", + input: " file.txt ", + expected: "file.txt", + }, + { + name: "truncate long filename preserving extension", + input: string(bytes.Repeat([]byte("a"), 260)) + ".txt", + expected: string(bytes.Repeat([]byte("a"), 251)) + ".txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validation.SanitizeFileName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestValidateContentLength(t *testing.T) { + tests := []struct { + name string + length int64 + wantError bool + errorMsg string + }{ + { + name: "valid content length", + length: 1024, + wantError: false, + }, + { + name: "zero content length", + length: 0, + wantError: false, + }, + { + name: "negative content length", + length: -1, + wantError: true, + errorMsg: "invalid content length", + }, + { + name: "content length exceeds maximum", + length: 101 * 1024 * 1024, + wantError: true, + errorMsg: "content length exceeds maximum", + }, + { + name: "maximum allowed content length", + length: 100 * 1024 * 1024, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validation.ValidateContentLength(tt.length) + if tt.wantError { + assert.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidationError(t *testing.T) { + err := &validation.ValidationError{ + Field: "testField", + Message: "test message", + } + + expected := "testField: test message" + assert.Equal(t, expected, err.Error()) +}