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
8 changes: 6 additions & 2 deletions .docker/compose/docker-compose.playwright-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,12 @@ services:
tmpfs:
# True tmpfs for E2E test data - fresh on every run, in-memory only
# mode=1777 allows any user to write (container runs as non-root)
# 256M gives headroom for the backup service's 100MB disk-space check
- /app/data:size=256M,mode=1777
# 512M: the baseline data is ~85MB and each application backup the CrowdSec import UI
# creates is ~40MB (7 retained => ~280MB), so 256M filled up across repeated runs against
# one container (#1558). RAM is only used on demand. The backup service also needs 100MB
# free for its disk-space check. The specs delete their own backups (best effort); this
# size is the backstop when a run is interrupted.
- /app/data:size=512M,mode=1777
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro # For container discovery in tests
labels:
Expand Down
25 changes: 23 additions & 2 deletions backend/internal/api/handlers/crowdsec_data_mutation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,27 @@ func assertEngineStateIntact(t *testing.T, dir string) {
require.Equal(t, "archive", readTreeFile(t, filepath.Join(dir, "hub_cache", "x", "bundle.tgz")))
}

func TestImportConfigSkipsMixedCaseEngineOwnedEntries(t *testing.T) {
t.Parallel()
dir := t.TempDir()
seedEngineState(t, dir)
_, r := newImportRouter(t, dir)

w := httptest.NewRecorder()
r.ServeHTTP(w, importRequest(t, map[string]string{
"config.yaml": importableConfig,
"Data/crowdsec.db": "evil-lapi",
"HUB_CACHE/y": "evil",
"CrowdSec.DB-WAL": "evil",
}))
require.Equal(t, http.StatusOK, w.Code, w.Body.String())

assertEngineStateIntact(t, dir)
require.NoDirExists(t, filepath.Join(dir, "Data"))
require.NoDirExists(t, filepath.Join(dir, "HUB_CACHE"))
require.NoFileExists(t, filepath.Join(dir, "CrowdSec.DB-WAL"))
}

