You've already forked openaccounting-server
forked from cybercinch/openaccounting-server
feat: implement secure file upload system with JWT authentication
- Add JWT-based secure file access for local storage with 1-hour expiry - Implement GORM repository methods for attachment CRUD operations - Add secure file serving endpoint with token validation - Update storage interface to support user context in URL generation - Add comprehensive security features including path traversal protection - Update documentation with security model and configuration examples - Add utility functions for hex/byte conversion and UUID validation - Configure secure file permissions (0600) for uploaded files 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
160
core/api/secure_files.go
Normal file
160
core/api/secure_files.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ant0ine/go-json-rest/rest"
|
||||
"github.com/openaccounting/oa-server/core/storage"
|
||||
)
|
||||
|
||||
// TokenService instance for file access
|
||||
var tokenService *storage.TokenService
|
||||
|
||||
// InitSecureFileServer initializes the token service for secure file serving
|
||||
func InitSecureFileServer(signingKey string) {
|
||||
tokenService = storage.NewTokenService(signingKey)
|
||||
}
|
||||
|
||||
// GetSecureFile serves files with JWT token validation
|
||||
func GetSecureFile(w rest.ResponseWriter, r *rest.Request) {
|
||||
// Extract token from query parameter
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
rest.Error(w, "Missing access token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the token
|
||||
claims, err := tokenService.ValidateFileToken(token)
|
||||
if err != nil {
|
||||
rest.Error(w, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the file path from the token claims
|
||||
filePath := claims.FilePath
|
||||
|
||||
// Validate the file path (additional security check)
|
||||
if err := validateSecureFilePath(filePath); err != nil {
|
||||
rest.Error(w, "Invalid file path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the file
|
||||
if err := serveFile(w, r, filePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
rest.Error(w, "File not found", http.StatusNotFound)
|
||||
} else {
|
||||
rest.Error(w, "Failed to serve file", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// serveFile serves a file with proper headers and security measures
|
||||
func serveFile(w rest.ResponseWriter, r *rest.Request, filePath string) error {
|
||||
// Get the full path relative to the uploads directory
|
||||
// This assumes the local storage root directory is "./uploads"
|
||||
fullPath := filepath.Join("./uploads", filePath)
|
||||
|
||||
// Open the file
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Get file info for headers
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set security headers
|
||||
responseWriter := w.(http.ResponseWriter)
|
||||
responseWriter.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
responseWriter.Header().Set("X-Frame-Options", "DENY")
|
||||
responseWriter.Header().Set("Content-Security-Policy", "default-src 'none'")
|
||||
|
||||
// Set content headers
|
||||
responseWriter.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
|
||||
responseWriter.Header().Set("Last-Modified", info.ModTime().UTC().Format(http.TimeFormat))
|
||||
|
||||
// Detect content type based on file extension
|
||||
contentType := getContentType(filePath)
|
||||
responseWriter.Header().Set("Content-Type", contentType)
|
||||
|
||||
// Set cache headers for temporary access
|
||||
responseWriter.Header().Set("Cache-Control", "private, max-age=300") // 5 minutes
|
||||
responseWriter.Header().Set("Expires", time.Now().Add(5*time.Minute).UTC().Format(http.TimeFormat))
|
||||
|
||||
// Copy file content to response
|
||||
_, err = io.Copy(responseWriter, file)
|
||||
return err
|
||||
}
|
||||
|
||||
// validateSecureFilePath validates that the file path is safe to serve
|
||||
func validateSecureFilePath(path string) error {
|
||||
// Clean the path and check for traversal attempts
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
// Reject paths that try to go up directories
|
||||
if strings.Contains(cleanPath, "..") {
|
||||
return fmt.Errorf("path traversal attempt detected")
|
||||
}
|
||||
|
||||
// Reject absolute paths
|
||||
if filepath.IsAbs(cleanPath) {
|
||||
return fmt.Errorf("absolute paths not allowed")
|
||||
}
|
||||
|
||||
// Additional validation: ensure path starts with expected date format
|
||||
parts := strings.Split(cleanPath, string(filepath.Separator))
|
||||
if len(parts) < 4 {
|
||||
return fmt.Errorf("invalid path structure")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getContentType returns the MIME type based on file extension
|
||||
func getContentType(filePath string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
switch ext {
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".doc":
|
||||
return "application/msword"
|
||||
case ".docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
case ".xls":
|
||||
return "application/vnd.ms-excel"
|
||||
case ".xlsx":
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
case ".ppt":
|
||||
return "application/vnd.ms-powerpoint"
|
||||
case ".pptx":
|
||||
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
case ".txt":
|
||||
return "text/plain"
|
||||
case ".csv":
|
||||
return "text/csv"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user