diff --git a/internal/compose/custom_services.go b/internal/compose/custom_services.go index 208ad242..fd5e9b7b 100644 --- a/internal/compose/custom_services.go +++ b/internal/compose/custom_services.go @@ -52,13 +52,18 @@ func coreEnvVars(cfg *config.Config, svc config.CustomService) map[string]string // buildCustomService returns the service configuration for a user-defined // custom service (CS_1..CS_10). Each custom service is built from a Dockerfile -// in ./services/{name}/ and exposes a single port with a /health endpoint. +// in ./services/{name}/ by default, or from CS_N_PATH when set. func (g *Generator) buildCustomService(cs config.CustomService) ServiceConfig { cfg := g.cfg + buildContext := cs.BuildPath + if buildContext == "" { + buildContext = fmt.Sprintf("./services/%s", cs.Name) + } + return ServiceConfig{ Build: &BuildConfig{ - Context: fmt.Sprintf("./services/%s", cs.Name), + Context: buildContext, Dockerfile: "Dockerfile", }, ContainerName: fmt.Sprintf("%s_%s", cfg.ProjectName, cs.Name), diff --git a/internal/config/custom_services.go b/internal/config/custom_services.go index a4a4daf9..400883a1 100644 --- a/internal/config/custom_services.go +++ b/internal/config/custom_services.go @@ -68,6 +68,20 @@ func parseCustomServices() ([]CustomService, error) { cs.TablePrefix = os.Getenv(fmt.Sprintf("CS_%d_TABLE_PREFIX", i)) cs.ExtraEnv = os.Getenv(fmt.Sprintf("CS_%d_ENV", i)) + // Optional build context path override. Rejects absolute paths and + // path traversal so a misconfigured env can't escape the project root. + if p := os.Getenv(fmt.Sprintf("CS_%d_PATH", i)); p != "" { + if strings.HasPrefix(p, "/") { + return nil, fmt.Errorf("CS_%d_PATH must be a relative path, got %q", i, p) + } + for _, seg := range strings.Split(p, "/") { + if seg == ".." { + return nil, fmt.Errorf("CS_%d_PATH must not contain '..', got %q", i, p) + } + } + cs.BuildPath = p + } + // Override port/route if explicitly set if p := getEnvInt(fmt.Sprintf("CS_%d_PORT", i), 0); p != 0 { cs.Port = p diff --git a/internal/config/parse_services_test.go b/internal/config/parse_services_test.go index 8814207f..843cf992 100644 --- a/internal/config/parse_services_test.go +++ b/internal/config/parse_services_test.go @@ -82,6 +82,57 @@ func TestCustomServicesSanitization_PathTraversal(t *testing.T) { } } +// TestCustomServicesBuildPath_Valid verifies that CS_N_PATH sets BuildPath +// when the value is a clean relative path. +func TestCustomServicesBuildPath_Valid(t *testing.T) { + t.Setenv("CS_1", "myservice:go") + t.Setenv("CS_1_PATH", "./backend/services/myservice") + for i := 2; i <= 10; i++ { + t.Setenv("CS_"+itoa(i), "") + } + + services, err := parseCustomServices() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(services) == 0 { + t.Fatal("expected at least one custom service") + } + if got := services[0].BuildPath; got != "./backend/services/myservice" { + t.Errorf("BuildPath = %q, want %q", got, "./backend/services/myservice") + } +} + +// TestCustomServicesBuildPath_AbsoluteRejected verifies that an absolute path +// in CS_N_PATH is rejected. +func TestCustomServicesBuildPath_AbsoluteRejected(t *testing.T) { + t.Setenv("CS_1", "myservice:go") + t.Setenv("CS_1_PATH", "/etc/secrets") + for i := 2; i <= 10; i++ { + t.Setenv("CS_"+itoa(i), "") + } + + _, err := parseCustomServices() + if err == nil { + t.Fatal("expected error for absolute CS_N_PATH, got nil") + } +} + +// TestCustomServicesBuildPath_TraversalRejected verifies that a path containing +// '..' in CS_N_PATH is rejected. +func TestCustomServicesBuildPath_TraversalRejected(t *testing.T) { + t.Setenv("CS_1", "myservice:go") + t.Setenv("CS_1_PATH", "../../outside") + for i := 2; i <= 10; i++ { + t.Setenv("CS_"+itoa(i), "") + } + + _, err := parseCustomServices() + if err == nil { + t.Fatal("expected error for traversal CS_N_PATH, got nil") + } +} + // --------------------------------------------------------------------------- // T07 — Frontend apps sanitization // --------------------------------------------------------------------------- diff --git a/internal/config/types.go b/internal/config/types.go index e8db8312..d5f14f45 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -499,6 +499,7 @@ type CustomService struct { CPU string TablePrefix string // CS_N_TABLE_PREFIX ExtraEnv string // CS_N_ENV (raw key=val pairs, comma-separated) + BuildPath string // CS_N_PATH: overrides default ./services/{name} build context } // FrontendApp represents a frontend application (FRONTEND_APP_1..FRONTEND_APP_20).