Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions web/files/antivirus.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
213 changes: 213 additions & 0 deletions web/files/antivirus_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
Loading
Loading