func TestImportConfigReplacesConfigButPreservesEngineStateAndDataDir(t *testing.T) {
t.Parallel()
dir := t.TempDir()
Expand Down Expand Up @@ -250,7 +271,7 @@ func TestDataMutationsSerializeOnHandlerLock(t *testing.T) {
}

for name, do := range requests {
c.handler.dataMu.Lock()
holdLock(&c.handler.dataMu)
done := make(chan *httptest.ResponseRecorder, 1)
go func() { done <- do() }()

Expand All @@ -277,7 +298,7 @@ func TestImportConfigWaitsForHandlerLock(t *testing.T) {
seedEngineState(t, dir)
h, r := newImportRouter(t, dir)

h.dataMu.Lock()
holdLock(&h.dataMu)
done := make(chan int, 1)
go func() {
w := httptest.NewRecorder()
Expand Down
5 changes: 2 additions & 3 deletions backend/internal/api/handlers/crowdsec_export_import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,6 @@ func TestImportConfigLayouts(t *testing.T) {
t.Parallel()
cases := map[string]map[string]string{
"config only": {"config/config.yaml": importableConfig},
"root layout": {"config.yaml": importableConfig, "other.yaml": "o"},
"real and extra": {"config/config.yaml": importableConfig, "config/acquis.yaml": "a"},
}
for name, files := range cases {
Expand Down Expand Up @@ -270,7 +269,7 @@ func TestImportConfigFailureKeepsSecretsAndRestoresConfig(t *testing.T) {
w := httptest.NewRecorder()
r.ServeHTTP(w, importRequest(t, map[string]string{"foo/config.yaml": importableConfig}))
require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), "config.yaml was not found at the top level or in config/")
require.Contains(t, w.Body.String(), "validation failed: required file missing: config.yaml")
require.Equal(t, importableConfig, readTreeFile(t, filepath.Join(dir, "config", "config.yaml")))
assertLiveSecretsIntact(t, dir)
})
Expand Down Expand Up @@ -501,7 +500,7 @@ func TestEditorHidesPidBouncersAndHub(t *testing.T) {
} {
require.Equal(t, readable, isReadable(rel), rel)
}
require.True(t, isWritable("config/Hub/x.yaml"))
require.False(t, isWritable("config/Hub/x.yaml"), "hub paths are protected whatever the letter case")
require.False(t, isWritable("config/hub/x.yaml"))
require.False(t, isWritable("hub/x.yaml"))
}
Expand Down
4 changes: 3 additions & 1 deletion backend/internal/api/handlers/crowdsec_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,9 @@ func (h *CrowdsecHandler) WriteFile(c *gin.Context) {
return
}

h.dataMu.Lock()
if !h.lockData(c, "file write") {
return
}
defer h.dataMu.Unlock()

target, ferr := resolveWritable(h.DataDir, rel)
Expand Down
122 changes: 110 additions & 12 deletions backend/internal/api/handlers/crowdsec_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ type CrowdsecHandler struct {
// dataMu serializes every operation that mutates DataDir (preset apply, config import, file write)
// and the export build; an export holds it only while writing its temporary file, never while sending.
// Lock order is always dataMu first, then HubService.mu.
dataMu sync.Mutex
// The wait is abandoned when the request ends (see lockData).
dataMu ctxMutex

// restore holds the state of the hub item restore action (crowdsec_hub_items.go).
restore hubRestoreState
Expand Down Expand Up @@ -228,21 +229,36 @@ func (v *ConfigArchiveValidator) Validate(path string) error {
}

for _, required := range v.RequiredFiles {
found := false
for _, file := range contents {
if filepath.Base(file) == required || file == required {
found = true
break
}
}
if !found {
return fmt.Errorf("required file missing: %s", required)
if err := requireArchiveFile(contents, required); err != nil {
return err
}
}

return nil
}

// requireArchiveFile checks that the archive holds file at the top level or in config/, after the same
// normalisation the extractor applies ("./a", "a//b" and "b/../a" are spelled alike). A copy in any
// other folder, such as parsers/config.yaml, does not count. An entry that would leave the destination
// is reported as invalid instead of missing.
func requireArchiveFile(contents []string, file string) error {
var escaped string
for _, name := range contents {
rel, ok := crowdsec.ArchiveEntryRel(name)
if !ok {
escaped = name
continue
}
if rel = filepath.ToSlash(rel); rel == file || rel == "config/"+file {
return nil
}
}
if escaped != "" {
return fmt.Errorf("invalid file path: %s", escaped)
}
return fmt.Errorf("required file missing: %s", file)
}

// errArchiveFormat is the fixed message for any upload that is not a .tar.gz archive.
var errArchiveFormat = errors.New("only .tar.gz archives are supported")

Expand Down Expand Up @@ -325,6 +341,17 @@ func listArchiveContents(path string) ([]string, error) {
return files, nil
}

const (
// msgKeptProfileUndefined and msgKeptProfileDuplicate refuse an import without profiles.yaml whose
// kept profile no longer matches the notification files. The undefined names follow the prefix.
msgKeptProfileUndefined = "the archive has no profiles.yaml, so the current one was kept, but it names notifications the archive has no files for: "
msgKeptProfileDuplicate = "the archive has no profiles.yaml, so the current one was kept, but a notification file it needs defines a name the archive already defines"
)

// msgImportFlatArchive is the fixed message for an archive whose configuration is not in the folder a
// nested installation reads.
const msgImportFlatArchive = "the archive keeps its configuration at the top level, but this CrowdSec installation reads it from the config folder; export a configuration from this page, or put the files in a config/ folder inside the archive"

// errConfigFileNotFound is the fixed message for an import that contains no config.yaml in a
// location the engine reads (top level or config/).
var errConfigFileNotFound = errors.New("config.yaml was not found at the top level or in config/")
Expand Down Expand Up @@ -733,9 +760,22 @@ func (h *CrowdsecHandler) ImportConfig(c *gin.Context) {
}

// Serialize with preset applies and file writes, then snapshot (copy, never rename) the config.
h.dataMu.Lock()
if !h.lockData(c, "import") {
return
}
defer h.dataMu.Unlock()

// A nested installation reads config/config.yaml: refuse an archive that cannot provide it before
// anything is snapshotted or changed. When the inspection was skipped the check after extraction
// enforces the same rule.
liveLayout := crowdsec.DetectLayout(h.DataDir)
if inspectErr == nil {
if err = crowdsec.CheckImportLayout(liveLayout, info); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": msgImportFlatArchive})
return
}
}

backupDir, err := crowdsec.Snapshot(h.DataDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup"})
Expand Down Expand Up @@ -769,6 +809,29 @@ func (h *CrowdsecHandler) ImportConfig(c *gin.Context) {
return
}

if liveLayout == crowdsec.LayoutNested {
if _, statErr := os.Stat(filepath.Join(h.DataDir, "config", "config.yaml")); statErr != nil {
rollback()
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": msgImportFlatArchive})
return
}
}

// The import cleared every file the archive did not replace: put back the ones CrowdSec cannot start
// without, and make sure a kept profile still resolves its notifications.
keptFiles, restoreErr := crowdsec.RestoreMissingCompanions(backupDir, h.DataDir, liveConfigPrefix(liveLayout))
if restoreErr != nil {
logger.Log().WithField("error", sanitizeForLog(restoreErr.Error())).Warn("crowdsec import: restoring the current files failed")
rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to keep the current CrowdSec files"})
return
}
if verifyErr := crowdsec.VerifyKeptProfile(h.DataDir, keptFiles); verifyErr != nil {
rollback()
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": keptProfileRefusal(verifyErr)})
return
}

// Validate extracted config
validateErr := errConfigFileNotFound
if configPath := crowdsec.FindConfigFile(h.DataDir); configPath != "" {
Expand All @@ -782,9 +845,30 @@ func (h *CrowdsecHandler) ImportConfig(c *gin.Context) {

resp := gin.H{"status": "imported", "backup": crowdsec.BackupID(backupDir)}
h.addHubItemsReport(resp, info.HubItems)
if len(keptFiles) > 0 {
resp["kept_files"] = keptFiles
}
c.JSON(http.StatusOK, resp)
}

// liveConfigPrefix is the folder below DataDir that holds the configuration CrowdSec reads.
func liveConfigPrefix(l crowdsec.Layout) string {
if l == crowdsec.LayoutNested {
return "config"
}
return ""
}

// keptProfileRefusal maps a failure of crowdsec.VerifyKeptProfile (an undefined or a duplicate
// notification) to its fixed 422 message.
func keptProfileRefusal(err error) string {
var undefined *crowdsec.UndefinedNotificationsError
if errors.As(err, &undefined) {
return msgKeptProfileUndefined + undefined.NameList()
}
return msgKeptProfileDuplicate
}

// pruneSnapshots bounds the full-tree snapshots kept next to DataDir; failures are logged only.
func (h *CrowdsecHandler) pruneSnapshots() {
if _, err := crowdsec.PruneBackups(h.DataDir, crowdsec.BackupKindSnapshot, crowdsec.DefaultBackupRetention); err != nil {
Expand Down Expand Up @@ -927,9 +1011,23 @@ func (h *CrowdsecHandler) buildExport(ctx context.Context, tmp *os.File) (int64,
return info.Size(), nil
}

// lockData takes dataMu, giving up when the request ends. On false the lock is NOT held and the request
// is aborted without a body (the client is gone), so the caller must return before registering its
// deferred Unlock.
func (h *CrowdsecHandler) lockData(c *gin.Context, op string) bool {
if err := h.dataMu.LockContext(c.Request.Context()); err != nil {
logger.Log().WithField("operation", op).WithField("error", sanitizeForLog(err.Error())).Debug("crowdsec: request ended while waiting for the data lock")
c.Abort()
return false
}
return true
}

// writeExportLocked serializes the archive build with every other writer of DataDir.
func (h *CrowdsecHandler) writeExportLocked(ctx context.Context, w io.Writer) (int64, error) {
h.dataMu.Lock()
if err := h.dataMu.LockContext(ctx); err != nil {
return 0, fmt.Errorf("wait for the data lock: %w", err)
}
defer h.dataMu.Unlock()
return crowdsec.WriteExportArchive(ctx, h.DataDir, w)
}
Expand Down
Loading
Loading