Omni: Operator can traverse image-factory API paths via unsanitized `talos_version` in CreateSchematic
Description
Summary
managementServer.CreateSchematic (internal/backend/grpc/schematics.go) passes the caller-controlled TalosVersion field directly to imageFactoryClient.OverlaysVersions, which embeds it verbatim into a fmt.Sprintf("/version/%s/overlays/official", talosVersion) path template. url.URL.JoinPath resolves any ../ sequences in that path, allowing an authenticated Operator to rewrite the URL path and force Omni to issue HTTP GET requests to unintended paths on the configured image-factory server. Error body content from those unintended endpoints is returned to the caller.
Severity
- Attack Vector: Network: exploited via the gRPC
CreateSchematicAPI endpoint. - Attack Complexity: Low: once the attacker holds an Operator credential and has identified a media ID with an overlay, exploitation is a single API call.
- Privileges Required: High:
role.Operatoris required, which has administrative capabilities on Omni. - User Interaction: None.
- Scope: Unchanged: the traversal is constrained to the configured image-factory host; the attacker cannot redirect Omni to an arbitrary external server.
- Confidentiality Impact: Low: error body content from unintended image-factory endpoints is reflected back to the operator, potentially leaking server-internal information.
- Integrity Impact: None: only HTTP GET requests are issued; no write operations are performed.
- Availability Impact: None.
Impact
- Same-host path traversal: An authenticated Operator can force Omni to issue GET requests to arbitrary URL paths on the configured image-factory server, bypassing the intended versioned overlay API structure.
- Error-body disclosure: HTTP error responses from unintended image-factory endpoints are reflected back to the operator, potentially leaking server-internal diagnostics or sensitive path content.
- Internal network probing: In deployments using a private image-factory instance on an internal network, the attacker can probe endpoint existence and partial responses through error-text differences.
- Depth control: By varying the number of
../prefixes intalosVersion, the attacker can reach any path hierarchy on the image-factory host.
Credit
This vulnerability was discovered and reported by bugbunny.ai.
Affected products
2Patches
29426c2cabccafix: add more input validations to management API
3 files changed · +35 −4
internal/backend/grpc/management.go+6 −2 modified@@ -677,8 +677,12 @@ func (s *managementServer) CreateJoinToken(ctx context.Context, request *managem joinToken := siderolinkres.NewJoinToken(token) - if request.Name == "" && len(request.Name) > omni.MaxJoinTokenNameLength { - return nil, status.Error(codes.InvalidArgument, "token name is invalid") + if request.Name == "" { + return nil, status.Error(codes.InvalidArgument, "token name cannot be empty") + } + + if len(request.Name) > omni.MaxJoinTokenNameLength { + return nil, status.Errorf(codes.InvalidArgument, "token name cannot be longer than %d symbols", omni.MaxJoinTokenNameLength) } joinToken.TypedSpec().Value.Name = request.Name
internal/backend/grpc/schematics.go+7 −0 modified@@ -10,6 +10,7 @@ import ( "fmt" "slices" + "github.com/blang/semver/v4" "github.com/cosi-project/runtime/pkg/safe" "github.com/siderolabs/image-factory/pkg/client" "github.com/siderolabs/image-factory/pkg/schematic" @@ -125,6 +126,12 @@ func (s *managementServer) CreateSchematic(ctx context.Context, request *managem } func (s *managementServer) getOverlay(ctx context.Context, req *management.CreateSchematicRequest) (schematic.Overlay, error) { + if req.TalosVersion != "" { + if _, err := semver.ParseTolerant(req.TalosVersion); err != nil { + return schematic.Overlay{}, status.Error(codes.InvalidArgument, "invalid Talos version") + } + } + if !quirks.New(req.TalosVersion).SupportsOverlay() { return schematic.Overlay{}, nil }
internal/backend/grpc/schematics_test.go+22 −2 modified@@ -167,6 +167,11 @@ func (suite *GrpcSuite) TestSchematicCreate() { suite.Require().NoError(suite.state.Create(ctx, media)) + overlayMedia := omni.NewInstallationMedia("overlay-test") + overlayMedia.TypedSpec().Value.Overlay = "test-overlay" + + suite.Require().NoError(suite.state.Create(ctx, overlayMedia)) + for _, tt := range []struct { request *management.CreateSchematicRequest expectedError func(*testing.T, error) @@ -269,10 +274,25 @@ func (suite *GrpcSuite) TestSchematicCreate() { require.Equal(t, codes.InvalidArgument, status.Code(err)) }, }, + { + name: "invalid Talos version", + request: &management.CreateSchematicRequest{ + TalosVersion: "../../secret", + MediaId: "overlay-test", + }, + expectedError: func(t *testing.T, err error) { + require.Equal(t, codes.InvalidArgument, status.Code(err)) + }, + }, } { req := tt.request - req.TalosVersion = "v1.6.5" - req.MediaId = "test" + if req.TalosVersion == "" { + req.TalosVersion = "v1.6.5" + } + + if req.MediaId == "" { + req.MediaId = "test" + } suite.T().Run(tt.name, func(t *testing.T) { resp, err := client.CreateSchematic(ctx, req)
3e69e8080262fix: add more input validations to management API
4 files changed · +87 −4
internal/backend/grpc/management.go+6 −2 modified@@ -676,8 +676,12 @@ func (s *managementServer) CreateJoinToken(ctx context.Context, request *managem joinToken := siderolinkres.NewJoinToken(token) - if request.Name == "" && len(request.Name) > omni.MaxJoinTokenNameLength { - return nil, status.Error(codes.InvalidArgument, "token name is invalid") + if request.Name == "" { + return nil, status.Error(codes.InvalidArgument, "token name cannot be empty") + } + + if len(request.Name) > omni.MaxJoinTokenNameLength { + return nil, status.Errorf(codes.InvalidArgument, "token name cannot be longer than %d symbols", omni.MaxJoinTokenNameLength) } joinToken.TypedSpec().Value.Name = request.Name
internal/backend/grpc/management_validation_test.go+52 −0 added@@ -0,0 +1,52 @@ +// Copyright (c) 2026 Sidero Labs, Inc. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. + +package grpc_test + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/siderolabs/omni/client/api/omni/management" + grpcomni "github.com/siderolabs/omni/internal/backend/grpc" + omniruntime "github.com/siderolabs/omni/internal/backend/runtime/omni" + "github.com/siderolabs/omni/internal/pkg/auth" + "github.com/siderolabs/omni/internal/pkg/auth/role" + "github.com/siderolabs/omni/internal/pkg/ctxstore" +) + +func TestCreateJoinTokenValidation(t *testing.T) { + server := grpcomni.NewManagementServer(nil, nil, zaptest.NewLogger(t), false, nil, nil) + ctx := ctxstore.WithValue(context.Background(), auth.EnabledAuthContextKey{Enabled: true}) + ctx = ctxstore.WithValue(ctx, auth.RoleContextKey{Role: role.Admin}) + + for _, tt := range []struct { + request *management.CreateJoinTokenRequest + name string + }{ + { + name: "empty name", + request: &management.CreateJoinTokenRequest{}, + }, + { + name: "name too long", + request: &management.CreateJoinTokenRequest{ + Name: strings.Repeat("x", omniruntime.MaxJoinTokenNameLength+1), + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + _, err := server.CreateJoinToken(ctx, tt.request) + require.Error(t, err) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + }) + } +}
internal/backend/grpc/schematics.go+7 −0 modified@@ -10,6 +10,7 @@ import ( "fmt" "slices" + "github.com/blang/semver/v4" "github.com/cosi-project/runtime/pkg/safe" "github.com/siderolabs/image-factory/pkg/client" "github.com/siderolabs/image-factory/pkg/schematic" @@ -125,6 +126,12 @@ func (s *managementServer) CreateSchematic(ctx context.Context, request *managem } func (s *managementServer) getOverlay(ctx context.Context, req *management.CreateSchematicRequest) (schematic.Overlay, error) { + if req.TalosVersion != "" { + if _, err := semver.ParseTolerant(req.TalosVersion); err != nil { + return schematic.Overlay{}, status.Error(codes.InvalidArgument, "invalid Talos version") + } + } + if !quirks.New(req.TalosVersion).SupportsOverlay() { return schematic.Overlay{}, nil }
internal/backend/grpc/schematics_test.go+22 −2 modified@@ -167,6 +167,11 @@ func (suite *GrpcSuite) TestSchematicCreate() { suite.Require().NoError(suite.state.Create(ctx, media)) + overlayMedia := omni.NewInstallationMedia("overlay-test") + overlayMedia.TypedSpec().Value.Overlay = "test-overlay" + + suite.Require().NoError(suite.state.Create(ctx, overlayMedia)) + for _, tt := range []struct { request *management.CreateSchematicRequest expectedError func(*testing.T, error) @@ -269,10 +274,25 @@ func (suite *GrpcSuite) TestSchematicCreate() { require.Equal(t, codes.InvalidArgument, status.Code(err)) }, }, + { + name: "invalid Talos version", + request: &management.CreateSchematicRequest{ + TalosVersion: "../../secret", + MediaId: "overlay-test", + }, + expectedError: func(t *testing.T, err error) { + require.Equal(t, codes.InvalidArgument, status.Code(err)) + }, + }, } { req := tt.request - req.TalosVersion = "v1.6.5" - req.MediaId = "test" + if req.TalosVersion == "" { + req.TalosVersion = "v1.6.5" + } + + if req.MediaId == "" { + req.MediaId = "test" + } suite.T().Run(tt.name, func(t *testing.T) { resp, err := client.CreateSchematic(ctx, req)
Vulnerability mechanics
Root cause
"The `TalosVersion` field is not sufficiently validated before being embedded into a URL path, allowing path traversal."
Attack vector
An authenticated Operator with administrative capabilities on Omni can exploit this vulnerability by making a single gRPC API call to `CreateSchematic`. The attacker crafts a request with a specially malformed `TalosVersion` field containing `../` sequences. This allows them to rewrite the URL path that Omni uses to request overlays from the image-factory server.
Affected code
The vulnerability resides in the `managementServer.CreateSchematic` function within `internal/backend/grpc/schematics.go`. Specifically, the `TalosVersion` field from the incoming request is passed directly to `imageFactoryClient.OverlaysVersions` without proper sanitization. The patch modifies `internal/backend/grpc/schematics.go` to include semver validation for the `TalosVersion` field.
What the fix does
The patch introduces validation for the `TalosVersion` field using semantic versioning parsing before it is used in the image factory client call [patch_id=4935412]. This prevents path traversal by rejecting invalid version strings that could contain `../` sequences. The validation ensures that the `TalosVersion` adheres to a safe format, thereby mitigating the risk of accessing unintended paths on the image-factory server.
Preconditions
- authThe attacker must possess `role.Operator` credentials.
- inputThe attacker must identify a media ID with an overlay.
Generated on Jun 5, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.