From 5f654796a37749a23f0ea09a8b75aeb47502b94f Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Thu, 25 Jun 2026 19:11:49 +0000 Subject: [PATCH 1/5] feat: add user secrets file parser and shared validator (PLAT-240) Add codersdk.ParseSecretsFile to parse .env/.json/.yaml secret files into CreateUserSecretRequests, plus codersdk.ValidateCreateUserSecretRequest as the shared per-entry validator. Both are transport-agnostic so the import endpoint and a future CLI can reuse them unchanged. --- codersdk/usersecretsimport.go | 387 +++++++++++++++++++++++++++++ codersdk/usersecretsimport_test.go | 361 +++++++++++++++++++++++++++ codersdk/usersecretvalidation.go | 25 ++ site/src/api/typesGenerated.ts | 25 ++ 4 files changed, 798 insertions(+) create mode 100644 codersdk/usersecretsimport.go create mode 100644 codersdk/usersecretsimport_test.go diff --git a/codersdk/usersecretsimport.go b/codersdk/usersecretsimport.go new file mode 100644 index 0000000000000..c0c4005cbe0d5 --- /dev/null +++ b/codersdk/usersecretsimport.go @@ -0,0 +1,387 @@ +package codersdk + +import ( + "encoding/json" + "errors" + "io" + "strings" + + "golang.org/x/xerrors" + "gopkg.in/yaml.v3" +) + +// SecretsFileFormat identifies the on-disk format of an uploaded +// secrets file. It is shared by the HTTP import endpoint and is +// intended to be reused by a future `coder secret` CLI without change. +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 an uploaded secrets file +// before parsing, guarding against resource-exhaustion inputs (huge +// files, deeply nested YAML, "billion laughs"). 1 MiB far exceeds the +// 200 KiB per-user value budget (MaxUserSecretsTotalValueBytes). +const MaxSecretsFileBytes = 1 << 20 // 1 MiB + +// ImportUserSecretsRequest is the payload for the bulk secret import +// endpoint. Content is the raw file contents and Format selects the +// parser used to interpret it. +type ImportUserSecretsRequest struct { + Format SecretsFileFormat `json:"format"` + Content string `json:"content"` +} + +// secretEntry is one parsed (key, value) pair in source order. line is +// 1-based and only meaningful for the env format (it is 0 for JSON and +// the key node's line for YAML); it is used to make duplicate-key and +// syntax errors point at the offending line. +type secretEntry struct { + key string + value string + line int +} + +// ParseSecretsFile parses an uploaded secrets file into +// CreateUserSecretRequests in source order. It does structural parsing +// and intra-file duplicate detection only; per-entry validation is left +// to ValidateCreateUserSecretRequest. Every format maps each KEY:VALUE +// to {Name: KEY, EnvName: KEY, Value: VALUE}, so one duplicate-KEY check +// covers duplicate names, env_names, and file_paths at once. +func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSecretRequest, error) { + // Reject oversized content before parsing so a malicious or + // accidental huge upload cannot drive the parser at all. + 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: + // Recognized format; fall through to parsing. + case "": + return nil, xerrors.New("a secrets file format is required") + default: + return nil, xerrors.Errorf("unknown secrets file format %q", format) + } + + // Treat an empty or whitespace-only file uniformly across formats. + 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 + } + + // An env file of only comments, or an empty JSON/YAML object, parses + // successfully but yields nothing to import. + if len(entries) == 0 { + return nil, xerrors.New("no secrets found in file") + } + + 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 +} + +// detectDuplicateKeys scans for repeated keys in source order. Because +// the flat mapping sets Name == EnvName == KEY, catching duplicates +// here gives a clear up-front error (citing the line for env files) +// instead of a later per-row uniqueness violation. +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 dotenv-style content into ordered entries. +// CRLF is normalized to LF and a leading BOM stripped; lines are +// 1-based for errors. Blank lines and full-line '#' comments are +// skipped, and an optional leading "export " prefix is removed. Each +// line splits on the first '='; later '=' stay in the value. Values +// may be double-quoted (escapes \n \t \r \\ \" interpreted), +// single-quoted (literal), or unquoted (whitespace-trimmed). An inline +// '#' is kept literally rather than starting a comment, since silently +// truncating a secret value would be a footgun. +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") +} + +// parseEnvValue interprets the right-hand side of an env assignment. +func parseEnvValue(rhs string, lineNum int) (string, error) { + v := strings.TrimLeft(rhs, " \t") + if v == "" { + return "", nil + } + switch v[0] { + case '"': + // Double-quoted: runs to the matching closing quote, with the + // permitted escape sequences interpreted. + inner, ok := quotedInner(v, '"') + if !ok { + return "", xerrors.Errorf("line %d: missing closing double quote", lineNum) + } + return unescapeDoubleQuoted(inner), nil + case '\'': + // Single-quoted: verbatim, no escape processing. + inner, ok := quotedInner(v, '\'') + if !ok { + return "", xerrors.Errorf("line %d: missing closing single quote", lineNum) + } + return inner, nil + default: + // Unquoted: trim surrounding whitespace, keep '#' literally. + return strings.TrimSpace(v), nil + } +} + +// quotedInner returns the content between the opening quote (v[0]) and +// the matching closing quote, which must be the last character after +// right-trimming whitespace. ok is false when no closing quote is found. +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 +} + +// unescapeDoubleQuoted interprets the escapes permitted inside a +// double-quoted env value: \n \t \r \\ \". Any other backslash +// sequence, or a trailing backslash, is preserved literally. +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) +} + +// parseJSONSecrets parses a flat JSON object of string values into +// ordered entries using a token decoder, so source order is preserved, +// duplicate keys remain observable, and non-string or nested values are +// rejected. +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) + } + } + + // Consume the closing brace, then ensure nothing follows the + // top-level object. + if _, err := dec.Token(); err != nil { + return nil, xerrors.Errorf("invalid JSON: %w", err) + } + if _, err := dec.Token(); !errors.Is(err, io.EOF) { + return nil, xerrors.New("unexpected trailing data after JSON object") + } + + return entries, nil +} + +// parseYAMLSecrets parses a flat YAML mapping of string values into +// ordered entries. The top level must be a mapping with scalar string +// values; non-string scalars, nested nodes, and multi-document streams +// are rejected so no value is silently coerced or dropped. Duplicate +// keys are caught by the shared duplicate check. +func parseYAMLSecrets(content string) ([]secretEntry, error) { + dec := yaml.NewDecoder(strings.NewReader(content)) + + var root yaml.Node + if err := dec.Decode(&root); err != nil { + // An empty document or comments-only file decodes to nothing. + if errors.Is(err, io.EOF) { + return nil, nil + } + return nil, xerrors.Errorf("invalid YAML: %w", err) + } + + // Reject additional documents so a multi-document stream cannot + // silently drop secrets. A bare trailing "---" or comments-only + // tail decodes to a null document and is allowed. + for { + var extra yaml.Node + err := dec.Decode(&extra) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, xerrors.Errorf("invalid YAML: %w", err) + } + if yamlDocumentHasContent(extra) { + return nil, xerrors.New("YAML content must be a single document mapping secret names to string values") + } + } + + // An empty document or comments-only file decodes to a zero node. + 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) + // Mapping node content alternates key, value, key, value, ... + for i := 0; i+1 < len(doc.Content); i += 2 { + keyNode := doc.Content[i] + valNode := doc.Content[i+1] + + 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 +} + +// yamlDocumentHasContent reports whether a decoded YAML document node +// carries data. A bare trailing "---" or comments-only tail decodes to +// a null scalar (no content); any other node is a real second document. +func yamlDocumentHasContent(doc yaml.Node) bool { + if doc.Kind == 0 || len(doc.Content) == 0 { + return false + } + child := doc.Content[0] + if child.Kind == yaml.ScalarNode && child.Tag == "!!null" { + return false + } + return true +} diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go new file mode 100644 index 0000000000000..69e838586f72e --- /dev/null +++ b/codersdk/usersecretsimport_test.go @@ -0,0 +1,361 @@ +package codersdk_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/codersdk" +) + +// TestParseSecretsFileEnv covers the dotenv parsing rules end-to-end: +// comments, blank lines, the export prefix, quoting and escapes, inline +// '#', non-ASCII, and the Name == EnvName == KEY mapping invariant. +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"`, + `SQUOTED='literal \n no escape'`, + "EQ_IN_VALUE=a=b=c", + "HASH=value # kept literal", + "UNICODE=héllo 世界 café", + "exportFOO=literal-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: "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: "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) +} + +// TestParseSecretsFileEnvCRLFAndBOM verifies CRLF normalization and BOM +// stripping. +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 + errMsg string + }{ + {name: "NoEquals", content: "NOEQUALS", errMsg: "no '='"}, + {name: "MissingKey", content: "=value", errMsg: "missing key"}, + {name: "UnterminatedDouble", content: `KEY="oops`, errMsg: "missing closing double quote"}, + {name: "UnterminatedSingle", content: `KEY='oops`, errMsg: "missing closing single quote"}, + {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.SecretsFileFormatEnv, tt.content) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +// TestParseSecretsFileEnvDuplicateCitesLine confirms the duplicate-key +// error reports the offending line for the env format. +func TestParseSecretsFileEnvDuplicateCitesLine(t *testing.T) { + t.Parallel() + + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, "DUP=a\nDUP=b") + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate key") + assert.Contains(t, err.Error(), "line 2") +} + +// TestParseSecretsFileEnvMissingEqualsCitesLine confirms the missing +// '=' error reports the offending line for the env format, not just +// line 1. +func TestParseSecretsFileEnvMissingEqualsCitesLine(t *testing.T) { + t.Parallel() + + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, "OK=value\nNOEQUALS\n") + require.Error(t, err) + assert.Contains(t, err.Error(), "no '='") + assert.Contains(t, err.Error(), "line 2") +} + +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"}, + } + 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: "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) + }) + } +} + +// TestParseSecretsFileYAMLAliasBomb guards against YAML alias-expansion +// ("billion laughs") exhaustion. yaml.v3 decodes into a Node without +// resolving aliases, and the parser only accepts scalar strings, so the +// inputs below (well under MaxSecretsFileBytes) are rejected quickly +// rather than expanded. +func TestParseSecretsFileYAMLAliasBomb(t *testing.T) { + t.Parallel() + + // Classic nested alias bomb: each anchor references the previous one + // nine times, so resolving the last alias would expand to 9^9 nodes. + var bomb strings.Builder + _, _ = bomb.WriteString("a: &a \"lol\"\n") + prev := "a" + for i := 0; i < 9; i++ { + cur := fmt.Sprintf("l%d", i) + _, _ = bomb.WriteString(cur + ": &" + cur + " [") + for j := 0; j < 9; j++ { + if j > 0 { + _ = bomb.WriteByte(',') + } + _, _ = bomb.WriteString("*" + prev) + } + _, _ = bomb.WriteString("]\n") + prev = cur + } + + cases := []struct { + name string + content string + }{ + {name: "NestedSequences", content: bomb.String()}, + // Top-level value is an alias node (not a scalar), which must be + // rejected even though the anchor it points at is a scalar. + {name: "AliasToScalar", content: "a: &a \"x\"\nb: *a\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Less(t, len(tc.content), codersdk.MaxSecretsFileBytes) + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, tc.content) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a string") + }) + } +} + +// TestParseSecretsFileYAMLMultiDocument verifies that a multi-document +// YAML stream is rejected rather than silently importing only the first +// document and dropping the rest. A bare trailing "---" separator with +// no content is harmless and must still parse. +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("TrailingSeparatorAllowed", func(t *testing.T) { + t.Parallel() + reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\n") + require.NoError(t, err) + require.Equal(t, []codersdk.CreateUserSecretRequest{ + {Name: "A", EnvName: "A", Value: "1"}, + }, reqs) + }) +} + +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("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") + }) + + 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") + }) + } +} + +// TestParseSecretsFileMappingEquivalence asserts the documented flat +// mapping (Name == EnvName == KEY, FilePath empty) holds for every +// format, which is what makes a single duplicate-KEY check cover +// duplicate names, env_names, and file_paths at once. +func TestParseSecretsFileMappingEquivalence(t *testing.T) { + t.Parallel() + + cases := []struct { + format codersdk.SecretsFileFormat + content string + }{ + {codersdk.SecretsFileFormatEnv, "FOO=bar"}, + {codersdk.SecretsFileFormatJSON, `{"FOO":"bar"}`}, + {codersdk.SecretsFileFormatYAML, "FOO: bar"}, + } + for _, tc := range cases { + reqs, err := codersdk.ParseSecretsFile(tc.format, tc.content) + require.NoErrorf(t, err, "format %s", tc.format) + require.Lenf(t, reqs, 1, "format %s", tc.format) + got := reqs[0] + assert.Equal(t, "FOO", got.Name) + assert.Equal(t, "FOO", got.EnvName) + assert.Equal(t, "bar", got.Value) + assert.Empty(t, got.FilePath) + assert.Empty(t, got.Description) + } +} diff --git a/codersdk/usersecretvalidation.go b/codersdk/usersecretvalidation.go index d43626e8e495f..02126713d468a 100644 --- a/codersdk/usersecretvalidation.go +++ b/codersdk/usersecretvalidation.go @@ -209,6 +209,31 @@ var ( } ) +// ValidateCreateUserSecretRequest validates a single create-secret +// request and returns field-level ValidationErrors keyed by JSON field +// name. It is reused by the HTTP handlers and a future CLI. The +// "value is required" rule lives here, not in UserSecretValueValid, +// because an empty value is syntactically valid but disallowed at +// create time. +func ValidateCreateUserSecretRequest(req CreateUserSecretRequest) []ValidationError { + var validations []ValidationError + if err := UserSecretNameValid(req.Name); err != nil { + validations = append(validations, ValidationError{Field: "name", Detail: err.Error()}) + } + if req.Value == "" { + validations = append(validations, ValidationError{Field: "value", Detail: "Value is required."}) + } else if err := UserSecretValueValid(req.Value); err != nil { + validations = append(validations, ValidationError{Field: "value", Detail: err.Error()}) + } + if err := UserSecretEnvNameValid(req.EnvName); err != nil { + validations = append(validations, ValidationError{Field: "env_name", Detail: err.Error()}) + } + if err := UserSecretFilePathValid(req.FilePath); err != nil { + validations = append(validations, ValidationError{Field: "file_path", 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/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6ecb349f45822..2ab52b2835826 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5157,6 +5157,17 @@ export interface IDPSyncMapping { readonly Gets: ResourceIdType; } +// From codersdk/usersecretsimport.go +/** + * ImportUserSecretsRequest is the payload for the bulk secret import + * endpoint. Content is the raw file contents and Format selects the + * parser used to interpret it. + */ +export interface ImportUserSecretsRequest { + readonly format: SecretsFileFormat; + readonly content: string; +} + // From codersdk/inboxnotification.go export interface InboxNotification { readonly id: string; @@ -5466,6 +5477,15 @@ export const MaxChatFileIDs = 50; */ export const MaxChatFileSizeBytes = 10485760; +// From codersdk/usersecretsimport.go +/** + * MaxSecretsFileBytes bounds the raw size of an uploaded secrets file + * before parsing, guarding against resource-exhaustion inputs (huge + * files, deeply nested YAML, "billion laughs"). 1 MiB far exceeds the + * 200 KiB per-user value budget (MaxUserSecretsTotalValueBytes). + */ +export const MaxSecretsFileBytes = 1048576; // 1 MiB + // From codersdk/usersecretvalidation.go /** * MaxUserSecretEnvNameLength caps the length of an env_name when one @@ -7529,6 +7549,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. From 4ae84e6f90adf1d2cc88c3573d18269098b60c48 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Fri, 26 Jun 2026 00:55:27 +0000 Subject: [PATCH 2/5] fix: tighten user secrets import parser --- coderd/usersecrets.go | 18 +-- codersdk/usersecretsimport.go | 151 ++++++++---------------- codersdk/usersecretsimport_test.go | 163 ++++++++------------------ codersdk/usersecretvalidation.go | 7 +- codersdk/usersecretvalidation_test.go | 52 ++++++++ site/src/api/typesGenerated.ts | 16 +-- 6 files changed, 149 insertions(+), 258 deletions(-) diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go index eed2570fa5904..b866390c53fef 100644 --- a/coderd/usersecrets.go +++ b/coderd/usersecrets.go @@ -62,7 +62,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 } @@ -322,22 +322,6 @@ 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 { diff --git a/codersdk/usersecretsimport.go b/codersdk/usersecretsimport.go index c0c4005cbe0d5..a7e9f97e420ee 100644 --- a/codersdk/usersecretsimport.go +++ b/codersdk/usersecretsimport.go @@ -10,9 +10,7 @@ import ( "gopkg.in/yaml.v3" ) -// SecretsFileFormat identifies the on-disk format of an uploaded -// secrets file. It is shared by the HTTP import endpoint and is -// intended to be reused by a future `coder secret` CLI without change. +// SecretsFileFormat identifies the on-disk format of a secrets file. type SecretsFileFormat string const ( @@ -24,53 +22,31 @@ const ( SecretsFileFormatYAML SecretsFileFormat = "yaml" ) -// MaxSecretsFileBytes bounds the raw size of an uploaded secrets file -// before parsing, guarding against resource-exhaustion inputs (huge -// files, deeply nested YAML, "billion laughs"). 1 MiB far exceeds the -// 200 KiB per-user value budget (MaxUserSecretsTotalValueBytes). +// MaxSecretsFileBytes bounds the raw size of a secrets file before parsing. const MaxSecretsFileBytes = 1 << 20 // 1 MiB -// ImportUserSecretsRequest is the payload for the bulk secret import -// endpoint. Content is the raw file contents and Format selects the -// parser used to interpret it. -type ImportUserSecretsRequest struct { - Format SecretsFileFormat `json:"format"` - Content string `json:"content"` -} - -// secretEntry is one parsed (key, value) pair in source order. line is -// 1-based and only meaningful for the env format (it is 0 for JSON and -// the key node's line for YAML); it is used to make duplicate-key and -// syntax errors point at the offending line. type secretEntry struct { key string value string line int } -// ParseSecretsFile parses an uploaded secrets file into -// CreateUserSecretRequests in source order. It does structural parsing -// and intra-file duplicate detection only; per-entry validation is left -// to ValidateCreateUserSecretRequest. Every format maps each KEY:VALUE -// to {Name: KEY, EnvName: KEY, Value: VALUE}, so one duplicate-KEY check -// covers duplicate names, env_names, and file_paths at once. +// 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) { - // Reject oversized content before parsing so a malicious or - // accidental huge upload cannot drive the parser at all. 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: - // Recognized format; fall through to parsing. case "": return nil, xerrors.New("a secrets file format is required") default: return nil, xerrors.Errorf("unknown secrets file format %q", format) } - // Treat an empty or whitespace-only file uniformly across formats. if strings.TrimSpace(content) == "" { return nil, xerrors.New("no secrets found in file") } @@ -91,11 +67,12 @@ func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSec return nil, err } - // An env file of only comments, or an empty JSON/YAML object, parses - // successfully but yields nothing to import. 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 @@ -112,10 +89,6 @@ func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSec return reqs, nil } -// detectDuplicateKeys scans for repeated keys in source order. Because -// the flat mapping sets Name == EnvName == KEY, catching duplicates -// here gives a clear up-front error (citing the line for env files) -// instead of a later per-row uniqueness violation. func detectDuplicateKeys(entries []secretEntry) error { seen := make(map[string]struct{}, len(entries)) for _, e := range entries { @@ -130,15 +103,6 @@ func detectDuplicateKeys(entries []secretEntry) error { return nil } -// parseEnvSecrets parses dotenv-style content into ordered entries. -// CRLF is normalized to LF and a leading BOM stripped; lines are -// 1-based for errors. Blank lines and full-line '#' comments are -// skipped, and an optional leading "export " prefix is removed. Each -// line splits on the first '='; later '=' stay in the value. Values -// may be double-quoted (escapes \n \t \r \\ \" interpreted), -// single-quoted (literal), or unquoted (whitespace-trimmed). An inline -// '#' is kept literally rather than starting a comment, since silently -// truncating a secret value would be a footgun. func parseEnvSecrets(content string) ([]secretEntry, error) { content = strings.ReplaceAll(content, "\r\n", "\n") content = strings.TrimPrefix(content, "\ufeff") @@ -187,7 +151,6 @@ func stripExportPrefix(s string) string { return strings.TrimLeft(rest, " \t") } -// parseEnvValue interprets the right-hand side of an env assignment. func parseEnvValue(rhs string, lineNum int) (string, error) { v := strings.TrimLeft(rhs, " \t") if v == "" { @@ -195,29 +158,43 @@ func parseEnvValue(rhs string, lineNum int) (string, error) { } switch v[0] { case '"': - // Double-quoted: runs to the matching closing quote, with the - // permitted escape sequences interpreted. - inner, ok := quotedInner(v, '"') - if !ok { - return "", xerrors.Errorf("line %d: missing closing double quote", lineNum) + inner, err := doubleQuotedInner(v, lineNum) + if err != nil { + return "", err } return unescapeDoubleQuoted(inner), nil case '\'': - // Single-quoted: verbatim, no escape processing. inner, ok := quotedInner(v, '\'') if !ok { return "", xerrors.Errorf("line %d: missing closing single quote", lineNum) } return inner, nil default: - // Unquoted: trim surrounding whitespace, keep '#' literally. return strings.TrimSpace(v), nil } } -// quotedInner returns the content between the opening quote (v[0]) and -// the matching closing quote, which must be the last character after -// right-trimming whitespace. ok is false when no closing quote is found. +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 { @@ -226,9 +203,6 @@ func quotedInner(v string, quote byte) (string, bool) { return trimmed[1 : len(trimmed)-1], true } -// unescapeDoubleQuoted interprets the escapes permitted inside a -// double-quoted env value: \n \t \r \\ \". Any other backslash -// sequence, or a trailing backslash, is preserved literally. func unescapeDoubleQuoted(s string) string { if !strings.Contains(s, "\\") { return s @@ -259,10 +233,6 @@ func unescapeDoubleQuoted(s string) string { return string(buf) } -// parseJSONSecrets parses a flat JSON object of string values into -// ordered entries using a token decoder, so source order is preserved, -// duplicate keys remain observable, and non-string or nested values are -// rejected. func parseJSONSecrets(content string) ([]secretEntry, error) { dec := json.NewDecoder(strings.NewReader(content)) @@ -299,53 +269,36 @@ func parseJSONSecrets(content string) ([]secretEntry, error) { } } - // Consume the closing brace, then ensure nothing follows the - // top-level object. if _, err := dec.Token(); err != nil { return nil, xerrors.Errorf("invalid JSON: %w", err) } - if _, err := dec.Token(); !errors.Is(err, io.EOF) { - return nil, xerrors.New("unexpected trailing data after JSON object") + 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 entries, nil + return nil, xerrors.New("unexpected trailing data after JSON object") } -// parseYAMLSecrets parses a flat YAML mapping of string values into -// ordered entries. The top level must be a mapping with scalar string -// values; non-string scalars, nested nodes, and multi-document streams -// are rejected so no value is silently coerced or dropped. Duplicate -// keys are caught by the shared duplicate check. func parseYAMLSecrets(content string) ([]secretEntry, error) { dec := yaml.NewDecoder(strings.NewReader(content)) var root yaml.Node if err := dec.Decode(&root); err != nil { - // An empty document or comments-only file decodes to nothing. if errors.Is(err, io.EOF) { return nil, nil } return nil, xerrors.Errorf("invalid YAML: %w", err) } - // Reject additional documents so a multi-document stream cannot - // silently drop secrets. A bare trailing "---" or comments-only - // tail decodes to a null document and is allowed. - for { - var extra yaml.Node - err := dec.Decode(&extra) - if errors.Is(err, io.EOF) { - break - } - if err != nil { - return nil, xerrors.Errorf("invalid YAML: %w", err) - } - if yamlDocumentHasContent(extra) { - return nil, xerrors.New("YAML content must be a single document mapping secret names to string values") - } + 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) } - // An empty document or comments-only file decodes to a zero node. if root.Kind == 0 || len(root.Content) == 0 { return nil, nil } @@ -356,11 +309,13 @@ func parseYAMLSecrets(content string) ([]secretEntry, error) { } entries := make([]secretEntry, 0, len(doc.Content)/2) - // Mapping node content alternates key, value, key, value, ... 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) } @@ -371,17 +326,3 @@ func parseYAMLSecrets(content string) ([]secretEntry, error) { } return entries, nil } - -// yamlDocumentHasContent reports whether a decoded YAML document node -// carries data. A bare trailing "---" or comments-only tail decodes to -// a null scalar (no content); any other node is a real second document. -func yamlDocumentHasContent(doc yaml.Node) bool { - if doc.Kind == 0 || len(doc.Content) == 0 { - return false - } - child := doc.Content[0] - if child.Kind == yaml.ScalarNode && child.Tag == "!!null" { - return false - } - return true -} diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go index 69e838586f72e..447ff25c91730 100644 --- a/codersdk/usersecretsimport_test.go +++ b/codersdk/usersecretsimport_test.go @@ -11,9 +11,6 @@ import ( "github.com/coder/coder/v2/codersdk" ) -// TestParseSecretsFileEnv covers the dotenv parsing rules end-to-end: -// comments, blank lines, the export prefix, quoting and escapes, inline -// '#', non-ASCII, and the Name == EnvName == KEY mapping invariant. func TestParseSecretsFileEnv(t *testing.T) { t.Parallel() @@ -27,6 +24,8 @@ func TestParseSecretsFileEnv(t *testing.T) { "WITH_SPACES= trimmed ", `DQUOTED="double quoted"`, `DQ_ESCAPES="a\nb\tc\\d\"e"`, + `DQ_ESCAPED_QUOTE="a\"b"`, + `DQ_EVEN_BACKSLASH_CLOSE="two backslashes\\"`, `SQUOTED='literal \n no escape'`, "EQ_IN_VALUE=a=b=c", "HASH=value # kept literal", @@ -47,6 +46,8 @@ func TestParseSecretsFileEnv(t *testing.T) { {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: "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"}, @@ -60,8 +61,6 @@ func TestParseSecretsFileEnv(t *testing.T) { require.Equal(t, want, reqs) } -// TestParseSecretsFileEnvCRLFAndBOM verifies CRLF normalization and BOM -// stripping. func TestParseSecretsFileEnvCRLFAndBOM(t *testing.T) { t.Parallel() @@ -80,47 +79,28 @@ func TestParseSecretsFileEnvErrors(t *testing.T) { tests := []struct { name string content string - errMsg string + errMsgs []string }{ - {name: "NoEquals", content: "NOEQUALS", errMsg: "no '='"}, - {name: "MissingKey", content: "=value", errMsg: "missing key"}, - {name: "UnterminatedDouble", content: `KEY="oops`, errMsg: "missing closing double quote"}, - {name: "UnterminatedSingle", content: `KEY='oops`, errMsg: "missing closing single quote"}, - {name: "DuplicateKey", content: "DUP=a\nDUP=b", errMsg: "duplicate key"}, + {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) - assert.Contains(t, err.Error(), tt.errMsg) + for _, msg := range tt.errMsgs { + assert.Contains(t, err.Error(), msg) + } }) } } -// TestParseSecretsFileEnvDuplicateCitesLine confirms the duplicate-key -// error reports the offending line for the env format. -func TestParseSecretsFileEnvDuplicateCitesLine(t *testing.T) { - t.Parallel() - - _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, "DUP=a\nDUP=b") - require.Error(t, err) - assert.Contains(t, err.Error(), "duplicate key") - assert.Contains(t, err.Error(), "line 2") -} - -// TestParseSecretsFileEnvMissingEqualsCitesLine confirms the missing -// '=' error reports the offending line for the env format, not just -// line 1. -func TestParseSecretsFileEnvMissingEqualsCitesLine(t *testing.T) { - t.Parallel() - - _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatEnv, "OK=value\nNOEQUALS\n") - require.Error(t, err) - assert.Contains(t, err.Error(), "no '='") - assert.Contains(t, err.Error(), "line 2") -} - func TestParseSecretsFileJSON(t *testing.T) { t.Parallel() @@ -151,6 +131,7 @@ func TestParseSecretsFileJSONErrors(t *testing.T) { {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) { @@ -196,6 +177,9 @@ func TestParseSecretsFileYAMLErrors(t *testing.T) { {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 { @@ -208,56 +192,14 @@ func TestParseSecretsFileYAMLErrors(t *testing.T) { } } -// TestParseSecretsFileYAMLAliasBomb guards against YAML alias-expansion -// ("billion laughs") exhaustion. yaml.v3 decodes into a Node without -// resolving aliases, and the parser only accepts scalar strings, so the -// inputs below (well under MaxSecretsFileBytes) are rejected quickly -// rather than expanded. -func TestParseSecretsFileYAMLAliasBomb(t *testing.T) { +func TestParseSecretsFileYAMLAlias(t *testing.T) { t.Parallel() - // Classic nested alias bomb: each anchor references the previous one - // nine times, so resolving the last alias would expand to 9^9 nodes. - var bomb strings.Builder - _, _ = bomb.WriteString("a: &a \"lol\"\n") - prev := "a" - for i := 0; i < 9; i++ { - cur := fmt.Sprintf("l%d", i) - _, _ = bomb.WriteString(cur + ": &" + cur + " [") - for j := 0; j < 9; j++ { - if j > 0 { - _ = bomb.WriteByte(',') - } - _, _ = bomb.WriteString("*" + prev) - } - _, _ = bomb.WriteString("]\n") - prev = cur - } - - cases := []struct { - name string - content string - }{ - {name: "NestedSequences", content: bomb.String()}, - // Top-level value is an alias node (not a scalar), which must be - // rejected even though the anchor it points at is a scalar. - {name: "AliasToScalar", content: "a: &a \"x\"\nb: *a\n"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - require.Less(t, len(tc.content), codersdk.MaxSecretsFileBytes) - _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, tc.content) - require.Error(t, err) - assert.Contains(t, err.Error(), "must be a string") - }) - } + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "a: &a \"x\"\nb: *a\n") + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a string") } -// TestParseSecretsFileYAMLMultiDocument verifies that a multi-document -// YAML stream is rejected rather than silently importing only the first -// document and dropping the rest. A bare trailing "---" separator with -// no content is harmless and must still parse. func TestParseSecretsFileYAMLMultiDocument(t *testing.T) { t.Parallel() @@ -275,13 +217,11 @@ func TestParseSecretsFileYAMLMultiDocument(t *testing.T) { assert.Contains(t, err.Error(), "single document") }) - t.Run("TrailingSeparatorAllowed", func(t *testing.T) { + t.Run("TrailingSeparatorRejected", func(t *testing.T) { t.Parallel() - reqs, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\n") - require.NoError(t, err) - require.Equal(t, []codersdk.CreateUserSecretRequest{ - {Name: "A", EnvName: "A", Value: "1"}, - }, reqs) + _, err := codersdk.ParseSecretsFile(codersdk.SecretsFileFormatYAML, "A: \"1\"\n---\n") + require.Error(t, err) + assert.Contains(t, err.Error(), "single document") }) } @@ -302,6 +242,16 @@ func TestParseSecretsFileGeneralErrors(t *testing.T) { 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) @@ -310,6 +260,17 @@ func TestParseSecretsFileGeneralErrors(t *testing.T) { 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 @@ -331,31 +292,3 @@ func TestParseSecretsFileGeneralErrors(t *testing.T) { }) } } - -// TestParseSecretsFileMappingEquivalence asserts the documented flat -// mapping (Name == EnvName == KEY, FilePath empty) holds for every -// format, which is what makes a single duplicate-KEY check cover -// duplicate names, env_names, and file_paths at once. -func TestParseSecretsFileMappingEquivalence(t *testing.T) { - t.Parallel() - - cases := []struct { - format codersdk.SecretsFileFormat - content string - }{ - {codersdk.SecretsFileFormatEnv, "FOO=bar"}, - {codersdk.SecretsFileFormatJSON, `{"FOO":"bar"}`}, - {codersdk.SecretsFileFormatYAML, "FOO: bar"}, - } - for _, tc := range cases { - reqs, err := codersdk.ParseSecretsFile(tc.format, tc.content) - require.NoErrorf(t, err, "format %s", tc.format) - require.Lenf(t, reqs, 1, "format %s", tc.format) - got := reqs[0] - assert.Equal(t, "FOO", got.Name) - assert.Equal(t, "FOO", got.EnvName) - assert.Equal(t, "bar", got.Value) - assert.Empty(t, got.FilePath) - assert.Empty(t, got.Description) - } -} diff --git a/codersdk/usersecretvalidation.go b/codersdk/usersecretvalidation.go index 02126713d468a..a973805561e7b 100644 --- a/codersdk/usersecretvalidation.go +++ b/codersdk/usersecretvalidation.go @@ -209,12 +209,7 @@ var ( } ) -// ValidateCreateUserSecretRequest validates a single create-secret -// request and returns field-level ValidationErrors keyed by JSON field -// name. It is reused by the HTTP handlers and a future CLI. The -// "value is required" rule lives here, not in UserSecretValueValid, -// because an empty value is syntactically valid but disallowed at -// create time. +// ValidateCreateUserSecretRequest validates a single create-secret request. func ValidateCreateUserSecretRequest(req CreateUserSecretRequest) []ValidationError { var validations []ValidationError if err := UserSecretNameValid(req.Name); err != nil { 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 2ab52b2835826..86a171eebc237 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5157,17 +5157,6 @@ export interface IDPSyncMapping { readonly Gets: ResourceIdType; } -// From codersdk/usersecretsimport.go -/** - * ImportUserSecretsRequest is the payload for the bulk secret import - * endpoint. Content is the raw file contents and Format selects the - * parser used to interpret it. - */ -export interface ImportUserSecretsRequest { - readonly format: SecretsFileFormat; - readonly content: string; -} - // From codersdk/inboxnotification.go export interface InboxNotification { readonly id: string; @@ -5479,10 +5468,7 @@ export const MaxChatFileSizeBytes = 10485760; // From codersdk/usersecretsimport.go /** - * MaxSecretsFileBytes bounds the raw size of an uploaded secrets file - * before parsing, guarding against resource-exhaustion inputs (huge - * files, deeply nested YAML, "billion laughs"). 1 MiB far exceeds the - * 200 KiB per-user value budget (MaxUserSecretsTotalValueBytes). + * MaxSecretsFileBytes bounds the raw size of a secrets file before parsing. */ export const MaxSecretsFileBytes = 1048576; // 1 MiB From dfd445ebed1cc69ea0760df77d2833224a53a8d0 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Fri, 26 Jun 2026 19:31:56 +0000 Subject: [PATCH 3/5] test(codersdk): cover env parser edge cases in secrets import tests Add test entries for \r escape, unknown escape preservation, and export=foo key edge case in TestParseSecretsFileEnv. --- codersdk/usersecretsimport_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go index 447ff25c91730..0218e2a619742 100644 --- a/codersdk/usersecretsimport_test.go +++ b/codersdk/usersecretsimport_test.go @@ -26,11 +26,14 @@ func TestParseSecretsFileEnv(t *testing.T) { `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", @@ -48,11 +51,14 @@ func TestParseSecretsFileEnv(t *testing.T) { {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"}, From 3bc16a251cbc8bf9951737909f1c13a3ae988d37 Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Tue, 21 Jul 2026 17:39:22 +0000 Subject: [PATCH 4/5] chore(codersdk): share user secret field constants and add ParseSecretsFile fuzz target --- coderd/usersecrets.go | 23 ++++------ codersdk/usersecretsimport_test.go | 72 ++++++++++++++++++++++++++++++ codersdk/usersecretvalidation.go | 20 ++++++--- site/src/api/typesGenerated.ts | 32 +++++++++++++ 4 files changed, 128 insertions(+), 19 deletions(-) diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go index b866390c53fef..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. @@ -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, @@ -325,13 +320,13 @@ func writeUserSecretValidationErrors(ctx context.Context, rw http.ResponseWriter 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 } @@ -388,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_test.go b/codersdk/usersecretsimport_test.go index 0218e2a619742..ae651c94a8071 100644 --- a/codersdk/usersecretsimport_test.go +++ b/codersdk/usersecretsimport_test.go @@ -231,6 +231,78 @@ func TestParseSecretsFileYAMLMultiDocument(t *testing.T) { }) } +// 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() diff --git a/codersdk/usersecretvalidation.go b/codersdk/usersecretvalidation.go index a973805561e7b..5892d1c35c758 100644 --- a/codersdk/usersecretvalidation.go +++ b/codersdk/usersecretvalidation.go @@ -209,22 +209,32 @@ 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: "name", Detail: err.Error()}) + validations = append(validations, ValidationError{Field: UserSecretNameField, Detail: err.Error()}) } if req.Value == "" { - validations = append(validations, ValidationError{Field: "value", Detail: "Value is required."}) + validations = append(validations, ValidationError{Field: UserSecretValueField, Detail: "Value is required."}) } else if err := UserSecretValueValid(req.Value); err != nil { - validations = append(validations, ValidationError{Field: "value", Detail: err.Error()}) + validations = append(validations, ValidationError{Field: UserSecretValueField, Detail: err.Error()}) } if err := UserSecretEnvNameValid(req.EnvName); err != nil { - validations = append(validations, ValidationError{Field: "env_name", Detail: err.Error()}) + validations = append(validations, ValidationError{Field: UserSecretEnvNameField, Detail: err.Error()}) } if err := UserSecretFilePathValid(req.FilePath); err != nil { - validations = append(validations, ValidationError{Field: "file_path", Detail: err.Error()}) + validations = append(validations, ValidationError{Field: UserSecretFilePathField, Detail: err.Error()}) } return validations } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5e162e82630bf..8a4c22d8dbb02 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -10200,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. From a4334010ce48a6d244bb36ad30b27fbaed5c7fbf Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Tue, 21 Jul 2026 21:15:34 +0000 Subject: [PATCH 5/5] docs(codersdk): document dotenv subset and why the parser is hand-rolled --- codersdk/usersecretsimport.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/codersdk/usersecretsimport.go b/codersdk/usersecretsimport.go index a7e9f97e420ee..84d9cf1810baa 100644 --- a/codersdk/usersecretsimport.go +++ b/codersdk/usersecretsimport.go @@ -103,6 +103,27 @@ func detectDuplicateKeys(entries []secretEntry) error { 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")