Refactor viewmodels and enhance security documentation; remove unused viewmodels, add path sanitization, and implement rate limiting
This commit is contained in:
@@ -29,6 +29,25 @@ import (
|
||||
"go.b0esche.cloud/backend/internal/storage"
|
||||
)
|
||||
|
||||
// sanitizePath validates and sanitizes a file path to prevent path traversal attacks.
|
||||
// Returns the cleaned path or an error if the path is invalid.
|
||||
func sanitizePath(inputPath string) (string, error) {
|
||||
// Clean the path to resolve . and ..
|
||||
cleaned := path.Clean(inputPath)
|
||||
|
||||
// Ensure the path doesn't try to escape the root
|
||||
if strings.Contains(cleaned, "..") {
|
||||
return "", fmt.Errorf("invalid path: path traversal detected")
|
||||
}
|
||||
|
||||
// Ensure path starts with /
|
||||
if !strings.HasPrefix(cleaned, "/") {
|
||||
cleaned = "/" + cleaned
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// getUserWebDAVClient gets or creates a user's Nextcloud account and returns a WebDAV client for them
|
||||
func getUserWebDAVClient(ctx context.Context, db *database.DB, userID uuid.UUID, nextcloudBaseURL, adminUser, adminPass string) (*storage.WebDAVClient, error) {
|
||||
var user struct {
|
||||
@@ -53,16 +72,12 @@ func getUserWebDAVClient(ctx context.Context, db *database.DB, userID uuid.UUID,
|
||||
return nil, fmt.Errorf("failed to generate password: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] Generated password for user %s: %s\n", ncUsername, ncPassword)
|
||||
|
||||
// Create Nextcloud user account
|
||||
err = storage.CreateNextcloudUser(nextcloudBaseURL, adminUser, adminPass, ncUsername, ncPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Nextcloud user: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] About to store in DB - username: %s, password: %s\n", ncUsername, ncPassword)
|
||||
|
||||
// Update database with Nextcloud credentials
|
||||
_, err = db.ExecContext(ctx,
|
||||
"UPDATE users SET nextcloud_username = $1, nextcloud_password = $2 WHERE id = $3",
|
||||
@@ -71,16 +86,11 @@ func getUserWebDAVClient(ctx context.Context, db *database.DB, userID uuid.UUID,
|
||||
return nil, fmt.Errorf("failed to update user credentials: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] Stored in DB successfully\n")
|
||||
|
||||
user.NextcloudUsername = ncUsername
|
||||
user.NextcloudPassword = ncPassword
|
||||
fmt.Printf("[AUTO-PROVISION] Created Nextcloud account for user %s: %s\n", userID, ncUsername)
|
||||
}
|
||||
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] Retrieved from DB - username: %s, password: %s\n", user.NextcloudUsername, user.NextcloudPassword)
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] Creating WebDAV client with URL: %s, user: %s, pass: %s\n", nextcloudBaseURL, user.NextcloudUsername, user.NextcloudPassword)
|
||||
|
||||
// Create user-specific WebDAV client
|
||||
return storage.NewUserWebDAVClient(nextcloudBaseURL, user.NextcloudUsername, user.NextcloudPassword), nil
|
||||
}
|
||||
@@ -1716,6 +1726,13 @@ func downloadOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize path to prevent path traversal
|
||||
filePath, err := sanitizePath(filePath)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create user's WebDAV client
|
||||
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
||||
if err != nil {
|
||||
@@ -1787,8 +1804,12 @@ func downloadUserFileHandler(w http.ResponseWriter, r *http.Request, db *databas
|
||||
return
|
||||
}
|
||||
|
||||
// Log the requested file path for debugging
|
||||
fmt.Printf("[DEBUG] Download request - User: %s, Path: %s\n", userID.String(), filePath)
|
||||
// Sanitize path to prevent path traversal
|
||||
filePath, err := sanitizePath(filePath)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create user's WebDAV client
|
||||
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
||||
@@ -1800,7 +1821,6 @@ func downloadUserFileHandler(w http.ResponseWriter, r *http.Request, db *databas
|
||||
|
||||
// Download from user's personal Nextcloud space
|
||||
remotePath := strings.TrimPrefix(filePath, "/")
|
||||
fmt.Printf("[DEBUG] Downloading from user WebDAV: /%s\n", remotePath)
|
||||
|
||||
resp, err := storageClient.Download(r.Context(), "/"+remotePath, r.Header.Get("Range"))
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.b0esche.cloud/backend/internal/audit"
|
||||
"go.b0esche.cloud/backend/internal/database"
|
||||
@@ -120,10 +122,62 @@ func originMatches(origin, pattern string) bool {
|
||||
return err == nil && matched
|
||||
}
|
||||
|
||||
// TODO: Implement rate limiter
|
||||
// rateLimiter tracks request counts per IP address
|
||||
type rateLimiter struct {
|
||||
mu sync.RWMutex
|
||||
requests map[string]*clientRequests
|
||||
}
|
||||
|
||||
type clientRequests struct {
|
||||
count int
|
||||
resetTime time.Time
|
||||
}
|
||||
|
||||
var limiter = &rateLimiter{
|
||||
requests: make(map[string]*clientRequests),
|
||||
}
|
||||
|
||||
// RateLimit implements a simple sliding window rate limiter
|
||||
// Limits: 100 requests per minute per IP for general endpoints
|
||||
// 10 requests per minute per IP for auth endpoints
|
||||
var RateLimit = func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Basic rate limiting logic here
|
||||
// Get client IP (consider X-Forwarded-For from reverse proxy)
|
||||
ip := r.RemoteAddr
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
ip = strings.Split(forwarded, ",")[0]
|
||||
}
|
||||
|
||||
// Determine rate limit based on endpoint
|
||||
limit := 100 // Default: 100 requests/minute
|
||||
if strings.HasPrefix(r.URL.Path, "/auth/") {
|
||||
limit = 10 // Auth endpoints: 10 requests/minute
|
||||
}
|
||||
|
||||
limiter.mu.Lock()
|
||||
client, exists := limiter.requests[ip]
|
||||
now := time.Now()
|
||||
|
||||
if !exists || now.After(client.resetTime) {
|
||||
// New window
|
||||
limiter.requests[ip] = &clientRequests{
|
||||
count: 1,
|
||||
resetTime: now.Add(time.Minute),
|
||||
}
|
||||
limiter.mu.Unlock()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if client.count >= limit {
|
||||
limiter.mu.Unlock()
|
||||
w.Header().Set("Retry-After", "60")
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
client.count++
|
||||
limiter.mu.Unlock()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -226,19 +280,6 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
|
||||
return
|
||||
}
|
||||
|
||||
_, err = org.CheckMembership(r.Context(), db, userID, orgID)
|
||||
if err != nil {
|
||||
auditLogger.Log(r.Context(), audit.Entry{
|
||||
UserID: &userID,
|
||||
Action: "org_access",
|
||||
Success: false,
|
||||
Metadata: map[string]interface{}{"org_id": orgID, "error": err.Error()},
|
||||
})
|
||||
errors.LogError(r, err, "Org access denied")
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), OrgKey, orgID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
|
||||
@@ -17,16 +17,12 @@ func CreateNextcloudUser(nextcloudBaseURL, adminUser, adminPass, username, passw
|
||||
baseURL := strings.Split(nextcloudBaseURL, "/remote.php")[0]
|
||||
urlStr := fmt.Sprintf("%s/ocs/v1.php/cloud/users", baseURL)
|
||||
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] CreateNextcloudUser called with password: %s\n", password)
|
||||
|
||||
// OCS API expects form-encoded data with proper URL encoding
|
||||
formData := url.Values{
|
||||
"userid": {username},
|
||||
"password": {password},
|
||||
}.Encode()
|
||||
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] Form data being sent to OCS API: %s\n", formData)
|
||||
|
||||
req, err := http.NewRequest("POST", urlStr, bytes.NewBufferString(formData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -70,9 +66,6 @@ func NewUserWebDAVClient(nextcloudBaseURL, username, password string) *WebDAVCli
|
||||
// Build the full WebDAV URL for this user
|
||||
fullURL := fmt.Sprintf("%s/remote.php/dav/files/%s", baseURL, username)
|
||||
|
||||
fmt.Printf("[WEBDAV-USER] Input URL: %s, Base: %s, Full: %s, User: %s\n", nextcloudBaseURL, baseURL, fullURL, username)
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] NewUserWebDAVClient called with password: %s\n", password)
|
||||
|
||||
return &WebDAVClient{
|
||||
baseURL: fullURL,
|
||||
user: username,
|
||||
|
||||
Reference in New Issue
Block a user