Skip to content
Draft
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
10 changes: 10 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/localstack/lstk/internal/tracing"
"github.com/localstack/lstk/internal/ui"
"github.com/localstack/lstk/internal/update"
"github.com/localstack/lstk/internal/validate"
"github.com/localstack/lstk/internal/version"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
Expand Down Expand Up @@ -134,6 +135,15 @@ func Execute(ctx context.Context) error {
resolvedToken = token
}
}
// Trim surrounding whitespace: env-injected tokens (e.g. CI secrets) commonly
// carry a trailing newline. Then reject clearly malformed tokens before they
// reach the platform API, telemetry, or the container environment.
resolvedToken = strings.TrimSpace(resolvedToken)
if err := validate.AuthToken(resolvedToken); err != nil {
err = fmt.Errorf("invalid auth token: %w", err)
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
return err
}
cfg.AuthToken = resolvedToken
tel.SetAuthToken(resolvedToken)

Expand Down
20 changes: 20 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"regexp"
"strings"

"github.com/localstack/lstk/internal/validate"
"github.com/pelletier/go-toml/v2"
"github.com/spf13/viper"
)
Expand Down Expand Up @@ -185,5 +186,24 @@ func Get() (*Config, error) {
return nil, fmt.Errorf("invalid container config: %w", err)
}
}
if err := validateNamedEnvs(cfg.Env); err != nil {
return nil, err
}
return &cfg, nil
}

// validateNamedEnvs rejects malformed variables defined in the top-level [env.*]
// config sections before they are injected into a container's environment.
func validateNamedEnvs(envs map[string]map[string]string) error {
for name, vars := range envs {
for key, value := range vars {
if err := validate.EnvVarName(key); err != nil {
return fmt.Errorf("invalid variable in [env.%s]: %w", name, err)
}
if err := validate.NoControlChars("value for "+key, value); err != nil {
return fmt.Errorf("invalid variable in [env.%s]: %w", name, err)
}
}
}
return nil
}
52 changes: 52 additions & 0 deletions internal/config/env_validation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package config

import "testing"

