-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Expand file tree
/
Copy pathusersecretvalidation.go
More file actions
356 lines (312 loc) · 13.1 KB
/
Copy pathusersecretvalidation.go
File metadata and controls
356 lines (312 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
package codersdk
import (
"regexp"
"strings"
"golang.org/x/xerrors"
)
const (
// maxFilePathLength is the maximum length of a file path for
// a user secret. Matches Linux PATH_MAX, which is the common
// case since workspace agents almost always run on Linux.
// This does not catch all Windows path length edge cases
// (legacy MAX_PATH is 260), but the agent will surface a
// runtime error if the write fails.
maxFilePathLength = 4096
)
// MaxUserSecretsPerUserCount caps the number of secrets a single user
// may own.
//
// Why a cap exists at all: user_secrets is user-scoped, so every
// workspace the user owns loads the same set into its agent
// manifest, and env-injected ones land in the workspace agent's
// process env. Without a cap, a user can overflow one of three
// external limits by accumulating enough secrets, or by making
// them large enough. The failure surfaces at workspace start (or
// as a truncated env), not at create-time.
//
// What drives each cap, and the rough math:
//
// - Count (50): backstops row-count growth from many small
// secrets. The total-bytes cap binds first for large secrets;
// this cap binds first for typical-sized ones (~few KB).
//
// - Total bytes (200 KiB): sized to cover realistic credential
// storage (API keys, SSH keys, kubeconfigs, cert bundles)
// with headroom. Well under the 4 MiB DRPC agent manifest
// budget (codersdk/drpcsdk.MaxMessageSize).
//
// - Env bytes (24 KiB): an approximate budget for the value
// bytes of env-injected secrets. Leaves ~8 KiB of headroom
// under the ~32 KiB Windows process env block
// (CreateProcessW's lpEnvironment is capped at 32,767
// characters) for what this aggregate does not count:
// env_name bytes, per-entry overhead, agent-injected vars
// (CODER_*, PATH, HOME, ...), and template-defined env. Not
// a strict overflow guarantee. Linux/macOS ARG_MAX (~2 MiB)
// is far above this, so one Windows-safe cap works
// everywhere.
//
// Byte caps measure stored bytes (octet_length of encrypted+base64).
// Plaintext is slightly tighter in encrypted deployments. That is
// fine: the limits we defend all measure transmitted bytes, and
// stored bytes upper-bound those.
//
// The Postgres trigger enforce_user_secrets_per_user_limits is the
// source of truth; the HTTP handler maps its check_violation to a
// 400. TestUserSecretLimits in coderd/usersecrets_test.go exercises
// off-by-one at each cap across POST and PATCH, so any drift
// between these constants and the trigger's literals fails an
// assertion.
const MaxUserSecretsPerUserCount = 50
// MaxUserSecretsTotalValueBytes caps the sum of stored value bytes
// per user. See MaxUserSecretsPerUserCount for the full rationale and
// math behind all three caps.
const MaxUserSecretsTotalValueBytes = 200 * 1024 // 200 KiB
// MaxUserSecretValueBytes is the maximum number of bytes for a
// single secret value. It is enforced in two places:
//
// - The HTTP handler validates the raw (plaintext) value with
// UserSecretValueValid before the row is written.
// - The Postgres trigger enforce_user_secrets_per_user_limits
// enforces the same number as an aggregate on stored bytes
// across a user's env-injected secrets. This defends the
// ~32 KiB Windows process env block.
//
// On deployments with secret encryption enabled, stored bytes
// exceed plaintext by ~1.33x (AES-GCM + base64), so the trigger's
// env-aggregate budget can be reached at less plaintext than the
// handler's per-value check would suggest. The trigger is
// authoritative; the handler's check is a fast pre-flight that
// catches the common "one value is too big" case before the row
// is encrypted and sent to the DB.
//
// One number serves both roles because the per-value cap can't
// usefully exceed the smallest aggregate cap any single row could
// trip: a value bigger than the env aggregate would be rejected
// the moment its env_name was set, so allowing it at the per-value
// layer would just move the failure later.
//
// See MaxUserSecretsPerUserCount for the rationale behind the other
// two caps (count, total bytes).
const MaxUserSecretValueBytes = 24 * 1024 // 24 KiB
// MaxUserSecretEnvNameLength caps the length of an env_name when one
// is provided. 256 is a generous round number that should allow any
// realistic env name while still bounding inputs.
//
// This is a per-row syntactic check, not an aggregate. It does not
// interact with the env_bytes aggregate (which is itself an
// approximate budget; see MaxUserSecretsPerUserCount).
const MaxUserSecretEnvNameLength = 256
var (
// posixEnvNameRegex matches valid POSIX environment variable names:
// must start with a letter or underscore, followed by letters,
// digits, or underscores.
posixEnvNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
// reservedEnvNames are system environment variables that must not
// be overridden by user secrets. This list is intentionally
// aggressive because it is easier to remove entries later than
// to add them after users have already created conflicting
// secrets.
reservedEnvNames = map[string]struct{}{
// Core POSIX/login variables. Overriding these breaks
// basic shell and session behavior.
"PATH": {},
"HOME": {},
"SHELL": {},
"USER": {},
"LOGNAME": {},
"PWD": {},
"OLDPWD": {},
// Locale and terminal. Agents and IDEs depend on these
// being set correctly by the system.
"LANG": {},
"TERM": {},
// Shell behavior. Overriding these can silently break
// word splitting, directory resolution, and script
// execution in every shell session and agent script.
"IFS": {},
"CDPATH": {},
// Shell startup files. ENV is sourced by POSIX sh for
// interactive shells; BASH_ENV is sourced by bash for
// every non-interactive invocation (scripts, subshells).
// Allowing users to set these would inject arbitrary
// code into every shell and script in the workspace.
"ENV": {},
"BASH_ENV": {},
// Temp directories. Overriding these is a security risk
// (symlink attacks, world-readable paths).
"TMPDIR": {},
"TMP": {},
"TEMP": {},
// Host identity.
"HOSTNAME": {},
// SSH session variables. The Coder agent sets
// SSH_AUTH_SOCK in agentssh.go; the others are set by
// sshd and should never be faked.
"SSH_AUTH_SOCK": {},
"SSH_CLIENT": {},
"SSH_CONNECTION": {},
"SSH_TTY": {},
// Editor/pager. The Coder agent sets these so that git
// operations inside workspaces work non-interactively.
"EDITOR": {},
"VISUAL": {},
"PAGER": {},
// IDE integration. The agent sets these for code-server
// and VS Code Remote proxying.
"VSCODE_PROXY_URI": {},
"CS_DISABLE_GETTING_STARTED_OVERRIDE": {},
// XDG base directories. Overriding these redirects
// config, cache, and runtime data for every tool in the
// workspace.
"XDG_RUNTIME_DIR": {},
"XDG_CONFIG_HOME": {},
"XDG_DATA_HOME": {},
"XDG_CACHE_HOME": {},
"XDG_STATE_HOME": {},
}
// reservedEnvPrefixes are namespace prefixes where every
// variable in the family is reserved. Checked after the
// exact-name map. The CODER / CODER_* namespace is handled
// separately with its own error message (see below).
reservedEnvPrefixes = []string{
// The Coder agent sets GIT_SSH_COMMAND, GIT_ASKPASS,
// GIT_AUTHOR_*, GIT_COMMITTER_*, and several others.
// Blocking the entire GIT_* namespace avoids an arms
// race with new git env vars.
"GIT_",
// Locale variables. LC_ALL, LC_CTYPE, LC_MESSAGES,
// etc. control character encoding, sorting, and
// formatting. Overriding them can break text
// processing in agents and IDEs.
"LC_",
// Dynamic linker variables. Allowing users to set
// these would let a secret inject arbitrary shared
// libraries into every process in the workspace.
"LD_",
"DYLD_",
}
)
// 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()})
}
// An enabled secret must have an injection target. The agent
// manifest layer relies on this invariant so it can gate injection
// solely on the enabled flag; "stored but not injected" is
// expressed by enabled=false, not by clearing both targets.
// Disabled secrets may have no target (e.g. bulk imports of keys
// that cannot be env-injected).
if req.EnvName == "" && req.FilePath == "" && (req.Enabled == nil || *req.Enabled) {
validations = append(validations, ValidationError{
Field: UserSecretEnvNameField,
Detail: UserSecretInjectionTargetRequiredDetail,
})
}
return validations
}
// UserSecretInjectionTargetRequiredDetail explains the injection-target
// invariant. It is shared by the create validator above and the PATCH
// handler's post-state check in coderd. The value is a user-facing
// validation message, not a credential.
const UserSecretInjectionTargetRequiredDetail = "An enabled secret must have at least one of env_name or file_path set. To keep a secret without injecting it, set enabled to false instead of clearing both targets." //nolint:gosec // G101: message text, not a hardcoded credential.
// 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 {
if strings.TrimSpace(s) == "" {
return xerrors.New("Name is required.")
}
if strings.TrimSpace(s) != s {
return xerrors.New("Name must not have leading or trailing whitespace.")
}
if strings.ContainsAny(s, "/?#") {
return xerrors.New("Name must not contain /, ?, or #.")
}
return nil
}
// UserSecretEnvNameValid validates an environment variable name for
// a user secret. Empty string is allowed (means no env injection).
func UserSecretEnvNameValid(s string) error {
if s == "" {
return nil
}
if len(s) > MaxUserSecretEnvNameLength {
return xerrors.Errorf(
"environment variable name must not exceed %d bytes",
MaxUserSecretEnvNameLength,
)
}
if !posixEnvNameRegex.MatchString(s) {
return xerrors.New("must start with a letter or underscore, followed by letters, digits, or underscores")
}
upper := strings.ToUpper(s)
if _, ok := reservedEnvNames[upper]; ok {
return xerrors.Errorf("%s is a reserved environment variable name", upper)
}
if upper == "CODER" || strings.HasPrefix(upper, "CODER_") {
return xerrors.New("environment variable names starting with CODER_ are reserved for internal use")
}
for _, prefix := range reservedEnvPrefixes {
if strings.HasPrefix(upper, prefix) {
return xerrors.Errorf("environment variables starting with %s are reserved", prefix)
}
}
return nil
}
// UserSecretFilePathValid validates a file path for a user secret.
// Empty string is allowed (means no file injection). Non-empty paths
// must start with ~/ or /, must not contain null bytes, and must not
// exceed 4096 bytes.
func UserSecretFilePathValid(s string) error {
if s == "" {
return nil
}
if !strings.HasPrefix(s, "~/") && !strings.HasPrefix(s, "/") {
return xerrors.New("file path must start with ~/ or /")
}
if strings.Contains(s, "\x00") {
return xerrors.New("file path must not contain null bytes")
}
if len(s) > maxFilePathLength {
return xerrors.Errorf("file path must not exceed %d bytes", maxFilePathLength)
}
return nil
}
// UserSecretValueValid validates a user secret value as bytes
// submitted by the user (plaintext). The value must not contain
// null bytes and must not exceed MaxUserSecretValueBytes. The DB
// trigger separately enforces a stored-bytes env aggregate at the
// same numeric cap; under encryption the trigger may reject values
// that pass this check. See MaxUserSecretValueBytes for the
// dual-enforcement explanation.
func UserSecretValueValid(value string) error {
if strings.Contains(value, "\x00") {
return xerrors.New("secret value must not contain null bytes")
}
if len(value) > MaxUserSecretValueBytes {
return xerrors.Errorf("secret value must not exceed %d bytes", MaxUserSecretValueBytes)
}
return nil
}