Skip to content

Commit 4854f33

Browse files
authored
feat: add secret value and file path validation (#24269)
Add secret value validation to reject null bytes and values exceeding 32KB. The 32KB limit applies uniformly to both env var and file secrets because the value field is shared and the destination can change after creation. Add file path validation to also reject null bytes and paths exceeding 4096 bytes. Wire up secret value validation into both POST and PATCH handlers.
1 parent 6ab3012 commit 4854f33

5 files changed

Lines changed: 161 additions & 5 deletions

File tree

coderd/usersecrets.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
4646
})
4747
return
4848
}
49+
if err := codersdk.UserSecretValueValid(req.Value); err != nil {
50+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
51+
Message: "Invalid secret value.",
52+
Detail: err.Error(),
53+
})
54+
return
55+
}
4956
envOpts := codersdk.UserSecretEnvValidationOptions{
5057
AIGatewayEnabled: api.DeploymentValues.AI.BridgeConfig.Enabled.Value(),
5158
}
@@ -212,6 +219,13 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
212219
FilePath: "",
213220
}
214221
if req.Value != nil {
222+
if err := codersdk.UserSecretValueValid(*req.Value); err != nil {
223+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
224+
Message: "Invalid secret value.",
225+
Detail: err.Error(),
226+
})
227+
return
228+
}
215229
params.Value = *req.Value
216230
}
217231
if req.Description != nil {

coderd/usersecrets_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package coderd_test
22

33
import (
44
"net/http"
5+
"strings"
56
"testing"
67

78
"github.com/stretchr/testify/assert"
@@ -188,6 +189,36 @@ func TestPostUserSecret(t *testing.T) {
188189
require.ErrorAs(t, err, &sdkErr)
189190
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
190191
})
192+
193+
t.Run("NullByteInValue", func(t *testing.T) {
194+
t.Parallel()
195+
ctx := testutil.Context(t, testutil.WaitMedium)
196+
197+
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
198+
Name: "null-byte-secret",
199+
Value: "before\x00after",
200+
})
201+
require.Error(t, err)
202+
var sdkErr *codersdk.Error
203+
require.ErrorAs(t, err, &sdkErr)
204+
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
205+
assert.Contains(t, sdkErr.Message, "Invalid secret value")
206+
})
207+
208+
t.Run("OversizedValue", func(t *testing.T) {
209+
t.Parallel()
210+
ctx := testutil.Context(t, testutil.WaitMedium)
211+
212+
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
213+
Name: "oversized-secret",
214+
Value: strings.Repeat("a", codersdk.MaxSecretValueSize+1),
215+
})
216+
require.Error(t, err)
217+
var sdkErr *codersdk.Error
218+
require.ErrorAs(t, err, &sdkErr)
219+
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
220+
assert.Contains(t, sdkErr.Message, "Invalid secret value")
221+
})
191222
}
192223

193224
func TestGetUserSecrets(t *testing.T) {
@@ -372,6 +403,27 @@ func TestPatchUserSecret(t *testing.T) {
372403
require.ErrorAs(t, err, &sdkErr)
373404
assert.Equal(t, http.StatusConflict, sdkErr.StatusCode())
374405
})
406+
407+
t.Run("InvalidValue", func(t *testing.T) {
408+
t.Parallel()
409+
ctx := testutil.Context(t, testutil.WaitMedium)
410+
411+
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
412+
Name: "patch-invalid-val",
413+
Value: "good-value",
414+
})
415+
require.NoError(t, err)
416+
417+
badVal := "before\x00after"
418+
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "patch-invalid-val", codersdk.UpdateUserSecretRequest{
419+
Value: &badVal,
420+
})
421+
require.Error(t, err)
422+
var sdkErr *codersdk.Error
423+
require.ErrorAs(t, err, &sdkErr)
424+
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
425+
assert.Contains(t, sdkErr.Message, "Invalid secret value")
426+
})
375427
}
376428

