diff --git a/web/files/antivirus.go b/web/files/antivirus.go new file mode 100644 index 00000000000..44c84832ae2 --- /dev/null +++ b/web/files/antivirus.go @@ -0,0 +1,88 @@ +package files + +import ( + "net/http" + "strings" + + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/vfs" + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/jsonapi" +) + +const ( + ActionDownload = "download" + ActionShare = "share" + ActionPreview = "preview" + ActionDelete = "delete" +) + +// CheckAntivirusAction checks if the requested action is allowed based on the file's +// antivirus scan status and the instance's antivirus configuration. +// Returns nil if allowed, or a jsonapi.Error with status 451 if blocked. +func CheckAntivirusAction(inst *instance.Instance, file *vfs.FileDoc, action string) error { + if file == nil { + return nil + } + avConfig := config.GetAntivirusConfig(inst.ContextName) + if avConfig == nil || !avConfig.Enabled { + return nil + } + status := getFileAntivirusStatus(file) + if isActionAllowed(avConfig, status, action) { + return nil + } + return newAntivirusBlockedError(file, action, status) +} + +// getFileAntivirusStatus returns the antivirus status of a file. +// If the file has no AntivirusScan data, it returns "clean" as the default status. +func getFileAntivirusStatus(file *vfs.FileDoc) string { + if file == nil { + return vfs.AVStatusClean + } + if file.AntivirusScan == nil { + return vfs.AVStatusClean + } + if file.AntivirusScan.Status == "" { + return vfs.AVStatusClean + } + return file.AntivirusScan.Status +} + +// isActionAllowed checks if the given action is in the list of allowed actions +// for the given antivirus status. +func isActionAllowed(cfg *config.AntivirusContextConfig, status, action string) bool { + if cfg == nil || cfg.Actions == nil { + return true + } + + allowedActions, ok := cfg.Actions[status] + if !ok { + // Status not configured - default to allow for safety + return true + } + + for _, allowed := range allowedActions { + if strings.EqualFold(allowed, action) { + return true + } + } + return false +} + +// newAntivirusBlockedError creates a jsonapi.Error with status 451 +// (Unavailable For Legal Reasons) for blocked antivirus actions. +func newAntivirusBlockedError(file *vfs.FileDoc, action, status string) *jsonapi.Error { + detail := "Action '" + action + "' is blocked for files with antivirus status '" + status + "'" + if status == vfs.AVStatusInfected && file != nil && file.AntivirusScan != nil && file.AntivirusScan.VirusName != "" { + detail += " (detected: " + file.AntivirusScan.VirusName + ")" + } + + return &jsonapi.Error{ + Status: http.StatusUnavailableForLegalReasons, // 451 + Title: "Unavailable For Legal Reasons", + Code: "antivirus_blocked", + Detail: detail, + } +} diff --git a/web/files/antivirus_test.go b/web/files/antivirus_test.go new file mode 100644 index 00000000000..960c005578f --- /dev/null +++ b/web/files/antivirus_test.go @@ -0,0 +1,213 @@ +package files + +import ( + "net/http" + "testing" + + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/vfs" + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/jsonapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckAntivirusAction(t *testing.T) { + config.UseTestFile(t) + + t.Run("NilFile", func(t *testing.T) { + inst := &instance.Instance{} + err := CheckAntivirusAction(inst, nil, ActionDownload) + assert.NoError(t, err) + }) + + t.Run("AntivirusDisabled", func(t *testing.T) { + inst := &instance.Instance{ContextName: "disabled-context"} + file := &vfs.FileDoc{ + AntivirusScan: &vfs.AntivirusScan{ + Status: vfs.AVStatusInfected, + }, + } + // No antivirus config means disabled - should allow all actions + err := CheckAntivirusAction(inst, file, ActionDownload) + assert.NoError(t, err) + }) + + t.Run("FileWithNoScanInfo", func(t *testing.T) { + // Configure antivirus that blocks download for pending but allows for clean + conf := config.GetConfig() + if conf.Contexts == nil { + conf.Contexts = make(map[string]interface{}) + } + conf.Contexts["no-scan-test"] = map[string]interface{}{ + "antivirus": map[string]interface{}{ + "enabled": true, + "actions": map[string]interface{}{ + "pending": []interface{}{}, // Block all for pending + "clean": []interface{}{"download", "share", "preview", "delete"}, // Allow all for clean + }, + }, + } + t.Cleanup(func() { + delete(conf.Contexts, "no-scan-test") + }) + + inst := &instance.Instance{ContextName: "no-scan-test"} + file := &vfs.FileDoc{} // No AntivirusScan - defaults to "clean" + + // Should be allowed because default is "clean" + err := CheckAntivirusAction(inst, file, ActionDownload) + assert.NoError(t, err) + }) + + t.Run("CleanFileAllowsAllActions", func(t *testing.T) { + conf := config.GetConfig() + if conf.Contexts == nil { + conf.Contexts = make(map[string]interface{}) + } + conf.Contexts["clean-test"] = map[string]interface{}{ + "antivirus": map[string]interface{}{ + "enabled": true, + "actions": map[string]interface{}{ + "clean": []interface{}{"download", "share", "preview", "delete"}, + }, + }, + } + t.Cleanup(func() { + delete(conf.Contexts, "clean-test") + }) + + inst := &instance.Instance{ContextName: "clean-test"} + file := &vfs.FileDoc{ + AntivirusScan: &vfs.AntivirusScan{ + Status: vfs.AVStatusClean, + }, + } + + assert.NoError(t, CheckAntivirusAction(inst, file, ActionDownload)) + assert.NoError(t, CheckAntivirusAction(inst, file, ActionShare)) + assert.NoError(t, CheckAntivirusAction(inst, file, ActionPreview)) + assert.NoError(t, CheckAntivirusAction(inst, file, ActionDelete)) + }) + + t.Run("InfectedFileBlocksDownload", func(t *testing.T) { + conf := config.GetConfig() + if conf.Contexts == nil { + conf.Contexts = make(map[string]interface{}) + } + conf.Contexts["infected-test"] = map[string]interface{}{ + "antivirus": map[string]interface{}{ + "enabled": true, + "actions": map[string]interface{}{ + "infected": []interface{}{"delete"}, // Only delete allowed + }, + }, + } + t.Cleanup(func() { + delete(conf.Contexts, "infected-test") + }) + + inst := &instance.Instance{ContextName: "infected-test"} + file := &vfs.FileDoc{ + AntivirusScan: &vfs.AntivirusScan{ + Status: vfs.AVStatusInfected, + VirusName: "EICAR-Test-File", + }, + } + + // Download should be blocked + err := CheckAntivirusAction(inst, file, ActionDownload) + require.Error(t, err) + jsonErr, ok := err.(*jsonapi.Error) + require.True(t, ok) + assert.Equal(t, http.StatusUnavailableForLegalReasons, jsonErr.Status) + assert.Equal(t, "antivirus_blocked", jsonErr.Code) + assert.Contains(t, jsonErr.Detail, "download") + assert.Contains(t, jsonErr.Detail, "infected") + assert.Contains(t, jsonErr.Detail, "EICAR-Test-File") + + // Share should be blocked + err = CheckAntivirusAction(inst, file, ActionShare) + require.Error(t, err) + + // Preview should be blocked + err = CheckAntivirusAction(inst, file, ActionPreview) + require.Error(t, err) + + // Delete should be allowed + err = CheckAntivirusAction(inst, file, ActionDelete) + assert.NoError(t, err) + }) + + t.Run("PendingFileConfigurable", func(t *testing.T) { + conf := config.GetConfig() + if conf.Contexts == nil { + conf.Contexts = make(map[string]interface{}) + } + conf.Contexts["pending-test"] = map[string]interface{}{ + "antivirus": map[string]interface{}{ + "enabled": true, + "actions": map[string]interface{}{ + "pending": []interface{}{"preview", "delete"}, // Only preview and delete allowed + }, + }, + } + t.Cleanup(func() { + delete(conf.Contexts, "pending-test") + }) + + inst := &instance.Instance{ContextName: "pending-test"} + file := &vfs.FileDoc{ + AntivirusScan: &vfs.AntivirusScan{ + Status: vfs.AVStatusPending, + }, + } + + // Download blocked + err := CheckAntivirusAction(inst, file, ActionDownload) + require.Error(t, err) + + // Share blocked + err = CheckAntivirusAction(inst, file, ActionShare) + require.Error(t, err) + + // Preview allowed + err = CheckAntivirusAction(inst, file, ActionPreview) + assert.NoError(t, err) + + // Delete allowed + err = CheckAntivirusAction(inst, file, ActionDelete) + assert.NoError(t, err) + }) + + t.Run("UnconfiguredStatusAllowsAll", func(t *testing.T) { + conf := config.GetConfig() + if conf.Contexts == nil { + conf.Contexts = make(map[string]interface{}) + } + conf.Contexts["unconfigured-test"] = map[string]interface{}{ + "antivirus": map[string]interface{}{ + "enabled": true, + "actions": map[string]interface{}{ + "clean": []interface{}{"download"}, // Only clean is configured + }, + }, + } + t.Cleanup(func() { + delete(conf.Contexts, "unconfigured-test") + }) + + inst := &instance.Instance{ContextName: "unconfigured-test"} + file := &vfs.FileDoc{ + AntivirusScan: &vfs.AntivirusScan{ + Status: vfs.AVStatusSkipped, // Not configured in actions + }, + } + + // Should allow all actions when status not configured + assert.NoError(t, CheckAntivirusAction(inst, file, ActionDownload)) + assert.NoError(t, CheckAntivirusAction(inst, file, ActionShare)) + assert.NoError(t, CheckAntivirusAction(inst, file, ActionPreview)) + assert.NoError(t, CheckAntivirusAction(inst, file, ActionDelete)) + }) +} diff --git a/web/files/files.go b/web/files/files.go index 0773764508f..34b9e7853be 100644 --- a/web/files/files.go +++ b/web/files/files.go @@ -939,6 +939,10 @@ func ReadFileContentFromIDHandler(c echo.Context) error { return err } + if err = CheckAntivirusAction(instance, doc, ActionDownload); err != nil { + return err + } + disposition := "inline" if c.QueryParam("Dl") == "1" { disposition = "attachment" @@ -975,6 +979,10 @@ func ReadFileContentFromVersion(c echo.Context) error { return err } + if err = CheckAntivirusAction(instance, doc, ActionDownload); err != nil { + return err + } + version, err := vfs.FindVersion(instance, doc.DocID+"/"+c.Param("version-id")) if err != nil { return WrapVfsError(err) @@ -1057,6 +1065,10 @@ func IconHandler(c echo.Context) error { return WrapVfsError(err) } + if err = CheckAntivirusAction(instance, doc, ActionPreview); err != nil { + return err + } + return vfs.ServePDFIcon(c.Response(), c.Request(), instance.VFS(), doc) } @@ -1078,6 +1090,10 @@ func PreviewHandler(c echo.Context) error { return WrapVfsError(err) } + if err = CheckAntivirusAction(instance, doc, ActionPreview); err != nil { + return err + } + return vfs.ServePDFPreview(c.Response(), c.Request(), instance.VFS(), doc) } @@ -1099,6 +1115,10 @@ func ThumbnailHandler(c echo.Context) error { return WrapVfsError(err) } + if err = CheckAntivirusAction(instance, doc, ActionPreview); err != nil { + return err + } + fs := instance.ThumbsFS() format := c.Param("format") err = fs.ServeThumbContent(c.Response(), c.Request(), doc, format) @@ -1147,6 +1167,10 @@ func SendFileFromDoc(instance *instance.Instance, c echo.Context, doc *vfs.FileD } } + if err = CheckAntivirusAction(instance, doc, ActionDownload); err != nil { + return err + } + // Forbid extracting autofilled passwords on an HTML page hosted in the Cozy if !config.GetConfig().CSPDisabled { middlewares.AppendCSPRule(c, "form-action", "'none'") @@ -1234,6 +1258,12 @@ func ArchiveDownloadCreateHandler(c echo.Context) error { if err != nil { return err } + // Check antivirus status for files + if e.File != nil { + if err = CheckAntivirusAction(instance, e.File, ActionDownload); err != nil { + return err + } + } } // if accept header is application/zip, send the archive immediately @@ -1288,6 +1318,10 @@ func FileDownload(c echo.Context, sharedDrive *sharing.Sharing) error { return err } + if err = CheckAntivirusAction(instance, doc, ActionShare); err != nil { + return err + } + var secret string if versionID == "" { secret, err = vfs.GetStore().AddFile(instance, path) @@ -1330,6 +1364,20 @@ func ArchiveDownloadHandler(c echo.Context) error { if err != nil { return WrapVfsError(err) } + + // Check antivirus status for all files in the archive + entries, err := archive.GetEntries(instance.VFS()) + if err != nil { + return WrapVfsError(err) + } + for _, entry := range entries { + if entry.File != nil { + if err = CheckAntivirusAction(instance, entry.File, ActionDownload); err != nil { + return err + } + } + } + if err := archive.Serve(instance.VFS(), c.Response()); err != nil { return WrapVfsError(err) } @@ -1363,6 +1411,11 @@ func versionDownloadHandler(c echo.Context, secret string) error { if err != nil { return WrapVfsError(err) } + + if err = CheckAntivirusAction(instance, doc, ActionDownload); err != nil { + return err + } + version, err := vfs.FindVersion(instance, versionID) if err != nil { return WrapVfsError(err) @@ -1400,6 +1453,13 @@ func Trash(c echo.Context, sharedDrive *sharing.Sharing) error { return err } + // Check antivirus status for files + if file != nil { + if err = CheckAntivirusAction(instance, file, ActionDelete); err != nil { + return err + } + } + var rev string if dir != nil { rev = dir.Rev() @@ -1549,6 +1609,13 @@ func DestroyFileHandler(c echo.Context) error { return err } + // Check antivirus status for files + if file != nil { + if err = CheckAntivirusAction(inst, file, ActionDelete); err != nil { + return err + } + } + var rev string if dir != nil { rev = dir.Rev()