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
7 changes: 7 additions & 0 deletions docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions docs/server/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions pkg/container/templates/go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ RUN package="{{.MCPPackage}}"; \
# Final stage - minimal runtime image
FROM index.docker.io/library/alpine:3.23@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11

{{if .RuntimeConfig.RuntimeEnv}}
# Custom runtime environment variables
{{range $key, $value := .RuntimeConfig.RuntimeEnv}}ENV {{$key}}="{{$value}}"
{{end}}
{{end}}
{{if .CACertContent}}
# Add custom CA certificate for runtime
COPY ca-cert.crt /tmp/custom-ca.crt
Expand Down
5 changes: 5 additions & 0 deletions pkg/container/templates/npx.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ RUN npm install --save {{.MCPPackage}}
# Final stage - runtime image with pre-installed packages
FROM {{.RuntimeConfig.BuilderImage}}

{{if .RuntimeConfig.RuntimeEnv}}
# Custom runtime environment variables
{{range $key, $value := .RuntimeConfig.RuntimeEnv}}ENV {{$key}}="{{$value}}"
{{end}}
{{end}}
{{if .CACertContent}}
# Add custom CA certificate for runtime
COPY ca-cert.crt /tmp/custom-ca.crt
Expand Down
54 changes: 54 additions & 0 deletions pkg/container/templates/runtime_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,27 @@ const maxPackageNameLength = 128
// dots, underscores, plus signs, or hyphens.
var packageNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._+\-]*$`)

// envKeyPattern matches valid environment variable names for RuntimeEnv.
// Must start with an uppercase letter, followed by uppercase letters, numbers, or underscores.
var envKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)

// reservedRuntimeEnvKeys lists environment variable names that RuntimeEnv must
// not override, either because the generated Dockerfile sets them itself
// (e.g. PATH) or because overriding them could destabilize the runtime image.
var reservedRuntimeEnvKeys = map[string]bool{
"PATH": true, "HOME": true, "USER": true, "SHELL": true, "PWD": true,
"HOSTNAME": true, "TERM": true, "LANG": true, "LC_ALL": true,
"LD_PRELOAD": true, "LD_LIBRARY_PATH": true,
}

// runtimeEnvDangerousValuePatterns lists substrings that must not appear in a
// RuntimeEnv value. Values are interpolated verbatim into a Dockerfile ENV
// line (ENV KEY="value") with no shell-escaping, so these characters could
// break out of the quoted value and inject arbitrary Dockerfile/shell content.
var runtimeEnvDangerousValuePatterns = []string{
"`", "$(", "${", "\\", "\n", "\r", "\"", ";", "&&", "||", "|", ">", "<",
}

// RuntimeConfig defines the base images and versions for a specific runtime
type RuntimeConfig struct {
// BuilderImage is the full image reference for the builder stage.
Expand All @@ -32,6 +53,16 @@ type RuntimeConfig struct {
// Examples for Alpine: ["git", "make", "gcc"]
// Examples for Debian: ["git", "build-essential"]
AdditionalPackages []string `json:"additional_packages,omitempty" yaml:"additional_packages,omitempty"`

// RuntimeEnv contains environment variables to inject into the Dockerfile's
// final runtime stage. Unlike BuildEnv (pkg/container/templates.TemplateData.BuildEnv),
// which only affects the builder stage, these variables are baked into the
// shipped image and are present in the running container's process
// environment at startup. Use this for values a packaged MCP server reads at
// process start (e.g. feature flags, cache backend selection), not for
// build-time package manager configuration.
// Keys must be uppercase with underscores, values are validated for safety.
RuntimeEnv map[string]string `json:"runtime_env,omitempty" yaml:"runtime_env,omitempty"`
}

// Validate checks that all RuntimeConfig fields contain safe values that cannot
Expand Down Expand Up @@ -69,6 +100,29 @@ func (rc *RuntimeConfig) Validate() error {
}
}

// Validate each RuntimeEnv entry to ensure keys and values are safe to
// interpolate into a Dockerfile ENV instruction.
for key, value := range rc.RuntimeEnv {
if !envKeyPattern.MatchString(key) {
errs = append(errs, fmt.Errorf(
"invalid runtime env key %q: must match %s", key, envKeyPattern.String(),
))
continue
}
if reservedRuntimeEnvKeys[key] {
errs = append(errs, fmt.Errorf("runtime env key %q is reserved and cannot be overridden", key))
continue
}
for _, pattern := range runtimeEnvDangerousValuePatterns {
if strings.Contains(value, pattern) {
errs = append(errs, fmt.Errorf(
"runtime env value for key %q contains potentially dangerous characters: %q", key, pattern,
))
break
}
}
}

return errors.Join(errs...)
}