377429
func TestDeleteUserSecret(t *testing.T) {

codersdk/usersecretvalidation.go

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ import (
77
"golang.org/x/xerrors"
88
)
99

10+
const (
11+
// MaxSecretValueSize is the maximum size of a user secret value
12+
// in bytes. This limit applies uniformly to both env var and
13+
// file-destined secrets because the value field is shared and
14+
// the destination can change after creation. 32KB is generous
15+
// for env vars (most are under 1KB) but necessary for file
16+
// content like SSH keys, TLS certificate chains, and JSON
17+
// configs. We are not trying to be overly restrictive here;
18+
// users can use the full 32KB for env var values even though
19+
// it would be unusual.
20+
MaxSecretValueSize = 32 * 1024 // 32KB
21+
22+
// maxFilePathLength is the maximum length of a file path for
23+
// a user secret. Matches Linux PATH_MAX, which is the common
24+
// case since workspace agents almost always run on Linux.
25+
// This does not catch all Windows path length edge cases
26+
// (legacy MAX_PATH is 260), but the agent will surface a
27+
// runtime error if the write fails.
28+
maxFilePathLength = 4096
29+
)
30+
1031
// UserSecretEnvValidationOptions controls deployment-aware behavior
1132
// in environment variable name validation.
1233
type UserSecretEnvValidationOptions struct {
@@ -177,15 +198,38 @@ func UserSecretEnvNameValid(s string, opts UserSecretEnvValidationOptions) error
177198

178199
// UserSecretFilePathValid validates a file path for a user secret.
179200
// Empty string is allowed (means no file injection). Non-empty paths
180-
// must start with ~/ or /.
201+
// must start with ~/ or /, must not contain null bytes, and must not
202+
// exceed 4096 bytes.
181203
func UserSecretFilePathValid(s string) error {
182204
if s == "" {
183205
return nil
184206
}
185207

186-
if strings.HasPrefix(s, "~/") || strings.HasPrefix(s, "/") {
187-
return nil
208+
if !strings.HasPrefix(s, "~/") && !strings.HasPrefix(s, "/") {
209+
return xerrors.New("file path must start with ~/ or /")
210+
}
211+
212+
if strings.Contains(s, "\x00") {
213+
return xerrors.New("file path must not contain null bytes")
214+
}
215+
216+
if len(s) > maxFilePathLength {
217+
return xerrors.Errorf("file path must not exceed %d bytes", maxFilePathLength)
188218
}
189219

190-
return xerrors.New("file path must start with ~/ or /")
220+
return nil
221+
}
222+
223+
// UserSecretValueValid validates a user secret value. The value must
224+
// not contain null bytes and must not exceed MaxSecretValueSize.
225+
func UserSecretValueValid(value string) error {
226+
if strings.Contains(value, "\x00") {
227+
return xerrors.New("secret value must not contain null bytes")
228+
}
229+
230+
if len(value) > MaxSecretValueSize {
231+
return xerrors.Errorf("secret value must not exceed %d bytes", MaxSecretValueSize)
232+
}
233+
234+
return nil
191235
}

codersdk/usersecretvalidation_test.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package codersdk_test
22

33
import (
4+
"strings"
45
"testing"
56

67
"github.com/stretchr/testify/assert"
@@ -166,6 +167,8 @@ func TestUserSecretFilePathValid(t *testing.T) {
166167
{name: "DotRelative", input: ".ssh/id_rsa", wantErr: true},
167168
{name: "JustFilename", input: "credentials", wantErr: true},
168169
{name: "TildeNoSlash", input: "~foo", wantErr: true},
170+
{name: "NullByte", input: "/home/\x00coder", wantErr: true},
171+
{name: "TooLong", input: "/" + strings.Repeat("a", 4096), wantErr: true},
169172
}
170173

171174
for _, tt := range tests {
@@ -174,7 +177,36 @@ func TestUserSecretFilePathValid(t *testing.T) {
174177
err := codersdk.UserSecretFilePathValid(tt.input)
175178
if tt.wantErr {
176179
assert.Error(t, err)
177-
assert.Contains(t, err.Error(), "must start with")
180+
} else {
181+
assert.NoError(t, err)
182+
}
183+
})
184+
}
185+
}
186+
187+
func TestUserSecretValueValid(t *testing.T) {
188+
t.Parallel()
189+
190+
tests := []struct {
191+
name string
192+
input string
193+
wantErr bool
194+
}{
195+
{name: "NormalString", input: "my-secret-token"},
196+
{name: "Empty", input: ""},
197+
{name: "WithNewlines", input: "line1\nline2\nline3"},
198+
{name: "WithTabs", input: "key\tvalue"},
199+
{name: "NullByte", input: "before\x00after", wantErr: true},
200+
{name: "ExactlyAtLimit", input: strings.Repeat("a", codersdk.MaxSecretValueSize)},
201+
{name: "OverLimit", input: strings.Repeat("a", codersdk.MaxSecretValueSize+1), wantErr: true},
202+
}
203+
204+
for _, tt := range tests {
205+
t.Run(tt.name, func(t *testing.T) {
206+
t.Parallel()
207+
err := codersdk.UserSecretValueValid(tt.input)
208+
if tt.wantErr {
209+
assert.Error(t, err)
178210
} else {
179211
assert.NoError(t, err)
180212
}

site/src/api/typesGenerated.ts

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)