func TestValidateNamedEnvs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
envs map[string]map[string]string
wantErr bool
}{
{"nil", nil, false},
{"empty", map[string]map[string]string{}, false},
{
name: "valid",
envs: map[string]map[string]string{
"debug": {"ls_log": "trace", "debug": "1"},
"ci": {"services": "s3,sqs"},
},
wantErr: false,
},
{
name: "control char in value",
envs: map[string]map[string]string{"bad": {"debug": "1\x00"}},
wantErr: true,
},
{
name: "hyphen in key",
envs: map[string]map[string]string{"bad": {"my-key": "1"}},
wantErr: true,
},
{
name: "equals in key",
envs: map[string]map[string]string{"bad": {"a=b": "1"}},
wantErr: true,
},
{
name: "key starts with digit",
envs: map[string]map[string]string{"bad": {"1var": "1"}},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateNamedEnvs(tt.envs)
if (err != nil) != tt.wantErr {
t.Errorf("validateNamedEnvs() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
13 changes: 6 additions & 7 deletions internal/snapshot/destination.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"

"github.com/localstack/lstk/internal/validate"
)

// ErrHomeNotSet is returned when a path needs "~" expansion but no home directory was provided.
Expand All @@ -19,8 +20,6 @@ var (
ErrRemoteNotSupported = errors.New("remote destinations are not yet supported — coming soon")
// ErrUnknownScheme is returned for unrecognized URL schemes.
ErrUnknownScheme = errors.New("unrecognized destination scheme")

validPodName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]*$`)
)

// DestinationKind distinguishes local file paths from remote pod destinations.
Expand Down Expand Up @@ -71,8 +70,8 @@ func ParseSource(ref, home string) (Destination, error) {
return Destination{}, fmt.Errorf("'%s' is not a valid reference. Aliases use a single colon. Did you mean:\npod:%s", ref, podName)
case strings.HasPrefix(lower, "pod:"):
podName := ref[len("pod:"):]
if !validPodName.MatchString(podName) {
return Destination{}, fmt.Errorf("invalid pod name %q: use letters, digits, and hyphens only, starting with a letter or digit", podName)
if err := validate.ResourceName("pod name", podName); err != nil {
return Destination{}, fmt.Errorf("invalid pod name %q: %w", podName, err)
}
return Destination{Kind: KindPod, Value: podName}, nil
case strings.HasPrefix(lower, "s3://"),
Expand Down Expand Up @@ -131,8 +130,8 @@ func ParseDestination(dest, home string, now time.Time) (Destination, error) {
return Destination{}, fmt.Errorf("'%s' is not a valid reference. Aliases use a single colon. Did you mean:\npod:%s", dest, podName)
case strings.HasPrefix(lower, "pod:"):
podName := dest[len("pod:"):]
if !validPodName.MatchString(podName) {
return Destination{}, fmt.Errorf("invalid pod name %q: use letters, digits, and hyphens only, starting with a letter or digit", podName)
if err := validate.ResourceName("pod name", podName); err != nil {
return Destination{}, fmt.Errorf("invalid pod name %q: %w", podName, err)
}
return Destination{Kind: KindPod, Value: podName}, nil
case strings.HasPrefix(lower, "s3://"),
Expand Down
25 changes: 25 additions & 0 deletions internal/snapshot/destination_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ func TestParseSource(t *testing.T) {
input: "pod:-bad",
wantErr: "invalid pod name",
},
{
name: "pod: percent encoding rejected",
input: "pod:staging%2Fpod",
wantErr: "invalid pod name",
},
{
name: "pod: shell metacharacters rejected",
input: "pod:a;rm",
wantErr: "invalid pod name",
},

// --- remote schemes ---
{
Expand Down Expand Up @@ -435,6 +445,21 @@ func TestParseDestination(t *testing.T) {
input: "pod:my_pod",
wantErr: "invalid pod name",
},
{
name: "pod: percent encoding rejected",
input: "pod:staging%2Fpod",
wantErr: "invalid pod name",
},
{
name: "pod: embedded query rejected",
input: "pod:abc?fields=name",
wantErr: "invalid pod name",
},
{
name: "pod: shell metacharacters rejected",
input: "pod:a;rm",
wantErr: "invalid pod name",
},

// --- unknown schemes ---
{
Expand Down
128 changes: 128 additions & 0 deletions internal/validate/validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Package validate provides reusable, deterministic validators for user-supplied
// CLI inputs. It exists to make the CLI a safe target for AI agents and scripts,
// which can produce malformed or hostile input — control characters, path
// traversal, percent-encoding, embedded query parameters, shell metacharacters —
// in ways humans rarely do.
//
// Validators return an *Error carrying a machine-classifiable Rule so callers can
// surface a precise, stable reason (and, in JSON output mode, a stable error
// code) instead of a generic "invalid input" message. Error() returns the bare
// reason so it composes cleanly when wrapped with caller context.
package validate

import (
"fmt"
"regexp"
"strings"
"unicode"
)

// Rule classifies why a value was rejected. The values are stable and intended to
// be surfaced as machine-readable error codes.
const (
RuleEmpty = "empty"
RuleControlChars = "control_chars"
RuleEncoding = "encoding"
RuleTraversal = "traversal"
RuleEmbedded = "embedded"
RuleMetachars = "metachars"
RuleFormat = "format"
RuleRange = "range"
)

type Error struct {
Field string
Rule string
Msg string
}

func (e *Error) Error() string { return e.Msg }

func newError(field, rule, msg string) *Error {
return &Error{Field: field, Rule: rule, Msg: msg}
}

// containsControlChars reports whether s contains any control character other
// than tab, newline, or carriage return.
func containsControlChars(s string) bool {
for _, r := range s {
if r == '\t' || r == '\n' || r == '\r' {
continue
}
if unicode.IsControl(r) {
return true
}
}
return false
}

func NoControlChars(field, value string) error {
if containsControlChars(value) {
return newError(field, RuleControlChars, "contains control characters")
}
return nil
}

var envVarKeyRegexp = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)

// EnvVarName validates an environment variable name (the key of a KEY=VALUE pair).
func EnvVarName(name string) error {
if !envVarKeyRegexp.MatchString(name) {
return newError("env", RuleFormat, fmt.Sprintf("env key %q contains invalid characters", name))
}
return nil
}

var resourceNameRegexp = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]*$`)

// shellMetaChars are characters that enable command injection if an identifier is
// ever interpolated into a shell. The slash, question mark, and hash are handled
// separately as embedded path/query characters and are not repeated here.
const shellMetaChars = ";&|$\x60<>(){}[]!*'~\\\""

// ResourceName validates an opaque resource identifier such as a Cloud Pod name.
// It runs ordered deny-checks so the most specific reason wins, then a strict
// allow-list. The deny-checks exist to give precise, machine-classifiable
// feedback; the allow-list alone would reject every invalid value.
func ResourceName(field, value string) error {
switch {
case value == "":
return newError(field, RuleEmpty, "must not be empty")
case containsControlChars(value):
return newError(field, RuleControlChars, "contains control characters")
case strings.Contains(value, "%"):
return newError(field, RuleEncoding, "contains percent-encoding (pass the decoded value)")
case strings.Contains(value, ".."):
return newError(field, RuleTraversal, "contains a path traversal sequence (..)")
case strings.ContainsAny(value, "/?#"):
return newError(field, RuleEmbedded, "contains path or query characters (/, ?, #)")
case strings.ContainsAny(value, shellMetaChars):
return newError(field, RuleMetachars, "contains shell metacharacters")
case !resourceNameRegexp.MatchString(value):
return newError(field, RuleFormat, "use letters, digits, and hyphens only, starting with a letter or digit")
}
return nil
}

// AuthToken validates a LocalStack auth token. The character set is intentionally
// not restricted — tokens are opaque — so only clearly malformed values are
// rejected: control characters, embedded whitespace, or an implausible length. An
// empty token is allowed (it means none is set). Callers should TrimSpace first,
// since environment injection (e.g. CI secrets) commonly appends a trailing newline.
func AuthToken(value string) error {
if value == "" {
return nil
}
for _, r := range value {
if unicode.IsControl(r) {
return newError("auth token", RuleControlChars, "contains control characters")
}
if unicode.IsSpace(r) {
return newError("auth token", RuleFormat, "contains whitespace")
}
}
if len(value) > 1024 {
return newError("auth token", RuleRange, "is implausibly long (over 1024 characters)")
}
return nil
}
Loading
Loading