VYPR
High severity7.7GHSA Advisory· Published May 28, 2026· Updated May 28, 2026

Arcane Has an Authenticated Arbitrary Host File Read via Docker Compose Include Directives

CVE-2026-47179

Description

Summary

ProjectService.GetProjectFileContent returns the contents of any Docker Compose include directive declared in a project's compose file before any path-traversal validation runs. Because ProjectService.CreateProject writes attacker-supplied compose content to disk without validating include paths, an authenticated user can create a project whose compose file declares include: ['../../../../etc/passwd'], then read the include via the project file API. The result is arbitrary read of any file readable by the Arcane backend process, including /app/data/arcane.db (the SQLite database containing every user's password hash and API key), enabling escalation to admin and, via Arcane's Docker control plane, RCE on the host.

Details

**Root cause #1 — CreateProject writes compose content without validation** (backend/internal/services/project_service.go:1605-1644):

func (s *ProjectService) CreateProject(ctx context.Context, name, composeContent string, envContent *string, user models.User) (*models.Project, error) {
    // ... directory setup ...
    if err := projects.SaveOrUpdateProjectFiles(projectsDirectory, projectPath, composeContent, envContent); err != nil {
        _ = s.db.WithContext(ctx).Delete(proj).Error
        return nil, fmt.Errorf("failed to save project files: %w", err)
    }
    // ...
}

Compare with UpdateProject (project_service.go:2494, :2577), which calls validateComposeContentForUpdate. That validator (line 2599) loads the compose with missingIncludeStubResourceLoaderInternal, which calls ValidateIncludePathForWrite (includes.go:139) and rejects includes outside the project directory. CreateProject bypasses this entirely, so any malicious include: array survives to disk.

**Root cause #2 — GetProjectFileContent reads include files before path validation** (backend/internal/services/project_service.go:831-872):

includes, parseErr := projects.ParseIncludes(composeFile, envMap, true)
if parseErr == nil {
    for _, inc := range includes {
        if inc.RelativePath == relativePath {
            return project.IncludeFile{
                Path:         inc.Path,
                RelativePath: inc.RelativePath,
                Content:      inc.Content,    // <-- arbitrary file content returned here
            }, nil
        }
    }
}

fullPath := filepath.Join(proj.Path, relativePath)
// ... IsSafeSubdirectory check at line 870 — never reached when include matches ...

**Root cause #3 — ParseIncludes reads include files from anywhere by design** (backend/pkg/projects/includes.go:24-72):

// Security Model for Include Files:
// - READ: Docker Compose allows include files from anywhere (parent dirs, absolute paths, etc.)
//         We allow reading from any path to maintain compatibility with standard Docker Compose behavior
// - WRITE/DELETE: Restricted to files within the project directory only for security

parseIncludeItemInternal at includes.go:97-101 builds fullPath = filepath.Clean(filepath.Join(baseDir, includePath)) and os.ReadFile(fullPath) at line 105 — no containment check. The returned RelativePath (line 124) is filepath.ToSlash(filepath.Clean(includePath)), which preserves ../../../../etc/passwd verbatim for the equality match in GetProjectFileContent.

Authorization surface: The handler GET /api/environments/{id}/projects/{projectId}/file (backend/internal/huma/handlers/projects.go:268-279) and POST /api/environments/{id}/projects (line 242-253) only declare BearerAuth/ApiKeyAuth. There is no admin-role gate on either handler — GetProjectFile (line 582) and CreateProject (line 524) simply call humamw.GetCurrentUserFromContext. The default user role assigned in users.go:223 is "user" (not admin), and that role is sufficient to exploit.

