Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bf372d7
docs: plan the next CrowdSec follow-up batch
Wikid82 Oct 9, 2026
4239f84
test(e2e): add fixme specs for editor secrets and response paths
Wikid82 Oct 9, 2026
b6dd527
fix(security): harden CrowdSec editor access
Wikid82 Oct 9, 2026
081ff47
fix: show CrowdSec backups by name
Wikid82 Oct 9, 2026
c8bed1f
fix: accept archive entries that merely start with two dots
Wikid82 Oct 9, 2026
624c6eb
refactor: remove an unused HTTP client option
Wikid82 Oct 9, 2026
4d7434e
refactor: restrict the hub URL check to production hosts
Wikid82 Oct 9, 2026
cc20449
test: enable the CrowdSec editor and response checks and document the…
Wikid82 Oct 9, 2026
ac9f059
test(e2e): shared CrowdSec stub helpers
Wikid82 Oct 9, 2026
29c0ead
test(e2e): deterministic crowdsec-config non-preset cases
Wikid82 Oct 9, 2026
1e7757a
test(e2e): deterministic crowdsec dashboard and decisions
Wikid82 Oct 9, 2026
10daf9d
test(e2e): deterministic crowdsec console enrollment
Wikid82 Oct 9, 2026
434e8eb
test(e2e): deterministic crowdsec diagnostics
Wikid82 Oct 9, 2026
2f4bb5d
fix: stop compressing the CrowdSec config export twice
Wikid82 Oct 9, 2026
8764187
fix: add the missing translation keys on the CrowdSec config page
Wikid82 Oct 9, 2026
8102cc2
fix: correct CrowdSec console enrollment form defaults and error feed…
Wikid82 Oct 9, 2026
dd30330
test(e2e): enable the CrowdSec specs for the fixed findings
Wikid82 Oct 9, 2026
d140bfe
test(e2e): deterministic crowdsec import
Wikid82 Oct 9, 2026
d834c6b
test: cover remaining CrowdSec backup and archive error paths
Wikid82 Oct 10, 2026
d0d036a
test: cover remaining CrowdSec config page branches
Wikid82 Oct 10, 2026
efedc8a
docs: update QA report for CrowdSec follow-up hardening
Wikid82 Oct 10, 2026
670e262
fix: return fixed messages from CrowdSec status and diagnostics endpo…
Wikid82 Oct 10, 2026
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
2 changes: 0 additions & 2 deletions backend/integration/crowdsec_lapi_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -532,8 +532,6 @@ func TestCrowdSecDiagnosticsConfig(t *testing.T) {
optionalFields := []string{
"config_valid",
"acquis_valid",
"config_path",
"acquis_path",
}

for _, field := range optionalFields {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ func TestApplyCuratedPresetInstallsViaCSCLI(t *testing.T) {
require.Equal(t, "curated-"+slug, resp["cache_key"])
backup, _ := resp["backup"].(string)
require.NotEmpty(t, backup)
require.DirExists(t, backup)
require.Equal(t, filepath.Base(backup), backup, "response carries a name, not a path")
require.DirExists(t, filepath.Join(filepath.Dir(c.dataDir), backup))

preset, ok := crowdsec.FindPreset(slug)
require.True(t, ok)
Expand All @@ -133,7 +134,7 @@ func TestApplyCuratedPresetInstallsViaCSCLI(t *testing.T) {
require.Len(t, events, 1)
require.Equal(t, slug, events[0].Slug)
require.Equal(t, "applied", events[0].Status)
require.Equal(t, backup, events[0].BackupPath)
require.Equal(t, backup, filepath.Base(events[0].BackupPath))
}

func TestApplyCuratedPresetCSCLIUnavailableReturns503(t *testing.T) {
Expand Down Expand Up @@ -180,7 +181,7 @@ func TestApplyCuratedPresetInstallFailureReturns500WithBackup(t *testing.T) {
events := c.events()
require.Len(t, events, 1)
require.Equal(t, "failed", events[0].Status)
require.Equal(t, backup, events[0].BackupPath)
require.Equal(t, backup, filepath.Base(events[0].BackupPath))
require.Contains(t, events[0].Error, "install exploded")
}

Expand Down
7 changes: 5 additions & 2 deletions backend/internal/api/handlers/crowdsec_data_mutation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -104,8 +105,10 @@ func TestImportConfigReplacesConfigButPreservesEngineStateAndDataDir(t *testing.

var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
backup, _ := resp["backup"].(string)
require.Equal(t, dir+".backup.", backup[:len(dir)+len(".backup.")])
backupName, _ := resp["backup"].(string)
require.True(t, strings.HasPrefix(backupName, filepath.Base(dir)+".backup."), backupName)
require.Equal(t, filepath.Base(backupName), backupName, "response carries a name, not a path")
backup := filepath.Join(filepath.Dir(dir), backupName)

after, err := os.Stat(dir)
require.NoError(t, err)
Expand Down
15 changes: 7 additions & 8 deletions backend/internal/api/handlers/crowdsec_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ var (
".yaml": {}, ".yml": {}, ".json": {}, ".txt": {}, ".conf": {},
}

// protectedWriteNames are credential files the editor never overwrites.
protectedWriteNames = map[string]struct{}{
// hiddenSecretNames are lowercase base names of secret files that are neither listed nor readable nor
// writable through the editor.
hiddenSecretNames = map[string]struct{}{
"bouncer_key": {},
"local_api_credentials.yaml": {},
"online_api_credentials.yaml": {},
}
Expand Down Expand Up @@ -75,14 +77,14 @@ func cleanRelPath(raw string) (string, *fileError) {
}

// isReadable reports whether rel may be listed and read: engine-owned state (databases, the data and
// hub_cache trees) and secrets are hidden, everything else stays visible.
// hub_cache trees) and secrets (see hiddenSecretNames) are hidden, everything else stays visible.
func isReadable(rel string) bool {
slash := filepath.ToSlash(rel)
if slash == "" || slash == "." || crowdsec.IsEngineOwnedPath(slash) {
return false
}
base := strings.ToLower(path.Base(slash))
if base == "bouncer_key" {
if _, hidden := hiddenSecretNames[base]; hidden {
return false
}
if matched, _ := path.Match("*.db*", base); matched {
Expand All @@ -102,9 +104,6 @@ func isWritable(rel string) bool {
if _, ok := writableExtensions[strings.ToLower(path.Ext(base))]; !ok {
return false
}
if _, protected := protectedWriteNames[base]; protected {
return false
}
if strings.HasPrefix(base, writeTempPrefix) {
return false
}
Expand Down Expand Up @@ -412,7 +411,7 @@ func (h *CrowdsecHandler) WriteFile(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "written", "backup": backupDir})
c.JSON(http.StatusOK, gin.H{"status": "written", "backup": crowdsec.BackupID(backupDir)})
}

// fileErrorLog returns a log entry carrying err with control characters
Expand Down
98 changes: 77 additions & 21 deletions backend/internal/api/handlers/crowdsec_files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,25 +92,31 @@ func errorOf(t *testing.T, w *httptest.ResponseRecorder) string {
func TestCrowdsecFiles_Predicates(t *testing.T) {
t.Parallel()
readable := map[string]bool{
"config.yaml": true,
"config/config.yaml": true,
"config/scenarios/x.yaml": true,
"config/acquis.d/a.conf": true,
"notes.md": true,
"config/data/nested.yaml": true, // only the top-level data/ is protected
"bouncer_key": false,
"config/bouncer_key": false,
"crowdsec.db": false,
"config/crowdsec.db-wal": false,
"data/crowdsec.db": false,
"data/GeoLite2-City.mmdb": false,
"hub_cache/a.tgz": false,
"config/other.db": false,
"config/other.db-journal": false,
"config/CASE.DB": false,
"data": false,
"hub_cache": false,
"config/hub_cache/thing.yaml": true,
"config.yaml": true,
"config/config.yaml": true,
"config/scenarios/x.yaml": true,
"config/acquis.d/a.conf": true,
"notes.md": true,
"config/data/nested.yaml": true, // only the top-level data/ is protected
"bouncer_key": false,
"config/bouncer_key": false,
"crowdsec.db": false,
"config/crowdsec.db-wal": false,
"data/crowdsec.db": false,
"data/GeoLite2-City.mmdb": false,
"hub_cache/a.tgz": false,
"config/other.db": false,
"config/other.db-journal": false,
"config/CASE.DB": false,
"data": false,
"hub_cache": false,
"local_api_credentials.yaml": false,
"config/local_api_credentials.yaml": false,
"config/online_api_credentials.yaml": false,
"config/Local_API_Credentials.YAML": false,
"config/ONLINE_API_CREDENTIALS.yaml": false,
"config/local_api_credentials.yaml.bak": true,
"config/hub_cache/thing.yaml": true,
}
for rel, want := range readable {
assert.Equal(t, want, isReadable(rel), "isReadable(%q)", rel)
Expand Down Expand Up @@ -493,10 +499,12 @@ func TestCrowdsecFiles_Write_BackupHoldsOnlyPreviousVersionAndIsCappedIndependen
var resp map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.NotEmpty(t, resp["backup"])
prev, err := os.ReadFile(filepath.Join(resp["backup"], "config", "config.yaml")) // #nosec G304 -- test path
require.Equal(t, filepath.Base(resp["backup"]), resp["backup"], "response carries a name, not a path")
backupDir := filepath.Join(f.root, resp["backup"])
prev, err := os.ReadFile(filepath.Join(backupDir, "config", "config.yaml")) // #nosec G304 -- test path
require.NoError(t, err)
assert.Equal(t, "v: 0\n", string(prev))
_, err = os.Stat(filepath.Join(resp["backup"], "config", "other.yaml"))
_, err = os.Stat(filepath.Join(backupDir, "config", "other.yaml"))
assert.True(t, os.IsNotExist(err), "backup must hold only the replaced file")

for i := 2; i <= 14; i++ {
Expand Down Expand Up @@ -689,3 +697,51 @@ func TestCrowdsecFiles_Write_FilesystemFailuresAre500(t *testing.T) {
assert.NoFileExists(t, filepath.Join(cfg, "new.yaml"))
})
}

var credentialsFileNames = []string{
"local_api_credentials.yaml", "online_api_credentials.yaml",
"Local_API_Credentials.yaml", "ONLINE_API_CREDENTIALS.YAML",
}

func TestCrowdsecFiles_CredentialsHiddenFromListReadAndWrite(t *testing.T) {
t.Parallel()
f := newFilesFixture(t)
f.put("config/config.yaml", "a: 1\n")
for _, name := range credentialsFileNames {
f.put("config/"+name, "password: hunter2\n")
}

assert.Equal(t, []string{filepath.Join("config", "config.yaml")}, f.list())

for _, name := range credentialsFileNames {
rel := "config/" + name
w := f.read(rel)
assert.Equal(t, http.StatusBadRequest, w.Code, rel)
assert.Equal(t, "invalid path", errorOf(t, w), rel)
assert.NotContains(t, w.Body.String(), "hunter2", rel)

w = f.write(rel, "a: 1\n")
assert.Equal(t, http.StatusBadRequest, w.Code, rel)
assert.Equal(t, errFileCannotBeEdited, errorOf(t, w), rel)
}

for _, name := range credentialsFileNames[:2] {
b, err := os.ReadFile(filepath.Join(f.dir, "config", name)) // #nosec G304 -- test path
require.NoError(t, err)
assert.Equal(t, "password: hunter2\n", string(b), "rejected write must not modify %s", name)
}
}

func TestCrowdsecFiles_Read_SymlinkToCredentialsRefused(t *testing.T) {
t.Parallel()
f := newFilesFixture(t)
f.put("config/local_api_credentials.yaml", "password: hunter2\n")
require.NoError(t, os.Symlink(
filepath.Join(f.dir, "config", "local_api_credentials.yaml"),
filepath.Join(f.dir, "config", "innocent.yaml")))

w := f.read("config/innocent.yaml")
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Equal(t, "invalid path", errorOf(t, w))
assert.NotContains(t, w.Body.String(), "hunter2")
}
35 changes: 19 additions & 16 deletions backend/internal/api/handlers/crowdsec_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,8 @@ func (h *CrowdsecHandler) Start(c *gin.Context) {
revertSetting := models.Setting{Key: "security.crowdsec.enabled", Value: "false", Category: "security", Type: "bool"}
h.DB.Where(models.Setting{Key: "security.crowdsec.enabled"}).Assign(revertSetting).FirstOrCreate(&revertSetting)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
logger.Log().WithField("error", sanitizeForLog(err.Error())).Warn("failed to start crowdsec")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start CrowdSec"})
return
}

Expand Down Expand Up @@ -593,7 +594,8 @@ func (h *CrowdsecHandler) Start(c *gin.Context) {
func (h *CrowdsecHandler) Stop(c *gin.Context) {
ctx := c.Request.Context()
if err := h.Executor.Stop(ctx, h.DataDir); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
logger.Log().WithField("error", sanitizeForLog(err.Error())).Warn("failed to stop crowdsec")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to stop CrowdSec"})
return
}

Expand Down Expand Up @@ -621,7 +623,8 @@ func (h *CrowdsecHandler) Status(c *gin.Context) {
ctx := c.Request.Context()
running, pid, err := h.Executor.Status(ctx, h.DataDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
logger.Log().WithField("error", sanitizeForLog(err.Error())).Warn("failed to read crowdsec status")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read CrowdSec status"})
return
}

Expand Down Expand Up @@ -679,7 +682,7 @@ func (h *CrowdsecHandler) ImportConfig(c *gin.Context) {
}

if err = validator.Validate(dst); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("validation failed: %v", err)})
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("validation failed: %v", crowdsec.RedactPaths(err.Error()))})
return
}

Expand Down Expand Up @@ -711,20 +714,21 @@ func (h *CrowdsecHandler) ImportConfig(c *gin.Context) {
// Extract archive
extractErr := h.extractArchive(dst, h.DataDir)
if extractErr != nil {
logger.Log().WithField("error", sanitizeForLog(extractErr.Error())).Warn("crowdsec import extraction failed")
rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("extraction failed: %v", extractErr)})
c.JSON(http.StatusInternalServerError, gin.H{"error": "extraction failed"})
return
}

// Validate extracted config
configPath := filepath.Join(h.DataDir, "config.yaml")
if err := validateYAMLFile(configPath); err != nil {
rollback()
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("config validation failed: %v", err)})
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("config validation failed: %v", crowdsec.RedactPaths(err.Error()))})
return
}

c.JSON(http.StatusOK, gin.H{"status": "imported", "backup": backupDir})
c.JSON(http.StatusOK, gin.H{"status": "imported", "backup": crowdsec.BackupID(backupDir)})
}

// pruneSnapshots bounds the full-tree snapshots kept next to DataDir; failures are logged only.
Expand Down Expand Up @@ -875,8 +879,9 @@ func (h *CrowdsecHandler) ExportConfig(c *gin.Context) {
return nil
})
if err != nil {
logger.Log().WithField("error", sanitizeForLog(err.Error())).Warn("crowdsec export failed")
// If any error occurred while creating the archive, return 500
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to export crowdsec config"})
return
}
}
Expand Down Expand Up @@ -922,7 +927,7 @@ func (h *CrowdsecHandler) ConsoleEnroll(c *gin.Context) {
if h.Security != nil {
_ = h.Security.LogAudit(&models.SecurityAudit{Actor: auditActor(c), Action: "crowdsec_console_enroll_failed", Details: fmt.Sprintf("status=%s tenant=%s agent=%s correlation_id=%s", status.Status, payload.Tenant, payload.AgentName, status.CorrelationID)})
}
resp := gin.H{"error": err.Error(), "status": status.Status}
resp := gin.H{"error": crowdsec.RedactPaths(err.Error()), "status": status.Status}
if status.CorrelationID != "" {
resp["correlation_id"] = status.CorrelationID
}
Expand Down Expand Up @@ -973,7 +978,7 @@ func (h *CrowdsecHandler) DeleteConsoleEnrollment(c *gin.Context) {
ctx := c.Request.Context()
if err := h.Console.ClearEnrollment(ctx); err != nil {
logger.Log().WithError(err).Warn("failed to clear console enrollment state")
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to clear enrollment state"})
return
}

Expand Down Expand Up @@ -1972,7 +1977,7 @@ func (h *CrowdsecHandler) GetAcquisitionConfig(c *gin.Context) {
content, err := readAcquisitionConfig(acquisPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusNotFound, gin.H{"error": "acquisition config not found", "path": acquisPath})
c.JSON(http.StatusNotFound, gin.H{"error": "acquisition config not found"})
return
}
logger.Log().WithError(err).WithField("path", acquisPath).Warn("Failed to read acquisition config")
Expand All @@ -1982,7 +1987,6 @@ func (h *CrowdsecHandler) GetAcquisitionConfig(c *gin.Context) {

c.JSON(http.StatusOK, gin.H{
"content": string(content),
"path": acquisPath,
})
}

Expand Down Expand Up @@ -2027,7 +2031,7 @@ func (h *CrowdsecHandler) UpdateAcquisitionConfig(c *gin.Context) {

c.JSON(http.StatusOK, gin.H{
"status": "updated",
"backup": backupPath,
"backup": crowdsec.BackupID(backupPath),
"reload_hint": true,
})
}
Expand Down Expand Up @@ -2162,7 +2166,6 @@ func (h *CrowdsecHandler) DiagnosticsConfig(c *gin.Context) {

if _, err := os.Stat(cleanConfigPath); err == nil {
validation["config_exists"] = true
validation["config_path"] = cleanConfigPath

// Read config and check LAPI port
// #nosec G304 -- Path validated against DataDir above
Expand Down Expand Up @@ -2207,7 +2210,6 @@ func (h *CrowdsecHandler) DiagnosticsConfig(c *gin.Context) {

if _, err := os.Stat(cleanAcquisPath); err == nil {
validation["acquis_exists"] = true
validation["acquis_path"] = cleanAcquisPath

// Check if it has datasources
// #nosec G304 -- Path validated against DataDir above
Expand Down Expand Up @@ -2245,7 +2247,8 @@ func (h *CrowdsecHandler) ConsoleHeartbeat(c *gin.Context) {
ctx := c.Request.Context()
status, err := h.Console.Status(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
logger.Log().WithField("error", sanitizeForLog(err.Error())).Warn("failed to read console heartbeat status")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read enrollment status"})
return
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func TestCrowdsec_Start_Error(t *testing.T) {
r.ServeHTTP(w, req)

assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "failed to start crowdsec")
assert.Contains(t, w.Body.String(), "failed to start CrowdSec")
}

func TestCrowdsec_Stop_Error(t *testing.T) {
Expand All @@ -61,7 +61,7 @@ func TestCrowdsec_Stop_Error(t *testing.T) {
r.ServeHTTP(w, req)

assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "failed to stop crowdsec")
assert.Contains(t, w.Body.String(), "failed to stop CrowdSec")
}

func TestCrowdsec_Status_Error(t *testing.T) {
Expand All @@ -79,7 +79,7 @@ func TestCrowdsec_Status_Error(t *testing.T) {
r.ServeHTTP(w, req)

assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "failed to get status")
assert.Contains(t, w.Body.String(), "failed to read CrowdSec status")
}

// ReadFile tests
Expand Down
Loading
Loading