Files
openaccounting-server/core/api/secure_files.go
Aaron Guise 8b6ba74ce9 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>
2025-07-03 15:45:25 +12:00

160 lines
4.4 KiB
Go

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"
}
}