From 47e94995b55f237ecd2cada1f2379ac76c8eece1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Tue, 13 Jan 2026 22:11:02 +0100 Subject: [PATCH] Refactor viewmodels and enhance security documentation; remove unused viewmodels, add path sanitization, and implement rate limiting --- README.md | 2 +- b0esche_cloud/lib/injection.dart | 8 - .../viewmodels/file_explorer_view_model.dart | 56 ------ .../lib/viewmodels/login_view_model.dart | 65 ------ docs/SECURITY.md | 185 ++++++++++++++++++ go_cloud/internal/http/routes.go | 44 +++-- go_cloud/internal/middleware/middleware.go | 71 +++++-- go_cloud/internal/storage/nextcloud.go | 7 - 8 files changed, 274 insertions(+), 164 deletions(-) delete mode 100644 b0esche_cloud/lib/viewmodels/file_explorer_view_model.dart delete mode 100644 b0esche_cloud/lib/viewmodels/login_view_model.dart create mode 100644 docs/SECURITY.md diff --git a/README.md b/README.md index 518e6ec..7074c4c 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ b0esche_cloud/ │ │ ├── repositories/ # Data repositories │ │ ├── services/ # API services │ │ ├── theme/ # App theming -│ │ ├── viewmodels/ # View models │ │ └── widgets/ # Reusable widgets │ └── web/ # Web assets ├── go_cloud/ # Go backend @@ -225,6 +224,7 @@ cd b0esche_cloud && flutter test | [ARCHITECTURE.md](docs/ARCHITECTURE.md) | System architecture, components, data flows | | [API.md](docs/API.md) | Complete API endpoint reference | | [AUTH.md](docs/AUTH.md) | Authentication system (Passkeys, OIDC, roles) | +| [SECURITY.md](docs/SECURITY.md) | Security architecture, hardening, best practices | | [DEVELOPMENT.md](docs/DEVELOPMENT.md) | Local setup, coding conventions, testing | | [DEPLOYMENT.md](docs/DEPLOYMENT.md) | Production deployment, operations, troubleshooting | diff --git a/b0esche_cloud/lib/injection.dart b/b0esche_cloud/lib/injection.dart index fb2b0a5..e550e5b 100644 --- a/b0esche_cloud/lib/injection.dart +++ b/b0esche_cloud/lib/injection.dart @@ -8,8 +8,6 @@ import 'repositories/http_file_repository.dart'; import 'services/auth_service.dart'; import 'services/file_service.dart'; import 'services/org_api.dart'; -import 'viewmodels/login_view_model.dart'; -import 'viewmodels/file_explorer_view_model.dart'; final getIt = GetIt.instance; @@ -28,10 +26,4 @@ void configureDependencies(SessionBloc sessionBloc) { getIt.registerSingleton(AuthService(getIt())); getIt.registerSingleton(FileService(getIt())); getIt.registerSingleton(OrgApi(getIt())); - - // Register viewmodels - getIt.registerSingleton(LoginViewModel(getIt())); - getIt.registerSingleton( - FileExplorerViewModel(getIt()), - ); } diff --git a/b0esche_cloud/lib/viewmodels/file_explorer_view_model.dart b/b0esche_cloud/lib/viewmodels/file_explorer_view_model.dart deleted file mode 100644 index 7740f9d..0000000 --- a/b0esche_cloud/lib/viewmodels/file_explorer_view_model.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter/foundation.dart'; -import '../models/file_item.dart'; -import '../services/file_service.dart'; - -class FileExplorerViewModel extends ChangeNotifier { - final FileService _fileService; - - FileExplorerViewModel(this._fileService); - - List _files = []; - bool _isLoading = false; - String? _error; - String _currentPath = '/'; - - List get files => _files; - bool get isLoading => _isLoading; - String? get error => _error; - String get currentPath => _currentPath; - - Future loadFiles([String? path]) async { - _isLoading = true; - _error = null; - if (path != null) _currentPath = path; - notifyListeners(); - - try { - _files = await _fileService.getFiles("", _currentPath); - } catch (e) { - _error = e.toString(); - _files = []; - } finally { - _isLoading = false; - notifyListeners(); - } - } - - Future uploadFile(FileItem file) async { - try { - await _fileService.uploadFile("", file); - await loadFiles(); // Reload files - } catch (e) { - _error = e.toString(); - notifyListeners(); - } - } - - Future deleteFile(String path) async { - try { - await _fileService.deleteFile("", path); - await loadFiles(); // Reload files - } catch (e) { - _error = e.toString(); - notifyListeners(); - } - } -} diff --git a/b0esche_cloud/lib/viewmodels/login_view_model.dart b/b0esche_cloud/lib/viewmodels/login_view_model.dart deleted file mode 100644 index fc7dac7..0000000 --- a/b0esche_cloud/lib/viewmodels/login_view_model.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/foundation.dart'; -import '../models/user.dart'; -import '../services/auth_service.dart'; - -class LoginViewModel extends ChangeNotifier { - final AuthService _authService; - - LoginViewModel(this._authService); - - bool _isLoading = false; - String? _error; - User? _currentUser; - - bool get isLoading => _isLoading; - String? get error => _error; - User? get currentUser => _currentUser; - bool get isLoggedIn => _currentUser != null; - - Future login(String email, String password) async { - _isLoading = true; - _error = null; - notifyListeners(); - - try { - _currentUser = await _authService.login(email, password); - _error = null; - } catch (e) { - _error = e.toString(); - _currentUser = null; - } finally { - _isLoading = false; - notifyListeners(); - } - } - - Future logout() async { - _isLoading = true; - notifyListeners(); - - try { - await _authService.logout(); - _currentUser = null; - _error = null; - } catch (e) { - _error = e.toString(); - } finally { - _isLoading = false; - notifyListeners(); - } - } - - Future checkCurrentUser() async { - _isLoading = true; - notifyListeners(); - - try { - _currentUser = await _authService.getCurrentUser(); - } catch (e) { - _error = e.toString(); - } finally { - _isLoading = false; - notifyListeners(); - } - } -} diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..5a954c7 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,185 @@ +# b0esche.cloud Security Guide + +This document describes the security architecture, configurations, and best practices for b0esche.cloud. + +## Security Architecture Overview + +``` + Internet + │ + ▼ + ┌─────────────────┐ + │ Traefik │ ← Only public entrypoint + │ (443, 80) │ TLS termination + └────────┬────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Flutter │ │ Go API │ │Collabora │ + │ Web │ │ Backend │ │ Online │ + └──────────┘ └────┬─────┘ └──────────┘ + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │PostgreSQL│ │Nextcloud │ │ Redis │ + │(internal)│ │(storage) │ │(sessions)│ + └──────────┘ └──────────┘ └──────────┘ +``` + +## Authentication Security + +### Primary: Passkeys (WebAuthn) + +- **Protocol**: WebAuthn/FIDO2 standard +- **Cryptography**: ECDSA with P-256 or RSA with 2048+ bits +- **Origin Binding**: Strictly bound to `https://b0esche.cloud` +- **RP ID**: `b0esche.cloud` +- **Challenge Generation**: 32 bytes of cryptographically secure random data +- **Challenge Expiry**: 60 seconds + +### Fallback: Password Authentication + +- **Hashing**: Argon2id (OWASP recommended parameters) + - Time: 2 iterations + - Memory: 19 MB + - Threads: 1 + - Key Length: 32 bytes +- **Password Requirements**: Minimum 8 characters (enforced server-side) + +### Session Management + +- **Token Format**: JWT (HS256) +- **Token Lifetime**: 15 minutes (auto-refresh enabled) +- **Session Storage**: Database-backed with revocation support +- **Session Validation**: Every request validates session is not revoked + +## Authorization + +### Role-Based Access Control (RBAC) + +| Role | Level | Permissions | +|------|-------|-------------| +| superadmin | 3 | Full system access, user management | +| admin | 2 | Organization management, user roles | +| user | 1 | Personal files, org membership | + +### Organization Scoping + +- All file operations are scoped to authenticated user + organization +- Membership verification on every org-scoped request +- Permission checks via middleware pipeline + +## API Security + +### Rate Limiting + +- **General Endpoints**: 100 requests/minute per IP +- **Auth Endpoints**: 10 requests/minute per IP (brute-force protection) +- **Implementation**: Sliding window algorithm + +### Input Validation + +- **Path Traversal Prevention**: All file paths are sanitized + - `..` sequences are rejected + - Paths are cleaned and normalized +- **UUID Validation**: All IDs are validated as proper UUIDs +- **File Size Limits**: 32MB maximum upload size + +### Output Security + +- **No Stack Traces**: Error responses never include stack traces +- **Structured Errors**: Consistent error format with codes: + - `UNAUTHENTICATED` (401) + - `PERMISSION_DENIED` (403) + - `NOT_FOUND` (404) + - `INVALID_ARGUMENT` (400) + - `INTERNAL` (500) +- **No Secrets in Logs**: Passwords and tokens are never logged + +## Network Security + +### TLS Configuration + +- **Protocol**: TLS 1.2 minimum (TLS 1.3 preferred) +- **Certificate**: Let's Encrypt (auto-renewed via DNS-01 challenge) +- **HSTS**: Enabled with 1-year max-age + +### CORS Policy + +- **Allowed Origins**: + - `https://b0esche.cloud` + - `https://www.b0esche.cloud` + - `https://*.b0esche.cloud` +- **Credentials**: Allowed +- **Methods**: GET, POST, PUT, PATCH, DELETE, OPTIONS +- **Max Age**: 3600 seconds + +### Port Exposure + +| Port | Service | Exposed To | +|------|---------|------------| +| 443 | Traefik (HTTPS) | Internet | +| 80 | Traefik (HTTP→HTTPS) | Internet | +| 22 | SSH | Internet (key-only) | +| 8080 | Go Backend | Internal only | +| 5432 | PostgreSQL | Internal only | +| 9980 | Collabora | Internal only | + +## Secure Development Practices + +### Code Security Checklist + +- [ ] No hardcoded secrets +- [ ] No debug logging of sensitive data +- [ ] Input validation on all endpoints +- [ ] Path sanitization for file operations +- [ ] Parameterized SQL queries (no string concatenation) +- [ ] Error responses don't leak internal details + +### Deployment Security + +- [ ] Production secrets via environment variables only +- [ ] `.env` files excluded from git +- [ ] Docker containers run as non-root where possible +- [ ] Regular dependency updates + +## Incident Response + +### Logging + +All security-relevant events are logged: +- Login attempts (success/failure) +- Session creation/revocation +- Permission denials +- Rate limit violations +- File access (view/edit/delete) + +### Log Format + +``` +[LEVEL] req_id= user_id= org_id= action=: message +``` + +### Audit Trail + +The `activities` table stores: +- User actions (file operations, org changes) +- Timestamps +- Associated resources +- Success/failure status + +## Security Contacts + +For security issues, contact the system administrator directly. +Do not report security vulnerabilities in public issue trackers. + +## Changelog + +| Date | Change | +|------|--------| +| 2026-01-13 | Initial security documentation | +| 2026-01-13 | Removed debug password logging | +| 2026-01-13 | Added rate limiting | +| 2026-01-13 | Added path traversal protection | diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index 885baa1..99dbe07 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -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 { diff --git a/go_cloud/internal/middleware/middleware.go b/go_cloud/internal/middleware/middleware.go index 4b3c469..62b4ca9 100644 --- a/go_cloud/internal/middleware/middleware.go +++ b/go_cloud/internal/middleware/middleware.go @@ -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)) }) diff --git a/go_cloud/internal/storage/nextcloud.go b/go_cloud/internal/storage/nextcloud.go index ef2da15..6ff4d73 100644 --- a/go_cloud/internal/storage/nextcloud.go +++ b/go_cloud/internal/storage/nextcloud.go @@ -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,