You've already forked openaccounting-server
forked from cybercinch/openaccounting-server
- 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>
160 lines
4.4 KiB
Go
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"
|
|
}
|
|
} |