Expand Down
127 changes: 127 additions & 0 deletions pkg/container/templates/runtime_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,3 +330,130 @@ func TestRuntimeConfigValidate_DefaultConfigsAreValid(t *testing.T) {
})
}
}

func TestRuntimeConfigValidate_ValidRuntimeEnv(t *testing.T) {
t.Parallel()

tests := []struct {
key string
value string
}{
{key: "PYTHON_KEYRING_BACKEND", value: "keyrings.alt.file.PlaintextKeyring"},
{key: "NODE_ENV", value: "production"},
{key: "FOO_BAR_123", value: "some-value"},
}

for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
t.Parallel()

rc := &RuntimeConfig{
RuntimeEnv: map[string]string{tt.key: tt.value},
}
assert.NoError(t, rc.Validate())
})
}
}

func TestRuntimeConfigValidate_InvalidRuntimeEnvKeys(t *testing.T) {
t.Parallel()

tests := []struct {
name string
key string
}{
{name: "lowercase key", key: "path_backend"},
{name: "starts with digit", key: "1FOO"},
{name: "contains hyphen", key: "FOO-BAR"},
{name: "empty key", key: ""},
{name: "contains space", key: "FOO BAR"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

rc := &RuntimeConfig{
RuntimeEnv: map[string]string{tt.key: "some-value"},
}
err := rc.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid runtime env key")
})
}
}

func TestRuntimeConfigValidate_ReservedRuntimeEnvKeys(t *testing.T) {
t.Parallel()

reservedKeys := []string{"PATH", "HOME", "LD_PRELOAD"}

for _, key := range reservedKeys {
t.Run(key, func(t *testing.T) {
t.Parallel()

rc := &RuntimeConfig{
RuntimeEnv: map[string]string{key: "some-value"},
}
err := rc.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "is reserved and cannot be overridden")
})
}
}

func TestRuntimeConfigValidate_InvalidRuntimeEnvValues(t *testing.T) {
t.Parallel()

tests := []struct {
name string
value string
}{
{name: "backtick command substitution", value: "`id`"},
{name: "dollar-paren command substitution", value: "$(curl evil)"},
{name: "dollar-brace expansion", value: "${HOME}"},
{name: "trailing backslash", value: `value\`},
{name: "embedded newline", value: "value\nRUN evil"},
{name: "embedded carriage return", value: "value\rRUN evil"},
{name: "embedded double quote breaks out of ENV quoting", value: `value" && RUN evil`},
{name: "semicolon separator", value: "value;rm -rf /"},
{name: "command chaining with &&", value: "value && evil"},
{name: "command chaining with ||", value: "value || evil"},
{name: "pipe operator", value: "value|cat"},
{name: "redirect operator >", value: "value>file"},
{name: "redirect operator <", value: "value<file"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

rc := &RuntimeConfig{
RuntimeEnv: map[string]string{"FOO": tt.value},
}
err := rc.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "contains potentially dangerous characters")
})
}
}

func TestRuntimeConfigValidate_MultipleErrorsWithRuntimeEnv(t *testing.T) {
t.Parallel()

rc := &RuntimeConfig{
BuilderImage: "alpine\nRUN evil",
AdditionalPackages: []string{"git", "pkg;ls"},
RuntimeEnv: map[string]string{
"PATH": "/custom/path",
"FOO": "$(evil)",
},
}
err := rc.Validate()
require.Error(t, err)
// Should report the builder image, package, and RuntimeEnv errors together.
assert.Contains(t, err.Error(), "builder_image")
assert.Contains(t, err.Error(), "pkg;ls")
assert.Contains(t, err.Error(), "is reserved and cannot be overridden")
assert.Contains(t, err.Error(), "contains potentially dangerous characters")
}
7 changes: 5 additions & 2 deletions pkg/container/templates/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ type TemplateData struct {
// These are typically required subcommands (e.g., "start") that must always be present.
// Runtime arguments passed via "-- <args>" will be appended after these build args.
BuildArgs []string
// BuildEnv contains environment variables to inject into the Dockerfile builder stage.
// These are used for configuring package managers (e.g., custom registry URLs).
// BuildEnv contains environment variables to inject into the Dockerfile builder stage only.
// These are used for configuring package managers (e.g., custom registry URLs) and do NOT
// persist into the final runtime image or the running container's environment.
// For environment variables the running container's process needs at startup
// (e.g. feature flags, cache backend selection), use RuntimeConfig.RuntimeEnv instead.
// Keys must be uppercase with underscores, values are validated for safety.
BuildEnv map[string]string
// BuildAuthFiles contains auth file contents keyed by file type (npmrc, netrc, etc).
Expand Down
Loading
Loading