mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
Users can now disable a secret to stop it from being injected into workspaces without deleting it, and re-enable it later. Disabled secrets stay visible and editable everywhere they already appear. An enabled secret must have at least one injection target; a secret with no target can be stored only while disabled. Existing target-less secrets are migrated to disabled to preserve current behavior. Support spans the REST API, SDK, CLI, dashboard, and audit log.
369 lines
11 KiB
Go
369 lines
11 KiB
Go
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. EnvName is set only when the key passes
|
|
// env-name validation (best-effort; keys like MY-TOKEN or PATH get an empty
|
|
// EnvName so they are still imported without env injection).
|
|
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 {
|
|
req := CreateUserSecretRequest{Name: e.key, Value: e.value}
|
|
// env_name uses a partial unique index (WHERE env_name != ''),
|
|
// so multiple empty env_names are allowed.
|
|
if UserSecretEnvNameValid(e.key) == nil {
|
|
req.EnvName = e.key
|
|
} else {
|
|
// Keys that cannot be env-injected (reserved names, invalid
|
|
// identifiers) are imported without an injection target, so
|
|
// they must be disabled: an enabled secret always has at
|
|
// least one of env_name or file_path set. The user can add
|
|
// a target and re-enable the secret afterwards.
|
|
disabled := false
|
|
req.Enabled = &disabled
|
|
}
|
|
reqs = append(reqs, req)
|
|
}
|
|
return reqs, nil
|
|
}
|
|
|
|
// Duplicate keys are rejected up front (citing the line for env files)
|
|
// instead of surfacing as a per-row uniqueness violation later.
|
|
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 '\'':
|
|
return singleQuotedInner(v, lineNum)
|
|
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
|
|
}
|
|
|
|
// singleQuotedInner returns the content between the opening single
|
|
// quote at index 0 and the first closing single quote. Single quotes
|
|
// have no escape sequences, so the first quote after the opener always
|
|
// closes the value. Only whitespace may follow the closing quote.
|
|
func singleQuotedInner(v string, lineNum int) (string, error) {
|
|
for i := 1; i < len(v); i++ {
|
|
if v[i] != '\'' {
|
|
continue
|
|
}
|
|
if strings.Trim(v[i+1:], " \t") != "" {
|
|
return "", xerrors.Errorf("line %d: unexpected data after closing single quote", lineNum)
|
|
}
|
|
return v[1:i], nil
|
|
}
|
|
return "", xerrors.Errorf("line %d: missing closing single quote", lineNum)
|
|
}
|
|
|
|
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
|
|
}
|