diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go index eed2570fa5904..c8cc5e32147dc 100644 --- a/coderd/usersecrets.go +++ b/coderd/usersecrets.go @@ -20,11 +20,6 @@ import ( ) const ( - userSecretNameField = "name" - userSecretValueField = "value" - userSecretEnvNameField = "env_name" - userSecretFilePathField = "file_path" - // These names are raised by the enforce_user_secrets_per_user_limits // trigger with USING CONSTRAINT. They are not table CHECK // constraints, so dbgen does not emit them in check_constraint.go. @@ -62,7 +57,7 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) { return } - if validations := createUserSecretValidationErrors(req); len(validations) > 0 { + if validations := codersdk.ValidateCreateUserSecretRequest(req); len(validations) > 0 { writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations) return } @@ -133,7 +128,7 @@ func (api *API) getUserSecrets(rw http.ResponseWriter, r *http.Request) { //noli func (api *API) getUserSecret(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route. ctx := r.Context() user := httpmw.UserParam(r) - name := chi.URLParam(r, userSecretNameField) + name := chi.URLParam(r, codersdk.UserSecretNameField) secret, err := api.Database.GetUserSecretByUserIDAndName(ctx, database.GetUserSecretByUserIDAndNameParams{ UserID: user.ID, @@ -169,7 +164,7 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() user = httpmw.UserParam(r) - name = chi.URLParam(r, userSecretNameField) + name = chi.URLParam(r, codersdk.UserSecretNameField) auditor = api.Auditor.Load() aReq, commitAudit = audit.InitRequest[database.UserSecret](rw, &audit.RequestParams{ Audit: *auditor, @@ -284,7 +279,7 @@ func (api *API) deleteUserSecret(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() user = httpmw.UserParam(r) - name = chi.URLParam(r, userSecretNameField) + name = chi.URLParam(r, codersdk.UserSecretNameField) auditor = api.Auditor.Load() aReq, commitAudit = audit.InitRequest[database.UserSecret](rw, &audit.RequestParams{ Audit: *auditor, @@ -322,32 +317,16 @@ func writeUserSecretValidationErrors(ctx context.Context, rw http.ResponseWriter }) } -func createUserSecretValidationErrors(req codersdk.CreateUserSecretRequest) []codersdk.ValidationError { - var validations []codersdk.ValidationError - validations = appendUserSecretValidationError(validations, userSecretNameField, codersdk.UserSecretNameValid(req.Name)) - if req.Value == "" { - validations = append(validations, codersdk.ValidationError{ - Field: userSecretValueField, - Detail: "Value is required.", - }) - } else { - validations = appendUserSecretValidationError(validations, userSecretValueField, codersdk.UserSecretValueValid(req.Value)) - } - validations = appendUserSecretValidationError(validations, userSecretEnvNameField, codersdk.UserSecretEnvNameValid(req.EnvName)) - validations = appendUserSecretValidationError(validations, userSecretFilePathField, codersdk.UserSecretFilePathValid(req.FilePath)) - return validations -} - func updateUserSecretValidationErrors(req codersdk.UpdateUserSecretRequest) []codersdk.ValidationError { var validations []codersdk.ValidationError if req.Value != nil { - validations = appendUserSecretValidationError(validations, userSecretValueField, codersdk.UserSecretValueValid(*req.Value)) + validations = appendUserSecretValidationError(validations, codersdk.UserSecretValueField, codersdk.UserSecretValueValid(*req.Value)) } if req.EnvName != nil { - validations = appendUserSecretValidationError(validations, userSecretEnvNameField, codersdk.UserSecretEnvNameValid(*req.EnvName)) + validations = appendUserSecretValidationError(validations, codersdk.UserSecretEnvNameField, codersdk.UserSecretEnvNameValid(*req.EnvName)) } if req.FilePath != nil { - validations = appendUserSecretValidationError(validations, userSecretFilePathField, codersdk.UserSecretFilePathValid(*req.FilePath)) + validations = appendUserSecretValidationError(validations, codersdk.UserSecretFilePathField, codersdk.UserSecretFilePathValid(*req.FilePath)) } return validations } @@ -404,17 +383,17 @@ func userSecretConflictValidationErrors(err error) []codersdk.ValidationError { switch { case database.IsUniqueViolation(err, database.UniqueUserSecretsUserNameIndex): return []codersdk.ValidationError{{ - Field: userSecretNameField, + Field: codersdk.UserSecretNameField, Detail: "name already in use", }} case database.IsUniqueViolation(err, database.UniqueUserSecretsUserEnvNameIndex): return []codersdk.ValidationError{{ - Field: userSecretEnvNameField, + Field: codersdk.UserSecretEnvNameField, Detail: "environment variable already in use", }} case database.IsUniqueViolation(err, database.UniqueUserSecretsUserFilePathIndex): return []codersdk.ValidationError{{ - Field: userSecretFilePathField, + Field: codersdk.UserSecretFilePathField, Detail: "file path already in use", }} default: diff --git a/codersdk/usersecretsimport.go b/codersdk/usersecretsimport.go new file mode 100644 index 0000000000000..84d9cf1810baa --- /dev/null +++ b/codersdk/usersecretsimport.go @@ -0,0 +1,349 @@ +package codersdk + +import ( + "encoding/json" + "errors" + "io" + "strings" + + "golang.org/x/xerrors" + "gopkg.in/yaml.v3" +) + +// SecretsFileFormat identifies the on-disk format of a secrets file. +type SecretsFileFormat string + +const ( + // SecretsFileFormatEnv is a dotenv-style file of KEY=VALUE lines. + SecretsFileFormatEnv SecretsFileFormat = "env" + // SecretsFileFormatJSON is a flat JSON object of string values. + SecretsFileFormatJSON SecretsFileFormat = "json" + // SecretsFileFormatYAML is a flat YAML mapping of string values. + SecretsFileFormatYAML SecretsFileFormat = "yaml" +) + +// MaxSecretsFileBytes bounds the raw size of a secrets file before parsing. +const MaxSecretsFileBytes = 1 << 20 // 1 MiB + +type secretEntry struct { + key string + value string + line int +} + +// ParseSecretsFile parses a secrets file into CreateUserSecretRequests. +// It checks structure and duplicate keys; per-entry validation is left to +// ValidateCreateUserSecretRequest. +func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSecretRequest, error) { + if len(content) > MaxSecretsFileBytes { + return nil, xerrors.Errorf("secrets file exceeds the maximum allowed size of %d bytes", MaxSecretsFileBytes) + } + + switch format { + case SecretsFileFormatEnv, SecretsFileFormatJSON, SecretsFileFormatYAML: + case "": + return nil, xerrors.New("a secrets file format is required") + default: + return nil, xerrors.Errorf("unknown secrets file format %q", format) + } + + if strings.TrimSpace(content) == "" { + return nil, xerrors.New("no secrets found in file") + } + + var ( + entries []secretEntry + err error + ) + switch format { + case SecretsFileFormatEnv: + entries, err = parseEnvSecrets(content) + case SecretsFileFormatJSON: + entries, err = parseJSONSecrets(content) + case SecretsFileFormatYAML: + entries, err = parseYAMLSecrets(content) + } + if err != nil { + return nil, err + } + + if len(entries) == 0 { + return nil, xerrors.New("no secrets found in file") + } + if len(entries) > MaxUserSecretsPerUserCount { + return nil, xerrors.Errorf("secrets file contains %d secrets, which exceeds the maximum of %d secrets per user", len(entries), MaxUserSecretsPerUserCount) + } + + if err := detectDuplicateKeys(entries); err != nil { + return nil, err + } + + reqs := make([]CreateUserSecretRequest, 0, len(entries)) + for _, e := range entries { + reqs = append(reqs, CreateUserSecretRequest{ + Name: e.key, + EnvName: e.key, + Value: e.value, + }) + } + return reqs, nil +} + +func detectDuplicateKeys(entries []secretEntry) error { + seen := make(map[string]struct{}, len(entries)) + for _, e := range entries { + if _, ok := seen[e.key]; ok { + if e.line > 0 { + return xerrors.Errorf("duplicate key %q on line %d", e.key, e.line) + } + return xerrors.Errorf("duplicate key %q", e.key) + } + seen[e.key] = struct{}{} + } + return nil +} + +// parseEnvSecrets parses a dotenv-style file into ordered entries. It supports a +// deliberately small subset of dotenv: +// - KEY=VALUE lines, an optional "export " prefix, and full-line "#" comments. +// - Single-quoted values are literal; double-quoted values support \n, \t, +// \r, \\, and \" escapes and keep unknown escapes literal. +// +// It intentionally does NOT: +// - expand $VAR or ${VAR}. Secrets frequently contain "$", and expansion would +// silently corrupt them. +// - strip inline comments, so PASS=abc#123 keeps the trailing #123. +// - support multiline values. Use the JSON or YAML format for PEM keys or +// certs. +// +// Duplicate keys are an error (see detectDuplicateKeys); a silent last-wins +// would drop a secret. +// +// This is hand-rolled rather than using joho/godotenv or hashicorp/go-envparse +// because those expand variables and/or strip inline comments (silent secret +// corruption) and return unordered maps, so we lose source order, line numbers, +// and duplicate detection. They would also add a dependency to the public +// codersdk package. +func parseEnvSecrets(content string) ([]secretEntry, error) { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.TrimPrefix(content, "\ufeff") + + var entries []secretEntry + for i, raw := range strings.Split(content, "\n") { + lineNum := i + 1 + + if t := strings.TrimSpace(raw); t == "" || strings.HasPrefix(t, "#") { + continue + } + + work := stripExportPrefix(strings.TrimLeft(raw, " \t")) + + eq := strings.IndexByte(work, '=') + if eq < 0 { + return nil, xerrors.Errorf("line %d: expected KEY=VALUE but found no '='", lineNum) + } + + key := strings.TrimSpace(work[:eq]) + if key == "" { + return nil, xerrors.Errorf("line %d: missing key before '='", lineNum) + } + + value, err := parseEnvValue(work[eq+1:], lineNum) + if err != nil { + return nil, err + } + entries = append(entries, secretEntry{key: key, value: value, line: lineNum}) + } + return entries, nil +} + +// stripExportPrefix removes a leading "export " (the word export +// followed by whitespace). A line like "export=foo" is left untouched +// so the key becomes "export". +func stripExportPrefix(s string) string { + const kw = "export" + if !strings.HasPrefix(s, kw) { + return s + } + rest := s[len(kw):] + if rest == "" || (rest[0] != ' ' && rest[0] != '\t') { + return s + } + return strings.TrimLeft(rest, " \t") +} + +func parseEnvValue(rhs string, lineNum int) (string, error) { + v := strings.TrimLeft(rhs, " \t") + if v == "" { + return "", nil + } + switch v[0] { + case '"': + inner, err := doubleQuotedInner(v, lineNum) + if err != nil { + return "", err + } + return unescapeDoubleQuoted(inner), nil + case '\'': + inner, ok := quotedInner(v, '\'') + if !ok { + return "", xerrors.Errorf("line %d: missing closing single quote", lineNum) + } + return inner, nil + default: + return strings.TrimSpace(v), nil + } +} + +func doubleQuotedInner(v string, lineNum int) (string, error) { + for i := 1; i < len(v); i++ { + if v[i] != '"' || hasOddBackslashRun(v, i) { + continue + } + if strings.Trim(v[i+1:], " \t") != "" { + return "", xerrors.Errorf("line %d: unexpected data after closing double quote", lineNum) + } + return v[1:i], nil + } + return "", xerrors.Errorf("line %d: missing closing double quote", lineNum) +} + +func hasOddBackslashRun(s string, before int) bool { + count := 0 + for i := before - 1; i >= 0 && s[i] == '\\'; i-- { + count++ + } + return count%2 == 1 +} + +func quotedInner(v string, quote byte) (string, bool) { + trimmed := strings.TrimRight(v, " \t") + if len(trimmed) < 2 || trimmed[len(trimmed)-1] != quote { + return "", false + } + return trimmed[1 : len(trimmed)-1], true +} + +func unescapeDoubleQuoted(s string) string { + if !strings.Contains(s, "\\") { + return s + } + buf := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c != '\\' || i == len(s)-1 { + buf = append(buf, c) + continue + } + switch next := s[i+1]; next { + case 'n': + buf = append(buf, '\n') + case 't': + buf = append(buf, '\t') + case 'r': + buf = append(buf, '\r') + case '\\': + buf = append(buf, '\\') + case '"': + buf = append(buf, '"') + default: + buf = append(buf, '\\', next) + } + i++ + } + return string(buf) +} + +func parseJSONSecrets(content string) ([]secretEntry, error) { + dec := json.NewDecoder(strings.NewReader(content)) + + tok, err := dec.Token() + if err != nil { + return nil, xerrors.Errorf("invalid JSON: %w", err) + } + if delim, ok := tok.(json.Delim); !ok || delim != '{' { + return nil, xerrors.New("JSON content must be an object mapping secret names to string values") + } + + var entries []secretEntry + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, xerrors.Errorf("invalid JSON: %w", err) + } + key, ok := keyTok.(string) + if !ok { + return nil, xerrors.New("invalid JSON object key") + } + + valTok, err := dec.Token() + if err != nil { + return nil, xerrors.Errorf("invalid JSON: %w", err) + } + switch val := valTok.(type) { + case string: + entries = append(entries, secretEntry{key: key, value: val}) + case json.Delim: + return nil, xerrors.Errorf("value for key %q must be a string, not a nested object or array", key) + default: + return nil, xerrors.Errorf("value for key %q must be a string", key) + } + } + + if _, err := dec.Token(); err != nil { + return nil, xerrors.Errorf("invalid JSON: %w", err) + } + var extra any + if err := dec.Decode(&extra); errors.Is(err, io.EOF) { + return entries, nil + } else if err != nil { + return nil, xerrors.Errorf("invalid JSON: %w", err) + } + return nil, xerrors.New("unexpected trailing data after JSON object") +} + +func parseYAMLSecrets(content string) ([]secretEntry, error) { + dec := yaml.NewDecoder(strings.NewReader(content)) + + var root yaml.Node + if err := dec.Decode(&root); err != nil { + if errors.Is(err, io.EOF) { + return nil, nil + } + return nil, xerrors.Errorf("invalid YAML: %w", err) + } + + var extra yaml.Node + if err := dec.Decode(&extra); err == nil { + return nil, xerrors.New("YAML content must be a single document mapping secret names to string values") + } else if !errors.Is(err, io.EOF) { + return nil, xerrors.Errorf("invalid YAML: %w", err) + } + + if root.Kind == 0 || len(root.Content) == 0 { + return nil, nil + } + + doc := root.Content[0] + if doc.Kind != yaml.MappingNode { + return nil, xerrors.New("YAML content must be a mapping of secret names to string values") + } + + entries := make([]secretEntry, 0, len(doc.Content)/2) + for i := 0; i+1 < len(doc.Content); i += 2 { + keyNode := doc.Content[i] + valNode := doc.Content[i+1] + + if keyNode.Kind != yaml.ScalarNode || (keyNode.Tag != "" && keyNode.Tag != "!!str") { + return nil, xerrors.New("YAML keys must be strings") + } + if valNode.Kind != yaml.ScalarNode { + return nil, xerrors.Errorf("value for key %q must be a string, not a nested mapping or sequence", keyNode.Value) + } + if valNode.Tag != "" && valNode.Tag != "!!str" { + return nil, xerrors.Errorf("value for key %q must be a string (quote the value if it is numeric or boolean)", keyNode.Value) + } + entries = append(entries, secretEntry{key: keyNode.Value, value: valNode.Value, line: keyNode.Line}) + } + return entries, nil +} diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go new file mode 100644 index 0000000000000..ae651c94a8071 --- /dev/null +++ b/codersdk/usersecretsimport_test.go @@ -0,0 +1,372 @@ +package codersdk_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" +) + +func TestParseSecretsFileEnv(t *testing.T) { + t.Parallel() + + content := strings.Join([]string{ + "# full-line comment", + " # indented full-line comment", + "", + " ", + "export EXPORTED=exported-value", + "PLAIN=plain-value", + "WITH_SPACES= trimmed ", + `DQUOTED="double quoted"`, + `DQ_ESCAPES="a\nb\tc\\d\"e"`, + `DQ_ESCAPED_QUOTE="a\"b"`, + `DQ_EVEN_BACKSLASH_CLOSE="two backslashes\\"`, + `DQ_CARRIAGE="a\rb"`, + `DQ_UNKNOWN="x\zy"`, + `SQUOTED='literal \n no escape'`, + "EQ_IN_VALUE=a=b=c", + "HASH=value # kept literal", + "UNICODE=héllo 世界 café", + "exportFOO=literal-key", + "export=literal-export-key", + "EQ_ONLY_VALUE==", + "EMPTY_VAL=", + "TABBED=\t tab trimmed \t", + "export\tTAB_EXPORT=via-tab", + }, "\n") + + reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, content) + require.NoError(t, err) + + want := []codersdk.CreateUserSecretRequest{ + {Name: "EXPORTED", EnvName: "EXPORTED", Value: "exported-value"}, + {Name: "PLAIN", EnvName: "PLAIN", Value: "plain-value"}, + {Name: "WITH_SPACES", EnvName: "WITH_SPACES", Value: "trimmed"}, + {Name: "DQUOTED", EnvName: "DQUOTED", Value: "double quoted"}, + {Name: "DQ_ESCAPES", EnvName: "DQ_ESCAPES", Value: "a\nb\tc\\d\"e"}, + {Name: "DQ_ESCAPED_QUOTE", EnvName: "DQ_ESCAPED_QUOTE", Value: `a"b`}, + {Name: "DQ_EVEN_BACKSLASH_CLOSE", EnvName: "DQ_EVEN_BACKSLASH_CLOSE", Value: `two backslashes\`}, + {Name: "DQ_CARRIAGE", EnvName: "DQ_CARRIAGE", Value: "a\rb"}, + {Name: "DQ_UNKNOWN", EnvName: "DQ_UNKNOWN", Value: `x\zy`}, + {Name: "SQUOTED", EnvName: "SQUOTED", Value: `literal \n no escape`}, + {Name: "EQ_IN_VALUE", EnvName: "EQ_IN_VALUE", Value: "a=b=c"}, + {Name: "HASH", EnvName: "HASH", Value: "value # kept literal"}, + {Name: "UNICODE", EnvName: "UNICODE", Value: "héllo 世界 café"}, + {Name: "exportFOO", EnvName: "exportFOO", Value: "literal-key"}, + {Name: "export", EnvName: "export", Value: "literal-export-key"}, + {Name: "EQ_ONLY_VALUE", EnvName: "EQ_ONLY_VALUE", Value: "="}, + {Name: "EMPTY_VAL", EnvName: "EMPTY_VAL", Value: ""}, + {Name: "TABBED", EnvName: "TABBED", Value: "tab trimmed"}, + {Name: "TAB_EXPORT", EnvName: "TAB_EXPORT", Value: "via-tab"}, + } + require.Equal(t, want, reqs) +} + +func TestParseSecretsFileEnvCRLFAndBOM(t *testing.T) { + t.Parallel() + + content := "\ufeffKEY1=val1\r\nKEY2=val2\r\n" + reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, content) + require.NoError(t, err) + require.Equal(t, []codersdk.CreateUserSecretRequest{ + {Name: "KEY1", EnvName: "KEY1", Value: "val1"}, + {Name: "KEY2", EnvName: "KEY2", Value: "val2"}, + }, reqs) +} + +func TestParseSecretsFileEnvErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + errMsgs []string + }{ + {name: "NoEquals", content: "OK=value\nNOEQUALS\n", errMsgs: []string{"no '='", "line 2"}}, + {name: "MissingKey", content: "=value", errMsgs: []string{"missing key"}}, + {name: "UnterminatedDouble", content: `KEY="oops`, errMsgs: []string{"missing closing double quote"}}, + {name: "EscapedDoubleQuoteNotClosing", content: `KEY="oops\"`, errMsgs: []string{"missing closing double quote"}}, + {name: "DoubleQuoteTrailingData", content: `KEY="ok" # comment`, errMsgs: []string{"unexpected data after closing double quote"}}, + {name: "UnterminatedSingle", content: `KEY='oops`, errMsgs: []string{"missing closing single quote"}}, + {name: "DuplicateKey", content: "DUP=a\nDUP=b", errMsgs: []string{"duplicate key", "line 2"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, tt.content) + require.Error(t, err) + for _, msg := range tt.errMsgs { + assert.Contains(t, err.Error(), msg) + } + }) + } +} + +func TestParseSecretsFileJSON(t *testing.T) { + t.Parallel() + + reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatJSON, `{"A":"1","B":"two","C":"a=b#c"}`) + require.NoError(t, err) + require.Equal(t, []codersdk.CreateUserSecretRequest{ + {Name: "A", EnvName: "A", Value: "1"}, + {Name: "B", EnvName: "B", Value: "two"}, + {Name: "C", EnvName: "C", Value: "a=b#c"}, + }, reqs) +} + +func TestParseSecretsFileJSONErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + errMsg string + }{ + {name: "Malformed", content: `{"A":`, errMsg: "invalid JSON"}, + {name: "NonObjectArray", content: `["a","b"]`, errMsg: "must be an object"}, + {name: "NonObjectScalar", content: `"just a string"`, errMsg: "must be an object"}, + {name: "NumberValue", content: `{"A":1}`, errMsg: "must be a string"}, + {name: "BoolValue", content: `{"A":true}`, errMsg: "must be a string"}, + {name: "NullValue", content: `{"A":null}`, errMsg: "must be a string"}, + {name: "NestedObject", content: `{"A":{"x":"y"}}`, errMsg: "nested object or array"}, + {name: "NestedArray", content: `{"A":["x"]}`, errMsg: "nested object or array"}, + {name: "DuplicateKey", content: `{"DUP":"a","DUP":"b"}`, errMsg: "duplicate key"}, + {name: "TrailingData", content: `{"A":"1"} {"B":"2"}`, errMsg: "trailing data"}, + {name: "InvalidTrailingJSON", content: `{"A":"1"} {`, errMsg: "invalid JSON"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatJSON, tt.content) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +func TestParseSecretsFileYAML(t *testing.T) { + t.Parallel() + + content := strings.Join([]string{ + "# a comment", + "A: one", + `B: "two"`, + "C: 'a=b#c'", + }, "\n") + reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, content) + require.NoError(t, err) + require.Equal(t, []codersdk.CreateUserSecretRequest{ + {Name: "A", EnvName: "A", Value: "one"}, + {Name: "B", EnvName: "B", Value: "two"}, + {Name: "C", EnvName: "C", Value: "a=b#c"}, + }, reqs) +} + +func TestParseSecretsFileYAMLErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + errMsg string + }{ + {name: "Malformed", content: "A: [unclosed", errMsg: "invalid YAML"}, + {name: "NonMappingScalar", content: "just a scalar", errMsg: "must be a mapping"}, + {name: "NonMappingSequence", content: "- a\n- b", errMsg: "must be a mapping"}, + {name: "NestedMapping", content: "OUTER:\n inner: x", errMsg: "nested mapping or sequence"}, + {name: "SequenceValue", content: "LIST:\n - a\n - b", errMsg: "nested mapping or sequence"}, + {name: "IntValue", content: "PORT: 8080", errMsg: "must be a string"}, + {name: "BoolValue", content: "FLAG: true", errMsg: "must be a string"}, + {name: "NullValue", content: "KEY: null", errMsg: "must be a string"}, + {name: "BoolKey", content: "true: value", errMsg: "keys must be strings"}, + {name: "IntKey", content: "1: value", errMsg: "keys must be strings"}, + {name: "SequenceKey", content: "? [a, b]\n: value", errMsg: "keys must be strings"}, + {name: "DuplicateKey", content: "DUP: a\nDUP: b", errMsg: "duplicate key"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, tt.content) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +func TestParseSecretsFileYAMLAlias(t *testing.T) { + t.Parallel() + + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "a: &a \"x\"\nb: *a\n") + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a string") +} + +func TestParseSecretsFileYAMLMultiDocument(t *testing.T) { + t.Parallel() + + t.Run("SecondMappingRejected", func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\nB: \"2\"\n") + require.Error(t, err) + assert.Contains(t, err.Error(), "single document") + }) + + t.Run("SecondScalarRejected", func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\nplain\n") + require.Error(t, err) + assert.Contains(t, err.Error(), "single document") + }) + + t.Run("TrailingSeparatorRejected", func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\n") + require.Error(t, err) + assert.Contains(t, err.Error(), "single document") + }) +} + +// FuzzParseSecretsFile checks two invariants: (1) the parser never panics +// regardless of input (the fuzz engine catches panics automatically); (2) on +// success the result is well-formed: at least one entry, at most +// MaxUserSecretsPerUserCount entries, EnvName == Name for every entry, +// and all keys unique. On error the returned slice must be nil/empty. +func FuzzParseSecretsFile(f *testing.F) { + // env - valid + f.Add("env", "KEY=value") + f.Add("env", "export EXPORTED=val\nPLAIN=plain") + f.Add("env", "\ufeffKEY1=val1\r\nKEY2=val2\r\n") + f.Add("env", "EMPTY=\nKEY=val") + f.Add("env", "EQ=a=b=c") + f.Add("env", `DQUOTED="double quoted"`) + f.Add("env", `SQUOTED='single quoted'`) + f.Add("env", "# comment\nKEY=val") + // env - malformed / tricky quoting + f.Add("env", `KEY="unterminated`) + f.Add("env", `KEY='unterminated`) + f.Add("env", `KEY="escaped\"`) + f.Add("env", `KEY="two backslashes\\"`) + f.Add("env", "NOEQUALS") + f.Add("env", "=value") + f.Add("env", `KEY="ok" # trailing`) + f.Add("env", "DUP=a\nDUP=b") + // json - valid + f.Add("json", `{"A":"1","B":"two"}`) + // json - malformed + f.Add("json", `{"A":`) + f.Add("json", `["a","b"]`) + f.Add("json", `"just a string"`) + f.Add("json", `{"A":1}`) + f.Add("json", `{"A":true}`) + f.Add("json", `{"A":null}`) + f.Add("json", `{"A":{"x":"y"}}`) + f.Add("json", `{"A":["x"]}`) + f.Add("json", `{"DUP":"a","DUP":"b"}`) + f.Add("json", `{"A":"1"} {"B":"2"}`) + // yaml - valid + f.Add("yaml", "A: one\nB: \"two\"\n") + // yaml - malformed + f.Add("yaml", "A: [unclosed") + f.Add("yaml", "- a\n- b\n") + f.Add("yaml", "OUTER:\n inner: x\n") + f.Add("yaml", "PORT: 8080\n") + f.Add("yaml", "FLAG: true\n") + f.Add("yaml", "a: &a \"x\"\nb: *a\n") + f.Add("yaml", "A: \"1\"\n---\nB: \"2\"\n") + // unknown / empty format + f.Add("", "KEY=value") + f.Add("toml", "KEY=value") + + f.Fuzz(func(t *testing.T, format string, content string) { + reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormat(format), content) + if err != nil { + require.Empty(t, reqs) + return + } + + // On success: at least one entry, within the per-user cap. + require.NotEmpty(t, reqs) + require.LessOrEqual(t, len(reqs), codersdk.MaxUserSecretsPerUserCount) + + seen := make(map[string]struct{}, len(reqs)) + for _, req := range reqs { + require.Equal(t, req.Name, req.EnvName) + _, dup := seen[req.Name] + require.False(t, dup, "duplicate key %q in result", req.Name) + seen[req.Name] = struct{}{} + } + }) +} + +func TestParseSecretsFileGeneralErrors(t *testing.T) { + t.Parallel() + + t.Run("UnknownFormat", func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile("toml", "A=1") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown secrets file format") + }) + + t.Run("EmptyFormat", func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile("", "A=1") + require.Error(t, err) + assert.Contains(t, err.Error(), "format is required") + }) + + t.Run("MaxBytesBoundary", func(t *testing.T) { + t.Parallel() + value := strings.Repeat("a", codersdk.MaxSecretsFileBytes-len("KEY=")) + reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, "KEY="+value) + require.NoError(t, err) + require.Equal(t, []codersdk.CreateUserSecretRequest{ + {Name: "KEY", EnvName: "KEY", Value: value}, + }, reqs) + }) + + t.Run("Oversized", func(t *testing.T) { + t.Parallel() + content := strings.Repeat("a", codersdk.MaxSecretsFileBytes+1) + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, content) + require.Error(t, err) + assert.Contains(t, err.Error(), "maximum allowed size") + }) + + t.Run("TooManySecrets", func(t *testing.T) { + t.Parallel() + lines := make([]string, 0, codersdk.MaxUserSecretsPerUserCount+1) + for i := 0; i < codersdk.MaxUserSecretsPerUserCount+1; i++ { + lines = append(lines, fmt.Sprintf("KEY_%d=value", i)) + } + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, strings.Join(lines, "\n")) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds the maximum") + }) + + emptyCases := []struct { + name string + format codersdk.SecretsFileFormat + content string + }{ + {name: "EnvEmpty", format: codersdk.SecretsFileFormatEnv, content: ""}, + {name: "EnvWhitespace", format: codersdk.SecretsFileFormatEnv, content: " \n\t\n"}, + {name: "EnvAllComments", format: codersdk.SecretsFileFormatEnv, content: "# one\n# two\n"}, + {name: "JSONEmptyObject", format: codersdk.SecretsFileFormatJSON, content: "{}"}, + {name: "YAMLEmpty", format: codersdk.SecretsFileFormatYAML, content: ""}, + {name: "YAMLCommentsOnly", format: codersdk.SecretsFileFormatYAML, content: "# nothing here\n"}, + } + for _, tt := range emptyCases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := codersdk.ParseSecretsFile(tt.format, tt.content) + require.Error(t, err) + assert.Contains(t, err.Error(), "no secrets found") + }) + } +} diff --git a/codersdk/usersecretvalidation.go b/codersdk/usersecretvalidation.go index d43626e8e495f..5892d1c35c758 100644 --- a/codersdk/usersecretvalidation.go +++ b/codersdk/usersecretvalidation.go @@ -209,6 +209,36 @@ var ( } ) +// UserSecret*Field constants are the canonical ValidationError.Field values +// for user secret fields. UserSecretNameField is also the chi URL parameter +// name used in coderd route segments. +const ( + UserSecretNameField = "name" + UserSecretValueField = "value" + UserSecretEnvNameField = "env_name" + UserSecretFilePathField = "file_path" +) + +// ValidateCreateUserSecretRequest validates a single create-secret request. +func ValidateCreateUserSecretRequest(req CreateUserSecretRequest) []ValidationError { + var validations []ValidationError + if err := UserSecretNameValid(req.Name); err != nil { + validations = append(validations, ValidationError{Field: UserSecretNameField, Detail: err.Error()}) + } + if req.Value == "" { + validations = append(validations, ValidationError{Field: UserSecretValueField, Detail: "Value is required."}) + } else if err := UserSecretValueValid(req.Value); err != nil { + validations = append(validations, ValidationError{Field: UserSecretValueField, Detail: err.Error()}) + } + if err := UserSecretEnvNameValid(req.EnvName); err != nil { + validations = append(validations, ValidationError{Field: UserSecretEnvNameField, Detail: err.Error()}) + } + if err := UserSecretFilePathValid(req.FilePath); err != nil { + validations = append(validations, ValidationError{Field: UserSecretFilePathField, Detail: err.Error()}) + } + return validations +} + // UserSecretNameValid validates a user secret name. Names are used in // API route path segments, so they must not include route separators. func UserSecretNameValid(s string) error { diff --git a/codersdk/usersecretvalidation_test.go b/codersdk/usersecretvalidation_test.go index fe959d7b5e0e5..55d7d001b7fc4 100644 --- a/codersdk/usersecretvalidation_test.go +++ b/codersdk/usersecretvalidation_test.go @@ -9,6 +9,58 @@ import ( "github.com/coder/coder/v2/codersdk" ) +func TestValidateCreateUserSecretRequest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + req codersdk.CreateUserSecretRequest + want []codersdk.ValidationError + }{ + { + name: "Valid", + req: codersdk.CreateUserSecretRequest{ + Name: "github-token", + Value: "ghp_xxxxxxxxxxxx", + EnvName: "GITHUB_TOKEN", + FilePath: "~/.github-token", + }, + }, + { + name: "MissingValue", + req: codersdk.CreateUserSecretRequest{ + Name: "missing-value-secret", + }, + want: []codersdk.ValidationError{{ + Field: "value", + Detail: "Value is required.", + }}, + }, + { + name: "MultiInvalid", + req: codersdk.CreateUserSecretRequest{ + EnvName: "1TOKEN", + FilePath: "relative/path", + }, + want: []codersdk.ValidationError{ + {Field: "name", Detail: "Name is required."}, + {Field: "value", Detail: "Value is required."}, + {Field: "env_name", Detail: "must start with a letter or underscore, followed by letters, digits, or underscores"}, + {Field: "file_path", Detail: "file path must start with ~/ or /"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := codersdk.ValidateCreateUserSecretRequest(tt.req) + assert.Equal(t, tt.want, got) + }) + } +} + func TestUserSecretNameValid(t *testing.T) { t.Parallel() diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 75a8d7c8d445d..8a4c22d8dbb02 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5772,6 +5772,12 @@ export const MaxChatFileIDs = 50; */ export const MaxChatFileSizeBytes = 10485760; +// From codersdk/usersecretsimport.go +/** + * MaxSecretsFileBytes bounds the raw size of a secrets file before parsing. + */ +export const MaxSecretsFileBytes = 1048576; // 1 MiB + // From codersdk/usersecretvalidation.go /** * MaxUserSecretEnvNameLength caps the length of an env_name when one @@ -7892,6 +7898,11 @@ export interface STUNReport { readonly Error: string | null; } +// From codersdk/usersecretsimport.go +export type SecretsFileFormat = "env" | "json" | "yaml"; + +export const SecretsFileFormats: SecretsFileFormat[] = ["env", "json", "yaml"]; + // From serpent/serpent.go /** * Annotations is an arbitrary key-mapping used to extend the Option and Command types. @@ -10189,6 +10200,38 @@ export interface UserSecret { readonly updated_at: string; } +// From codersdk/usersecretvalidation.go +/** + * UserSecret*Field constants are the canonical ValidationError.Field values + * for user secret fields. UserSecretNameField is also the chi URL parameter + * name used in coderd route segments. + */ +export const UserSecretEnvNameField = "env_name"; + +// From codersdk/usersecretvalidation.go +/** + * UserSecret*Field constants are the canonical ValidationError.Field values + * for user secret fields. UserSecretNameField is also the chi URL parameter + * name used in coderd route segments. + */ +export const UserSecretFilePathField = "file_path"; + +// From codersdk/usersecretvalidation.go +/** + * UserSecret*Field constants are the canonical ValidationError.Field values + * for user secret fields. UserSecretNameField is also the chi URL parameter + * name used in coderd route segments. + */ +export const UserSecretNameField = "name"; + +// From codersdk/usersecretvalidation.go +/** + * UserSecret*Field constants are the canonical ValidationError.Field values + * for user secret fields. UserSecretNameField is also the chi URL parameter + * name used in coderd route segments. + */ +export const UserSecretValueField = "value"; + // From codersdk/userskills.go /** * UserSkill represents a user skill with its raw Markdown content.