feat: validate module variable keys and values (#26354)

Validates caller-supplied module variable keys and values in the
template builder compose endpoint before template rendering. Previously,
`mergeModuleVariables` accepted any caller-supplied key and value
without validation, allowing unknown keys, computed/sensitive variable
overrides, and malformed HCL literals (including injection payloads) to
pass through to rendered output.

Now `mergeModuleVariables` rejects unknown keys (those not in the
manifest's non-computed, non-sensitive variables) and type-checks
values: strings must be quoted HCL literals without interpolation
markers or unescaped newlines, numbers must be strict numeric literals,
and bools must be exactly `true` or `false`. The literal `null` is
accepted for any type.

Closes https://linear.app/codercom/issue/DEVEX-278

<details>
<summary>Implementation details</summary>

- Changed `mergeModuleVariables` signature from `map[string]string` to
`(map[string]string, error)` to surface validation failures
- Added `validateVariableValue`, `validateStringValue`,
`validateNumberValue`, `validateBoolValue` in `compose.go`
- String validation rejects: unquoted values, HCL interpolation (`${`,
`%{`), unescaped newlines/quotes, trailing backslashes (which would
escape the closing delimiter), and values exceeding 4096 bytes
- Errors wrap the module ID and variable name for clear diagnostics
(e.g. `module "code-server": variable "port": invalid number value`)
- Tests cover key validation, type validation, injection attempts, and
full Compose flow integration

> Generated with the help of [Coder Agents](https://coder.com) by
@jeremyruppel
</details>
This commit is contained in:
Jeremy Ruppel
2026-06-15 11:34:47 -04:00
committed by GitHub
parent 3cde346cbb
commit 4574c7d792
3 changed files with 399 additions and 11 deletions
+115 -5
View File
@@ -4,11 +4,19 @@ import (
"archive/tar"
"bytes"
"encoding/json"
"regexp"
"strings"
"time"
"golang.org/x/xerrors"
)
// maxStringValueLen is the maximum byte length for a string variable value.
const maxStringValueLen = 4096
// numberPattern matches valid HCL number literals (integers and decimals).
var numberPattern = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?$`)
// ComposeRequest describes which base template and modules to render.
type ComposeRequest struct {
BaseTemplateID string
@@ -147,7 +155,10 @@ func renderModules(
return nil, xerrors.Errorf("module template FS for %q: %w", cm.ID, err)
}
vars := mergeModuleVariables(manifest, cm.Variables)
vars, err := mergeModuleVariables(manifest, cm.Variables)
if err != nil {
return nil, xerrors.Errorf("module %q: %w", cm.ID, err)
}
modCtx := ModuleRenderContext{
RegistryBase: registryURL,
PinnedVersion: manifest.PinnedVersion,
@@ -170,9 +181,31 @@ func renderModules(
// mergeModuleVariables builds the final Variables map for a module template.
// It starts with manifest defaults for all non-computed, non-sensitive
// variables, then overlays caller-supplied values. This ensures every
// variable referenced in the template has a value.
func mergeModuleVariables(manifest ModuleManifest, callerVars map[string]string) map[string]string {
// variables, then overlays caller-supplied values. Caller-supplied keys
// are validated against the manifest and values are checked for type
// correctness before being accepted.
func mergeModuleVariables(manifest ModuleManifest, callerVars map[string]string) (map[string]string, error) {
// Build lookup structures for the manifest variables.
allowedVars := make(map[string]ModuleVariable, len(manifest.Variables))
for _, v := range manifest.Variables {
if v.Computed || v.Sensitive {
continue
}
allowedVars[v.Name] = v
}
// Validate caller-supplied keys and values before merging.
for k, val := range callerVars {
v, ok := allowedVars[k]
if !ok {
return nil, xerrors.Errorf("unknown variable %q", k)
}
if err := validateVariableValue(v, val); err != nil {
return nil, xerrors.Errorf("variable %q: %w", k, err)
}
}
// Build merged map from manifest defaults.
merged := make(map[string]string, len(manifest.Variables))
for _, v := range manifest.Variables {
if v.Computed || v.Sensitive {
@@ -191,10 +224,87 @@ func mergeModuleVariables(manifest ModuleManifest, callerVars map[string]string)
// Required variables without defaults are left out so that
// missingkey=error surfaces the omission at render time.
}
// Overlay validated caller values.
for k, val := range callerVars {
merged[k] = val
}
return merged
return merged, nil
}
// validateVariableValue checks that value is a valid HCL literal for the
// variable's declared type. The literal "null" is accepted for any type.
func validateVariableValue(v ModuleVariable, value string) error {
if value == "null" {
return nil
}
switch v.Type {
case "string":
return validateStringValue(value)
case "number":
return validateNumberValue(value)
case "bool":
return validateBoolValue(value)
default:
return xerrors.Errorf("unsupported variable type %q", v.Type)
}
}
// validateStringValue checks that value is a valid quoted HCL string literal.
// It must start and end with '"', contain no unescaped newlines or quotes,
// and must not contain HCL interpolation/directive markers.
func validateStringValue(value string) error {
if len(value) > maxStringValueLen {
return xerrors.Errorf("value exceeds maximum length of %d bytes", maxStringValueLen)
}
if len(value) < 2 || value[0] != '"' || value[len(value)-1] != '"' {
return xerrors.New("must be a quoted string (e.g. \"value\")")
}
inner := value[1 : len(value)-1]
if strings.Contains(inner, "${") || strings.Contains(inner, "%{") {
return xerrors.New("must not contain HCL interpolation or directive sequences")
}
// Walk the inner content to reject unescaped newlines and quotes.
for i := 0; i < len(inner); i++ {
ch := inner[i]
if ch == '\\' {
i++
if i >= len(inner) {
// Trailing backslash with no character to escape.
// In HCL this would escape the closing quote delimiter,
// producing an unterminated string.
return xerrors.New("must not end with a trailing backslash")
}
continue
}
if ch == '"' {
return xerrors.New("must not contain unescaped quotes")
}
if ch == '\n' || ch == '\r' {
return xerrors.New("must not contain unescaped newlines")
}
}
return nil
}
// validateNumberValue checks that value is a valid HCL number literal.
func validateNumberValue(value string) error {
if !numberPattern.MatchString(value) {
return xerrors.Errorf("invalid number value %q, must be a numeric literal (e.g. 42, 3.14)", value)
}
return nil
}
// validateBoolValue checks that value is exactly "true" or "false".
func validateBoolValue(value string) error {
if value != "true" && value != "false" {
return xerrors.Errorf("invalid bool value %q, must be true or false", value)
}
return nil
}
// isSimpleJSONValue returns true if raw is a valid JSON string, number,