Resulting primitive: arbitrary read of any file readable by the Arcane backend process (uid/gid of the container). Sensitive targets include /app/data/arcane.db (SQLite containing argon2 password hashes and API keys for every user), /app/data/secrets/*, mounted host configuration, SSH keys (if mounted), and Docker socket-adjacent secrets.

Impact

  • Arbitrary file read as the Arcane backend process for any authenticated user, including users with the lowest-privilege "user" role.
  • Credential disclosure: arcane.db contains argon2 password hashes for every account (including admins) and API key material — supports offline cracking and direct token exfiltration.
  • Privilege escalation: a "user"-role attacker can recover or replay admin credentials, then exercise full Arcane functionality (Docker container/exec/volume control), which on a typical deployment with the host Docker socket mounted is host RCE.
  • Configuration / secret exposure: any environment files, OIDC client secrets, registry credentials, or files mounted into the container are reachable.
  • The scope crosses the security authority of other user accounts (S:C), since one authenticated user reads credentials belonging to other users and to the admin.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Authenticated user can read arbitrary host files via Docker Compose include directives in Arcane by combining unvalidated project creation and path-traversal reads.

Vulnerability

CVE-2026-47179 affects Arcane backend's ProjectService (fixed in commit b6cbffa). Two missing validations enable the attack: CreateProject (line 1605) writes attacker-supplied compose content to disk without validating include: directives, unlike UpdateProject which calls ValidateIncludePathForWrite [1]. Second, GetProjectFileContent (line 831) resolves include file contents before path-traversal checks [2]. Any authenticated user can create a project with a compose file containing include: ['../../../../etc/passwd'] and read its content via the project file API.

Exploitation

An attacker with a valid Arcane account and API access calls CreateProject with a compose file that includes a path-traversal payload (e.g., include: ['../../../../etc/passwd']). The server writes this file without validation. The attacker then calls GetProjectFileContent with the relative path of the include, which the server resolves and returns the target file content. No additional privileges, race conditions, or user interaction are required.

Impact

Successful exploitation yields arbitrary read of any file readable by the backend process. This includes /app/data/arcane.db, the SQLite database containing all user password hashes and API keys. An attacker can exfiltrate this database, compromise admin credentials, and then use Arcane's Docker control plane to achieve remote code execution on the host [2][3].

Mitigation

A fix is available in commit b6cbffabf61dbc3f12a28d3b5830e3c6b7e67daf [1]. The patch adds validation in CreateProject to reject includes outside the project directory and moves the path check before file read in GetProjectFileContent. No workaround exists; users should update to the patched version immediately.

AI Insight generated on May 28, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
b6cbffabf61d

fix: block unsafe compose include file reads (#2630)

https://github.com/getarcaneapp/arcaneKyle MendellMay 17, 2026via ghsa-ref
4 files changed · +402 34
  • backend/internal/services/project_service.go+84 7 modified
    @@ -1039,16 +1039,16 @@ func (s *ProjectService) GetProjectFileContent(ctx context.Context, projectID, r
     		envLoader := projects.NewEnvLoader(projectsDirectory, filepath.Dir(composeFile), utils.BoolOrDefault(cfg.AutoInjectEnv.Value, false))
     		envMap, _, _ := envLoader.LoadEnvironment(ctx)
     
    -		includes, parseErr := projects.ParseIncludes(composeFile, envMap, true)
    +		includes, parseErr := projects.ParseIncludes(composeFile, envMap, false)
     		if parseErr == nil {
     			for _, inc := range includes {
    -				if inc.RelativePath == relativePath {
    -					return project.IncludeFile{
    -						Path:         inc.Path,
    -						RelativePath: inc.RelativePath,
    -						Content:      inc.Content,
    -					}, nil
    +				if inc.RelativePath != relativePath {
    +					continue
    +				}
    +				if !projects.IsSafeSubdirectory(proj.Path, inc.Path) {
    +					return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: fmt.Errorf("file path is outside project directory")}
     				}
    +				return readProjectIncludeFileContentInternal(proj.Path, inc)
     			}
     		}
     	}
    @@ -1098,6 +1098,60 @@ func (s *ProjectService) GetProjectFileContent(ctx context.Context, projectID, r
     	}, nil
     }
     
    +func readProjectIncludeFileContentInternal(projectPath string, inc projects.IncludeFile) (project.IncludeFile, error) {
    +	validatedPath, err := projects.ValidateIncludePathForWrite(projectPath, inc.Path)
    +	if err != nil {
    +		return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: fmt.Errorf("file path is outside project directory")}
    +	}
    +
    +	resolvedProjectPath, err := filepath.EvalSymlinks(projectPath)
    +	if err != nil {
    +		return project.IncludeFile{}, fmt.Errorf("failed to resolve project path: %w", err)
    +	}
    +	resolvedPath, err := filepath.EvalSymlinks(validatedPath)
    +	if err != nil {
    +		if os.IsNotExist(err) {
    +			return project.IncludeFile{
    +				Path:         validatedPath,
    +				RelativePath: inc.RelativePath,
    +				Content:      "# This file will be created when you save changes\nservices:\n",
    +			}, nil
    +		}
    +		return project.IncludeFile{}, fmt.Errorf("failed to resolve include file: %w", err)
    +	}
    +	if !projects.IsSafeSubdirectory(resolvedProjectPath, resolvedPath) {
    +		return project.IncludeFile{}, &common.ProjectFileForbiddenError{Err: fmt.Errorf("file path is outside project directory")}
    +	}
    +
    +	info, err := os.Stat(resolvedPath)
    +	if err != nil {
    +		if os.IsNotExist(err) {
    +			return project.IncludeFile{}, &common.ProjectFileNotFoundError{}
    +		}
    +		return project.IncludeFile{}, fmt.Errorf("failed to stat include file: %w", err)
    +	}
    +	if info.IsDir() {
    +		return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: fmt.Errorf("path refers to a directory")}
    +	}
    +
    +	content, err := os.ReadFile(resolvedPath)
    +	if err != nil {
    +		if os.IsNotExist(err) {
    +			return project.IncludeFile{}, &common.ProjectFileNotFoundError{}
    +		}
    +		return project.IncludeFile{}, fmt.Errorf("failed to read include file: %w", err)
    +	}
    +	if projects.IsBinaryProjectFileContent(content) {
    +		return project.IncludeFile{}, &common.ProjectFileBadRequestError{Err: fmt.Errorf("binary files are not supported")}
    +	}
    +
    +	return project.IncludeFile{
    +		Path:         resolvedPath,
    +		RelativePath: inc.RelativePath,
    +		Content:      string(content),
    +	}, nil
    +}
    +
     func (s *ProjectService) enrichWithIncludeFiles(ctx context.Context, composeFile string, resp *project.Details) {
     	if strings.TrimSpace(composeFile) == "" {
     		return
    @@ -1922,6 +1976,12 @@ func (s *ProjectService) CreateProject(ctx context.Context, name, composeContent
     		return nil, fmt.Errorf("failed to create project: %w", err)
     	}
     
    +	if err := s.validateComposeContentForUpdate(ctx, projectsDirectory, projectPath, name, composeContent, envContent); err != nil {
    +		_ = s.db.WithContext(ctx).Delete(proj).Error
    +		_ = os.RemoveAll(projectPath)
    +		return nil, fmt.Errorf("invalid compose file: %w", err)
    +	}
    +
     	if err := projects.SaveOrUpdateProjectFiles(projectsDirectory, projectPath, composeContent, envContent); err != nil {
     		// Best-effort cleanup to restore pre-transaction behavior.
     		_ = s.db.WithContext(ctx).Delete(proj).Error
    @@ -2926,6 +2986,10 @@ func (s *ProjectService) validateComposeContentForUpdate(ctx context.Context, pr
     		return envErr
     	}
     
    +	if err := validateComposeIncludePathsForProjectInternal(projectPath, composeContent, fullEnvMap); err != nil {
    +		return err
    +	}
    +
     	validationProjectName := normalizeComposeProjectName(projectName)
     	cfg := composetypes.ConfigDetails{
     		Version:    api.ComposeVersion,
    @@ -2952,6 +3016,19 @@ func (s *ProjectService) validateComposeContentForUpdate(ctx context.Context, pr
     	return err
     }
     
    +func validateComposeIncludePathsForProjectInternal(projectPath, composeContent string, envMap projects.EnvMap) error {
    +	includes, err := projects.ParseIncludesFromContent(filepath.Join(projectPath, "compose.yaml"), []byte(composeContent), envMap, false)
    +	if err != nil {
    +		return err
    +	}
    +	for _, inc := range includes {
    +		if _, err := projects.ValidateIncludePathForWrite(projectPath, inc.Path); err != nil {
    +			return fmt.Errorf("include path %q is outside project directory: %w", inc.RelativePath, err)
    +		}
    +	}
    +	return nil
    +}
    +
     type missingIncludeStubResourceLoaderInternal struct {
     	projectPath string
     	tempDir     string
    
  • backend/internal/services/project_service_test.go+209 0 modified
    @@ -1213,6 +1213,215 @@ services:
     	assert.NoFileExists(t, filepath.Join(projectPath, "compose.yaml"))
     }
     
    +func TestProjectService_CreateProject_RejectsExternalInclude(t *testing.T) {
    +	db := setupProjectTestDB(t)
    +	ctx := context.Background()
    +
    +	projectsDir := t.TempDir()
    +	t.Setenv("PROJECTS_DIRECTORY", projectsDir)
    +
    +	settingsService, err := NewSettingsService(ctx, db)
    +	require.NoError(t, err)
    +
    +	eventService := NewEventService(db, nil, nil)
    +	svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load())
    +	require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "metadata.yaml"), []byte("services: {}\n"), 0o644))
    +
    +	compose := `include:
    +  - ../metadata.yaml
    +services:
    +  app:
    +    image: nginx:alpine
    +`
    +
    +	project, err := svc.CreateProject(ctx, "evil", compose, nil, models.User{
    +		BaseModel: models.BaseModel{ID: "u1"},
    +		Username:  "tester",
    +	})
    +	require.Error(t, err)
    +	assert.Nil(t, project)
    +	assert.Contains(t, err.Error(), "invalid compose file")
    +	assert.NoDirExists(t, filepath.Join(projectsDir, "evil"))
    +	assert.FileExists(t, filepath.Join(projectsDir, "metadata.yaml"))
    +
    +	var count int64
    +	require.NoError(t, db.Model(&models.Project{}).Where("name = ?", "evil").Count(&count).Error)
    +	assert.Zero(t, count)
    +}
    +
    +func TestProjectService_CreateProject_RejectsArrayPathInclude(t *testing.T) {
    +	db := setupProjectTestDB(t)
    +	ctx := context.Background()
    +
    +	projectsDir := t.TempDir()
    +	t.Setenv("PROJECTS_DIRECTORY", projectsDir)
    +
    +	settingsService, err := NewSettingsService(ctx, db)
    +	require.NoError(t, err)
    +
    +	eventService := NewEventService(db, nil, nil)
    +	svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load())
    +	require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "metadata.yaml"), []byte("services: {}\n"), 0o644))
    +
    +	compose := `include:
    +  - path:
    +      - ./local.yaml
    +      - ../metadata.yaml
    +services:
    +  app:
    +    image: nginx:alpine
    +`
    +
    +	project, err := svc.CreateProject(ctx, "evil-array", compose, nil, models.User{
    +		BaseModel: models.BaseModel{ID: "u1"},
    +		Username:  "tester",
    +	})
    +	require.Error(t, err)
    +	assert.Nil(t, project)
    +	assert.Contains(t, err.Error(), "invalid compose file")
    +	assert.NoDirExists(t, filepath.Join(projectsDir, "evil-array"))
    +	assert.FileExists(t, filepath.Join(projectsDir, "metadata.yaml"))
    +
    +	var count int64
    +	require.NoError(t, db.Model(&models.Project{}).Where("name = ?", "evil-array").Count(&count).Error)
    +	assert.Zero(t, count)
    +}
    +
    +func TestProjectService_GetProjectFileContent_RejectsExternalInclude(t *testing.T) {
    +	db := setupProjectTestDB(t)
    +	ctx := context.Background()
    +
    +	projectsDir := t.TempDir()
    +	t.Setenv("PROJECTS_DIRECTORY", projectsDir)
    +
    +	settingsService, err := NewSettingsService(ctx, db)
    +	require.NoError(t, err)
    +
    +	eventService := NewEventService(db, nil, nil)
    +	svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load())
    +
    +	dirName := "include-read"
    +	projectPath := filepath.Join(projectsDir, dirName)
    +	require.NoError(t, os.MkdirAll(projectPath, 0o755))
    +	require.NoError(t, os.WriteFile(filepath.Join(projectsDir, "metadata.yaml"), []byte("services: {}\n"), 0o644))
    +
    +	project := &models.Project{
    +		BaseModel: models.BaseModel{ID: "proj-external-include-read"},
    +		Name:      "include-read",
    +		DirName:   &dirName,
    +		Path:      projectPath,
    +		Status:    models.ProjectStatusStopped,
    +	}
    +	require.NoError(t, db.Create(project).Error)
    +
    +	compose := `include:
    +  - ../metadata.yaml
    +services:
    +  app:
    +    image: nginx:alpine
    +`
    +	require.NoError(t, os.WriteFile(filepath.Join(projectPath, "compose.yaml"), []byte(compose), 0o644))
    +
    +	includeFile, err := svc.GetProjectFileContent(ctx, project.ID, "../metadata.yaml")
    +	require.Error(t, err)
    +	assert.Empty(t, includeFile)
    +
    +	var forbiddenErr *common.ProjectFileForbiddenError
    +	assert.ErrorAs(t, err, &forbiddenErr)
    +}
    +
    +func TestProjectService_GetProjectFileContent_RejectsSymlinkInclude(t *testing.T) {
    +	db := setupProjectTestDB(t)
    +	ctx := context.Background()
    +
    +	projectsDir := t.TempDir()
    +	t.Setenv("PROJECTS_DIRECTORY", projectsDir)
    +
    +	settingsService, err := NewSettingsService(ctx, db)
    +	require.NoError(t, err)
    +
    +	eventService := NewEventService(db, nil, nil)
    +	svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load())
    +
    +	dirName := "include-symlink"
    +	projectPath := filepath.Join(projectsDir, dirName)
    +	require.NoError(t, os.MkdirAll(projectPath, 0o755))
    +
    +	outsidePath := filepath.Join(t.TempDir(), "outside.yaml")
    +	require.NoError(t, os.WriteFile(outsidePath, []byte("services: {}\n"), 0o644))
    +	require.NoError(t, os.Symlink(outsidePath, filepath.Join(projectPath, "evil-link")))
    +
    +	project := &models.Project{
    +		BaseModel: models.BaseModel{ID: "proj-symlink-include-read"},
    +		Name:      "include-symlink",
    +		DirName:   &dirName,
    +		Path:      projectPath,
    +		Status:    models.ProjectStatusStopped,
    +	}
    +	require.NoError(t, db.Create(project).Error)
    +
    +	compose := `include:
    +  - ./evil-link
    +services:
    +  app:
    +    image: nginx:alpine
    +`
    +	require.NoError(t, os.WriteFile(filepath.Join(projectPath, "compose.yaml"), []byte(compose), 0o644))
    +
    +	includeFile, err := svc.GetProjectFileContent(ctx, project.ID, "evil-link")
    +	require.Error(t, err)
    +	assert.Empty(t, includeFile)
    +
    +	var forbiddenErr *common.ProjectFileForbiddenError
    +	assert.ErrorAs(t, err, &forbiddenErr)
    +}
    +
    +func TestProjectService_GetProjectFileContent_RejectsIntermediateSymlinkInclude(t *testing.T) {
    +	db := setupProjectTestDB(t)
    +	ctx := context.Background()
    +
    +	projectsDir := t.TempDir()
    +	t.Setenv("PROJECTS_DIRECTORY", projectsDir)
    +
    +	settingsService, err := NewSettingsService(ctx, db)
    +	require.NoError(t, err)
    +
    +	eventService := NewEventService(db, nil, nil)
    +	svc := NewProjectService(db, settingsService, eventService, nil, nil, nil, config.Load())
    +
    +	dirName := "include-intermediate-symlink"
    +	projectPath := filepath.Join(projectsDir, dirName)
    +	require.NoError(t, os.MkdirAll(projectPath, 0o755))
    +
    +	outsideDir := t.TempDir()
    +	require.NoError(t, os.WriteFile(filepath.Join(outsideDir, "secret.yaml"), []byte("services: {}\n"), 0o644))
    +	require.NoError(t, os.Symlink(outsideDir, filepath.Join(projectPath, "subdir")))
    +
    +	project := &models.Project{
    +		BaseModel: models.BaseModel{ID: "proj-intermediate-symlink-include-read"},
    +		Name:      "include-intermediate-symlink",
    +		DirName:   &dirName,
    +		Path:      projectPath,
    +		Status:    models.ProjectStatusStopped,
    +	}
    +	require.NoError(t, db.Create(project).Error)
    +
    +	compose := `include:
    +  - ./subdir/secret.yaml
    +services:
    +  app:
    +    image: nginx:alpine
    +`
    +	require.NoError(t, os.WriteFile(filepath.Join(projectPath, "compose.yaml"), []byte(compose), 0o644))
    +
    +	includeFile, err := svc.GetProjectFileContent(ctx, project.ID, "subdir/secret.yaml")
    +	require.Error(t, err)
    +	assert.Empty(t, includeFile)
    +
    +	var forbiddenErr *common.ProjectFileForbiddenError
    +	assert.ErrorAs(t, err, &forbiddenErr)
    +}
    +
     func TestProjectService_UpdateProject_UsesExistingEnvFileDuringComposeValidation(t *testing.T) {
     	db := setupProjectTestDB(t)
     	ctx := context.Background()
    
  • backend/pkg/projects/includes.go+83 27 modified
    @@ -22,10 +22,12 @@ func expandEnvVarsInternal(s string, envMap EnvMap) string {
     }
     
     // Security Model for Include Files:
    -// - READ: Docker Compose allows include files from anywhere (parent dirs, absolute paths, etc.)
    -//         We allow reading from any path to maintain compatibility with standard Docker Compose behavior
    -// - WRITE/DELETE: Restricted to files within the project directory only for security
    -//         This prevents malicious users from modifying files outside the project scope
    +// - READ: Docker Compose's spec allows include files from anywhere (parent dirs,
    +//   absolute paths). ParseIncludes does NOT enforce containment; it returns whatever
    +//   the compose file points at. Callers must validate containment and symlinks before
    +//   reading include content or returning inc.Content to users.
    +// - WRITE/DELETE: Restricted to files within the project directory only for security.
    +//   Always go through ValidateIncludePathForWrite or WriteIncludeFile.
     
     type IncludeFile struct {
     	Path         string `json:"path"`
    @@ -41,6 +43,11 @@ func ParseIncludes(composeFilePath string, envMap EnvMap, includeContent bool) (
     		return nil, fmt.Errorf("failed to read compose file: %w", err)
     	}
     
    +	return ParseIncludesFromContent(composeFilePath, content, envMap, includeContent)
    +}
    +
    +// ParseIncludesFromContent extracts include directives from compose content using composeFilePath as the base path.
    +func ParseIncludesFromContent(composeFilePath string, content []byte, envMap EnvMap, includeContent bool) ([]IncludeFile, error) {
     	var composeData map[string]any
     	if err := yaml.Unmarshal(content, &composeData); err != nil {
     		return nil, fmt.Errorf("failed to parse compose file: %w", err)
    @@ -58,33 +65,77 @@ func ParseIncludes(composeFilePath string, envMap EnvMap, includeContent bool) (
     	switch v := includes.(type) {
     	case []any:
     		for _, item := range v {
    -			if include, err := parseIncludeItemInternal(item, composeDir, envMap, includeContent); err == nil {
    -				includeFiles = append(includeFiles, include)
    +			incs, err := parseIncludeItemInternal(item, composeDir, envMap, includeContent)
    +			if err != nil {
    +				return nil, err
     			}
    +			includeFiles = append(includeFiles, incs...)
     		}
     	case string:
    -		if include, err := parseIncludeItemInternal(v, composeDir, envMap, includeContent); err == nil {
    -			includeFiles = append(includeFiles, include)
    +		incs, err := parseIncludeItemInternal(v, composeDir, envMap, includeContent)
    +		if err != nil {
    +			return nil, err
     		}
    +		includeFiles = append(includeFiles, incs...)
    +	case nil:
    +		// `include:` key present but null (e.g. `include: ~`) — treat as empty.
    +		return []IncludeFile{}, nil
    +	default:
    +		return nil, fmt.Errorf("invalid include type")
     	}
     
     	return includeFiles, nil
     }
     
    -func parseIncludeItemInternal(item any, baseDir string, envMap EnvMap, includeContent bool) (IncludeFile, error) {
    -	var includePath string
    +func parseIncludeItemInternal(item any, baseDir string, envMap EnvMap, includeContent bool) ([]IncludeFile, error) {
    +	includePaths, err := extractIncludePathsInternal(item)
    +	if err != nil {
    +		return nil, err
    +	}
     
    +	results := make([]IncludeFile, 0, len(includePaths))
    +	for _, includePath := range includePaths {
    +		inc, err := resolveIncludeFileInternal(includePath, baseDir, envMap, includeContent)
    +		if err != nil {
    +			return nil, err
    +		}
    +		results = append(results, inc)
    +	}
    +	return results, nil
    +}
    +
    +func extractIncludePathsInternal(item any) ([]string, error) {
     	switch v := item.(type) {
     	case string:
    -		includePath = v
    +		return []string{v}, nil
     	case map[string]any:
    -		if path, ok := v["path"].(string); ok {
    -			includePath = path
    +		return extractIncludePathsFromMapInternal(v)
    +	default:
    +		return nil, fmt.Errorf("invalid include item type")
    +	}
    +}
    +
    +func extractIncludePathsFromMapInternal(v map[string]any) ([]string, error) {
    +	switch p := v["path"].(type) {
    +	case string:
    +		return []string{p}, nil
    +	case []any:
    +		// Docker Compose allows `path: [./base.yaml, ./override.yaml]` for multi-file overrides.
    +		paths := make([]string, 0, len(p))
    +		for _, entry := range p {
    +			s, ok := entry.(string)
    +			if !ok {
    +				return nil, fmt.Errorf("invalid include path entry: expected string, got %T", entry)
    +			}
    +			paths = append(paths, s)
     		}
    +		return paths, nil
     	default:
    -		return IncludeFile{}, fmt.Errorf("invalid include item type")
    +		return nil, fmt.Errorf("invalid include path type: %T", v["path"])
     	}
    +}
     
    +func resolveIncludeFileInternal(includePath, baseDir string, envMap EnvMap, includeContent bool) (IncludeFile, error) {
     	if includePath == "" {
     		return IncludeFile{}, fmt.Errorf("empty include path")
     	}
    @@ -100,19 +151,9 @@ func parseIncludeItemInternal(item any, baseDir string, envMap EnvMap, includeCo
     	}
     	fullPath = filepath.Clean(fullPath)
     
    -	var content string
    -	if includeContent {
    -		fileContent, err := os.ReadFile(fullPath)
    -		if err != nil {
    -			if errors.Is(err, os.ErrNotExist) {
    -				// File doesn't exist yet - return empty content so it can be created
    -				content = "# This file will be created when you save changes\nservices:\n"
    -			} else {
    -				return IncludeFile{}, fmt.Errorf("failed to read include file %s: %w", includePath, err)
    -			}
    -		} else {
    -			content = string(fileContent)
    -		}
    +	content, err := readIncludeContentInternal(fullPath, includePath, includeContent)
    +	if err != nil {
    +		return IncludeFile{}, err
     	}
     
     	relativePath := includePath
    @@ -133,6 +174,21 @@ func parseIncludeItemInternal(item any, baseDir string, envMap EnvMap, includeCo
     	}, nil
     }
     
    +func readIncludeContentInternal(fullPath, includePath string, includeContent bool) (string, error) {
    +	if !includeContent {
    +		return "", nil
    +	}
    +	fileContent, err := os.ReadFile(fullPath)
    +	if err == nil {
    +		return string(fileContent), nil
    +	}
    +	if errors.Is(err, os.ErrNotExist) {
    +		// File doesn't exist yet - return empty content so it can be created
    +		return "# This file will be created when you save changes\nservices:\n", nil
    +	}
    +	return "", fmt.Errorf("failed to read include file %s: %w", includePath, err)
    +}
    +
     // ValidateIncludePathForWrite ensures the include path is safe for write operations
     // Returns the validated absolute path to prevent recomputation after validation
     // Only allows writing within the project directory
    
  • backend/pkg/projects/includes_test.go+26 0 modified
    @@ -39,6 +39,32 @@ func TestParseIncludes_NormalizesRelativePaths(t *testing.T) {
     	}
     }
     
    +func TestParseIncludes_ExpandsArrayPathForm(t *testing.T) {
    +	t.Parallel()
    +
    +	projectDir := t.TempDir()
    +	composePath := filepath.Join(projectDir, "compose.yaml")
    +
    +	requireNoError := func(err error) {
    +		t.Helper()
    +		if err != nil {
    +			t.Fatalf("unexpected error: %v", err)
    +		}
    +	}
    +
    +	requireNoError(os.WriteFile(composePath, []byte("include:\n  - path:\n      - ./base.yaml\n      - ./override.yaml\n"), 0o600))
    +
    +	includes, err := ParseIncludes(composePath, nil, false)
    +	requireNoError(err)
    +
    +	if len(includes) != 2 {
    +		t.Fatalf("expected 2 includes, got %d", len(includes))
    +	}
    +	if includes[0].RelativePath != "base.yaml" || includes[1].RelativePath != "override.yaml" {
    +		t.Fatalf("unexpected relative paths: %q, %q", includes[0].RelativePath, includes[1].RelativePath)
    +	}
    +}
    +
     func TestWriteIncludeFilePermissions(t *testing.T) {
     	// Save original perms
     	origFilePerm := pkgutils.FilePerm
    

Vulnerability mechanics

Root cause

"Missing path-traversal validation in CreateProject and GetProjectFileContent allows Docker Compose include directives to read arbitrary files outside the project directory."

Attack vector

An authenticated user with the default `"user"` role sends `POST /api/environments/{id}/projects` with a compose file containing `include: ['../../../../etc/passwd']` [ref_id=2]. `CreateProject` writes this compose file to disk without validating the include path, unlike `UpdateProject` which does validate [ref_id=2]. The attacker then sends `GET /api/environments/{id}/projects/{projectId}/file?path=../../../../etc/passwd`. `GetProjectFileContent` parses the compose file, finds the include match, and returns the file content before reaching the `IsSafeSubdirectory` check [ref_id=2]. No admin role is required — both handlers only call `humamw.GetCurrentUserFromContext` [ref_id=2].

Affected code

Three code paths combine. `CreateProject` in `backend/internal/services/project_service.go` writes attacker-supplied compose content to disk without validating include paths [ref_id=2]. `GetProjectFileContent` (same file, lines 831-872) calls `projects.ParseIncludes` which reads include files from any path by design — the security model comment in `backend/pkg/projects/includes.go` explicitly states "We allow reading from any path" [ref_id=2]. `parseIncludeItemInternal` at includes.go:97-101 builds the full path with `filepath.Join(baseDir, includePath)` and calls `os.ReadFile` with no containment check [ref_id=2]. The `IsSafeSubdirectory` check at line 870 is never reached because the include-match branch returns early [ref_id=2].

What the fix does

The patch adds three defenses [patch_id=3014623]. First, `CreateProject` now calls `validateComposeContentForUpdate` before writing files, which invokes the new `validateComposeIncludePathsForProjectInternal` to reject includes outside the project directory [patch_id=3014623]. Second, `GetProjectFileContent` now passes `false` for `includeContent` to `ParseIncludes` (preventing the read there) and instead calls the new `readProjectIncludeFileContentInternal`, which validates the path with `ValidateIncludePathForWrite`, resolves symlinks via `filepath.EvalSymlinks`, and checks `IsSafeSubdirectory` on the resolved paths [patch_id=3014623]. Third, `ParseIncludes` is refactored into `ParseIncludesFromContent` and `resolveIncludeFileInternal` to support the new validation flow, and the security model comment is updated to clarify that callers must enforce containment [patch_id=3014623].

Preconditions

  • authAttacker must have a valid Arcane account with at least the default 'user' role
  • networkAttacker must be able to reach the Arcane backend API (typically exposed on a network)
  • inputTarget file must be readable by the Arcane backend process (uid/gid of the container)

Generated on May 28, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.