feat: enforce per-user limits on user_secrets (#25588)

Add a Postgres trigger and matching codersdk constants that cap each
user's secrets in four dimensions: count (50), total stored value bytes
(200 KiB), env-injected stored value bytes (24 KiB), and env name length
(256 bytes). Without these caps a user could overflow the 4 MiB DRPC
agent manifest, the ~32 KiB Windows process env
block, or Linux/macOS ARG_MAX at workspace start. The trigger is the
source of truth on aggregates; the handler maps its check_violation
error into a 400 that names the per-user budget in stored
(post-encryption) bytes. A handler test exercises off-by-one at each cap
across POST and PATCH, plus per-user budget isolation.

Generated with help from Coder Agents.
This commit is contained in:
Zach
2026-05-26 14:42:31 -06:00
committed by GitHub
parent d3155e1cab
commit 47ac4b309a
9 changed files with 672 additions and 28 deletions
+63
View File
@@ -837,6 +837,67 @@ BEGIN
END;
$$;
CREATE FUNCTION enforce_user_secrets_per_user_limits() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
existing_count int;
existing_total_bytes bigint;
existing_env_bytes bigint;
new_count int;
new_total_bytes bigint;
new_env_bytes bigint;
count_limit constant int := 50;
total_bytes_limit constant bigint := 204800; -- 200 KiB
env_bytes_limit constant bigint := 24576; -- 24 KiB
BEGIN
-- Serialize cap checks per user so concurrent inserts cannot all
-- observe the same pre-insert aggregates and exceed the cap.
PERFORM 1 FROM users WHERE id = NEW.user_id FOR UPDATE;
-- Sum existing rows excluding the row being updated (so UPDATE statements
-- don't double-count NEW). On INSERT, no row matches NEW.id, so
-- the FILTER is a no-op.
SELECT
count(*) FILTER (WHERE id IS DISTINCT FROM NEW.id),
coalesce(sum(octet_length(value)) FILTER (WHERE id IS DISTINCT FROM NEW.id), 0),
coalesce(sum(octet_length(value)) FILTER (WHERE id IS DISTINCT FROM NEW.id AND env_name <> ''), 0)
INTO existing_count, existing_total_bytes, existing_env_bytes
FROM user_secrets
WHERE user_id = NEW.user_id;
new_count := existing_count + 1;
new_total_bytes := existing_total_bytes + octet_length(NEW.value);
new_env_bytes := existing_env_bytes
+ CASE WHEN NEW.env_name <> '' THEN octet_length(NEW.value) ELSE 0 END;
IF new_count > count_limit THEN
RAISE EXCEPTION 'user has reached the user secrets count limit (% > %)',
new_count, count_limit
USING ERRCODE = 'check_violation',
CONSTRAINT = 'user_secrets_per_user_count_limit';
END IF;
IF new_total_bytes > total_bytes_limit THEN
RAISE EXCEPTION 'user has reached the user secrets total value bytes limit (% > %)',
new_total_bytes, total_bytes_limit
USING ERRCODE = 'check_violation',
CONSTRAINT = 'user_secrets_per_user_total_bytes_limit';
END IF;
IF new_env_bytes > env_bytes_limit THEN
RAISE EXCEPTION 'user has reached the env-injected user secrets bytes limit (% > %)',
new_env_bytes, env_bytes_limit
USING ERRCODE = 'check_violation',
CONSTRAINT = 'user_secrets_per_user_env_bytes_limit';
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION enforce_user_skills_per_user_limit() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -4398,6 +4459,8 @@ CREATE TRIGGER trigger_upsert_user_secrets BEFORE INSERT OR UPDATE ON user_secre
CREATE TRIGGER trigger_upsert_user_skills BEFORE INSERT OR UPDATE ON user_skills FOR EACH ROW EXECUTE FUNCTION insert_user_skill_fail_if_user_deleted();
CREATE TRIGGER trigger_user_secrets_per_user_limits BEFORE INSERT OR UPDATE ON user_secrets FOR EACH ROW EXECUTE FUNCTION enforce_user_secrets_per_user_limits();
CREATE TRIGGER trigger_user_skills_per_user_limit BEFORE INSERT ON user_skills FOR EACH ROW EXECUTE FUNCTION enforce_user_skills_per_user_limit();
CREATE TRIGGER update_notification_message_dedupe_hash BEFORE INSERT OR UPDATE ON notification_messages FOR EACH ROW EXECUTE FUNCTION compute_notification_message_dedupe_hash();
@@ -0,0 +1,2 @@
DROP TRIGGER IF EXISTS trigger_user_secrets_per_user_limits ON user_secrets;
DROP FUNCTION IF EXISTS enforce_user_secrets_per_user_limits();
@@ -0,0 +1,105 @@
-- Per-user user_secrets caps (count, total stored bytes, env-injected
-- stored bytes), enforced at the schema level.
--
-- Why: user_secrets is user-scoped; every workspace loads the same
-- set via the agent manifest, and env-injected ones land in the
-- agent's process env. Without a cap the failure surfaces at
-- workspace start (or as a truncated env), not at create-time.
--
-- What drives each cap:
--
-- * count_limit = 50: backstop against row-count growth from many
-- small secrets. The total_bytes_limit binds first for large
-- secrets; this binds first for typical-sized ones (~few KB).
--
-- * total_bytes_limit = 200 KiB: sized to cover realistic
-- credential storage (API keys, SSH keys, kubeconfigs, cert
-- bundles) with headroom. Well under the 4 MiB DRPC manifest
-- budget (codersdk/drpcsdk.MaxMessageSize).
--
-- * env_bytes_limit = 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 the same cap works everywhere.
--
-- octet_length(value) measures stored bytes. In encrypted
-- deployments stored bytes exceed plaintext (AES-GCM + base64
-- ~1.33x). The handler's per-value check (UserSecretValueValid)
-- measures plaintext separately, so it can pass while the
-- trigger's stored-bytes aggregate rejects. The trigger is
-- authoritative; the handler is a fast pre-flight.
--
-- Keep the literals below in sync with codersdk.MaxUserSecret*
-- in codersdk/usersecretvalidation.go. TestUserSecretLimits in
-- coderd/usersecrets_test.go exercises off-by-one for each cap,
-- so any drift between the two layers fails an assertion.
CREATE FUNCTION enforce_user_secrets_per_user_limits() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
existing_count int;
existing_total_bytes bigint;
existing_env_bytes bigint;
new_count int;
new_total_bytes bigint;
new_env_bytes bigint;
count_limit constant int := 50;
total_bytes_limit constant bigint := 204800; -- 200 KiB
env_bytes_limit constant bigint := 24576; -- 24 KiB
BEGIN
-- Serialize cap checks per user so concurrent inserts cannot all
-- observe the same pre-insert aggregates and exceed the cap.
PERFORM 1 FROM users WHERE id = NEW.user_id FOR UPDATE;
-- Sum existing rows excluding the row being updated (so UPDATE statements
-- don't double-count NEW). On INSERT, no row matches NEW.id, so
-- the FILTER is a no-op.
SELECT
count(*) FILTER (WHERE id IS DISTINCT FROM NEW.id),
coalesce(sum(octet_length(value)) FILTER (WHERE id IS DISTINCT FROM NEW.id), 0),
coalesce(sum(octet_length(value)) FILTER (WHERE id IS DISTINCT FROM NEW.id AND env_name <> ''), 0)
INTO existing_count, existing_total_bytes, existing_env_bytes
FROM user_secrets
WHERE user_id = NEW.user_id;
new_count := existing_count + 1;
new_total_bytes := existing_total_bytes + octet_length(NEW.value);
new_env_bytes := existing_env_bytes
+ CASE WHEN NEW.env_name <> '' THEN octet_length(NEW.value) ELSE 0 END;
IF new_count > count_limit THEN
RAISE EXCEPTION 'user has reached the user secrets count limit (% > %)',
new_count, count_limit
USING ERRCODE = 'check_violation',
CONSTRAINT = 'user_secrets_per_user_count_limit';
END IF;
IF new_total_bytes > total_bytes_limit THEN
RAISE EXCEPTION 'user has reached the user secrets total value bytes limit (% > %)',
new_total_bytes, total_bytes_limit
USING ERRCODE = 'check_violation',
CONSTRAINT = 'user_secrets_per_user_total_bytes_limit';
END IF;
IF new_env_bytes > env_bytes_limit THEN
RAISE EXCEPTION 'user has reached the env-injected user secrets bytes limit (% > %)',
new_env_bytes, env_bytes_limit
USING ERRCODE = 'check_violation',
CONSTRAINT = 'user_secrets_per_user_env_bytes_limit';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trigger_user_secrets_per_user_limits
BEFORE INSERT OR UPDATE ON user_secrets
FOR EACH ROW
EXECUTE PROCEDURE enforce_user_secrets_per_user_limits();
+54
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
@@ -23,6 +24,13 @@ const (
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.
userSecretsCountLimitConstraint database.CheckConstraint = "user_secrets_per_user_count_limit"
userSecretsTotalBytesLimitConstraint database.CheckConstraint = "user_secrets_per_user_total_bytes_limit"
userSecretsEnvBytesLimitConstraint database.CheckConstraint = "user_secrets_per_user_env_bytes_limit"
)
// @Summary Create a new user secret
@@ -74,6 +82,10 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, validations)
return
}
if resp, ok := userSecretLimitResponse(err); ok {
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error creating secret.",
Detail: err.Error(),
@@ -246,6 +258,10 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, validations)
return
}
if resp, ok := userSecretLimitResponse(err); ok {
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating secret.",
Detail: err.Error(),
@@ -346,6 +362,44 @@ func appendUserSecretValidationError(validations []codersdk.ValidationError, fie
})
}
// userSecretLimitResponse maps a per-user-limits trigger violation
// (raised by enforce_user_secrets_per_user_limits) to a 400. Returns
// ok=false if err is not such a violation. See
// codersdk.MaxUserSecretsPerUserCount for the rationale behind the caps.
func userSecretLimitResponse(err error) (codersdk.Response, bool) {
switch {
case database.IsCheckViolation(err, userSecretsCountLimitConstraint):
return codersdk.Response{
Message: "User secrets limit reached.",
Detail: fmt.Sprintf(
"Each user can have at most %d secrets.",
codersdk.MaxUserSecretsPerUserCount,
),
}, true
case database.IsCheckViolation(err, userSecretsTotalBytesLimitConstraint):
return codersdk.Response{
Message: "User secrets value-bytes limit reached.",
Detail: fmt.Sprintf(
"Stored bytes of your secret values exceed the per-user "+
"budget (%d bytes after encryption, if applicable). "+
"Reduce the size or number of your secrets.",
codersdk.MaxUserSecretsTotalValueBytes,
),
}, true
case database.IsCheckViolation(err, userSecretsEnvBytesLimitConstraint):
return codersdk.Response{
Message: "Environment-injected user secrets bytes limit reached.",
Detail: fmt.Sprintf(
"Stored bytes of env-injected secret values exceed the "+
"per-user budget (%d bytes after encryption, if applicable). "+
"Clear env_name on large secrets or use file_path instead.",
codersdk.MaxUserSecretValueBytes,
),
}, true
}
return codersdk.Response{}, false
}
func userSecretConflictValidationErrors(err error) []codersdk.ValidationError {
switch {
case database.IsUniqueViolation(err, database.UniqueUserSecretsUserNameIndex):
+213 -1
View File
@@ -1,6 +1,7 @@
package coderd_test
import (
"fmt"
"net/http"
"strings"
"testing"
@@ -200,7 +201,7 @@ func TestPostUserSecret(t *testing.T) {
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "oversized-secret",
Value: strings.Repeat("a", codersdk.MaxSecretValueSize+1),
Value: strings.Repeat("a", codersdk.MaxUserSecretValueBytes+1),
})
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "value", "must not exceed")
})
@@ -463,6 +464,217 @@ func requireSecretValidation(t *testing.T, err error, status int, field string)
return codersdk.ValidationError{}
}
// TestUserSecretLimits exercises the per-user count and byte caps
// enforced by enforce_user_secrets_per_user_limits across both POST
// (creating a new secret) and PATCH (updating an existing one).
// Each subtest spins up its own server so it can burn the budget
// without affecting other tests.
//
// Each subtest checks three things per cap:
//
// - POST past the cap is rejected with a 400.
// - PATCH of an existing row at the cap is accepted; the trigger
// uses FILTER (WHERE id IS DISTINCT FROM NEW.id) so an UPDATE
// does not double-count its own row.
// - A different user's budget is independent; the trigger groups
// by user_id.
func TestUserSecretLimits(t *testing.T) {
t.Parallel()
t.Run("CountLimit", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
otherClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// Fill the count budget exactly to the cap.
var firstSecret codersdk.UserSecret
for i := 0; i < codersdk.MaxUserSecretsPerUserCount; i++ {
s, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: fmt.Sprintf("count-limit-%03d", i),
Value: "x",
})
require.NoError(t, err)
if i == 0 {
firstSecret = s
}
}
// POST: the 51st secret is rejected.
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "one-too-many",
Value: "x",
})
requireSecretAPIError(t, err, http.StatusBadRequest, "at most")
// PATCH at the cap: changing the description must succeed.
// Without the FILTER clause the trigger would re-count
// firstSecret and reject this UPDATE.
newDescription := "renamed"
_, err = client.UpdateUserSecret(ctx, codersdk.Me, firstSecret.Name, codersdk.UpdateUserSecretRequest{
Description: &newDescription,
})
require.NoError(t, err)
// Other-user isolation: the second user's budget is independent.
_, err = otherClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "other-user-secret",
Value: "x",
})
require.NoError(t, err)
})
t.Run("TotalBytesLimit", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
otherClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// Pre-fill the total-bytes budget exactly to the cap using
// max-sized file-only secrets (which don't count against env
// bytes).
big := strings.Repeat("a", codersdk.MaxUserSecretValueBytes)
numBig := codersdk.MaxUserSecretsTotalValueBytes / codersdk.MaxUserSecretValueBytes
remainder := codersdk.MaxUserSecretsTotalValueBytes % codersdk.MaxUserSecretValueBytes
var firstSecret codersdk.UserSecret
for i := 0; i < numBig; i++ {
s, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: fmt.Sprintf("big-%03d", i),
Value: big,
FilePath: fmt.Sprintf("/tmp/big-%03d", i),
})
require.NoError(t, err)
if i == 0 {
firstSecret = s
}
}
if remainder > 0 {
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "big-pad",
Value: strings.Repeat("a", remainder),
FilePath: "/tmp/big-pad",
})
require.NoError(t, err)
}
// POST: one more byte pushes past the total budget.
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "overflow",
Value: "x",
FilePath: "/tmp/overflow",
})
requireSecretAPIError(t, err, http.StatusBadRequest, "per-user budget")
// PATCH at the cap: rewriting the existing row with a value
// of the same size must succeed. The FILTER clause excludes
// firstSecret's old bytes from the aggregate so the trigger
// computes (cap - old) + new = cap, not cap + new.
_, err = client.UpdateUserSecret(ctx, codersdk.Me, firstSecret.Name, codersdk.UpdateUserSecretRequest{
Value: &big,
})
require.NoError(t, err)
// Other-user isolation: a fresh user can fill their own
// total-bytes budget without interference.
for i := 0; i < numBig; i++ {
_, err := otherClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: fmt.Sprintf("other-big-%03d", i),
Value: big,
FilePath: fmt.Sprintf("/tmp/other-big-%03d", i),
})
require.NoError(t, err)
}
if remainder > 0 {
_, err := otherClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "other-big-pad",
Value: strings.Repeat("a", remainder),
FilePath: "/tmp/other-big-pad",
})
require.NoError(t, err)
}
})
t.Run("EnvBytesLimit", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
otherClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// One env-injected secret consumes nearly the whole env budget.
envBig, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "env-big",
Value: strings.Repeat("a", codersdk.MaxUserSecretValueBytes-16),
EnvName: "ENV_BIG",
})
require.NoError(t, err)
// POST: another env-injected secret pushes us over the env budget.
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "env-overflow",
Value: strings.Repeat("a", 1024),
EnvName: "ENV_OVERFLOW",
})
requireSecretAPIError(t, err, http.StatusBadRequest, "env_name")
// A same-sized value used purely as a file is fine because
// file_path secrets do not count against the env budget.
fileOK, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "file-ok",
Value: strings.Repeat("a", 1024),
FilePath: "/tmp/file-ok",
})
require.NoError(t, err)
// PATCH at the cap: updating envBig's description must
// succeed. Without FILTER, the trigger would re-add envBig's
// 24 KiB to itself and reject the UPDATE.
newDescription := "renamed"
_, err = client.UpdateUserSecret(ctx, codersdk.Me, envBig.Name, codersdk.UpdateUserSecretRequest{
Description: &newDescription,
})
require.NoError(t, err)
// PATCH a file_path secret to env mode: moves its 1 KiB into
// the env budget, which already holds envBig's 24 KiB - 16.
// new_env_bytes = 24560 + 1024 = 25584 > 24576, rejected.
envName := "ENV_LATE"
_, err = client.UpdateUserSecret(ctx, codersdk.Me, fileOK.Name, codersdk.UpdateUserSecretRequest{
EnvName: &envName,
})
requireSecretAPIError(t, err, http.StatusBadRequest, "env_name")
// Other-user isolation: a fresh user can create their own
// near-cap env secret.
_, err = otherClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "other-env-big",
Value: strings.Repeat("a", codersdk.MaxUserSecretValueBytes-16),
EnvName: "OTHER_ENV_BIG",
})
require.NoError(t, err)
})
}
// requireSecretAPIError asserts a non-validation user-facing error.
// Used for trigger-driven failures (per-user limits) whose responses
// are plain codersdk.Response without ValidationError entries.
func requireSecretAPIError(t *testing.T, err error, status int, detailContains string) {
t.Helper()
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
assert.Equal(t, status, sdkErr.StatusCode())
combined := sdkErr.Message + " " + sdkErr.Response.Detail
assert.Containsf(t, combined, detailContains,
"expected response to contain %q; got Message=%q Detail=%q",
detailContains, sdkErr.Message, sdkErr.Response.Detail)
}
func TestDeleteUserSecret(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
+104 -15
View File
@@ -8,17 +8,6 @@ import (
)
const (
// MaxSecretValueSize is the maximum size of a user secret value
// in bytes. This limit applies uniformly to both env var and
// file-destined secrets because the value field is shared and
// the destination can change after creation. 32KB is generous
// for env vars (most are under 1KB) but necessary for file
// content like SSH keys, TLS certificate chains, and JSON
// configs. We are not trying to be overly restrictive here;
// users can use the full 32KB for env var values even though
// it would be unusual.
MaxSecretValueSize = 32 * 1024 // 32KB
// 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.
@@ -28,6 +17,94 @@ const (
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,
@@ -157,6 +234,13 @@ func UserSecretEnvNameValid(s string) error {
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")
}
@@ -204,15 +288,20 @@ func UserSecretFilePathValid(s string) error {
return nil
}
// UserSecretValueValid validates a user secret value. The value must
// not contain null bytes and must not exceed MaxSecretValueSize.
// 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) > MaxSecretValueSize {
return xerrors.Errorf("secret value must not exceed %d bytes", MaxSecretValueSize)
if len(value) > MaxUserSecretValueBytes {
return xerrors.Errorf("secret value must not exceed %d bytes", MaxUserSecretValueBytes)
}
return nil
+6 -2
View File
@@ -63,6 +63,10 @@ func TestUserSecretEnvNameValid(t *testing.T) {
{name: "WithDigits", input: "A1B2"},
{name: "Empty", input: ""},
// Length cap.
{name: "ExactlyAtLengthLimit", input: strings.Repeat("A", codersdk.MaxUserSecretEnvNameLength)},
{name: "OverLengthLimit", input: strings.Repeat("A", codersdk.MaxUserSecretEnvNameLength+1), wantErr: true, errMsg: "256 bytes"},
// Invalid POSIX names.
{name: "StartsWithDigit", input: "1FOO", wantErr: true, errMsg: "must start with"},
{name: "ContainsHyphen", input: "FOO-BAR", wantErr: true, errMsg: "must start with"},
@@ -214,8 +218,8 @@ func TestUserSecretValueValid(t *testing.T) {
{name: "WithNewlines", input: "line1\nline2\nline3"},
{name: "WithTabs", input: "key\tvalue"},
{name: "NullByte", input: "before\x00after", wantErr: true},
{name: "ExactlyAtLimit", input: strings.Repeat("a", codersdk.MaxSecretValueSize)},
{name: "OverLimit", input: strings.Repeat("a", codersdk.MaxSecretValueSize+1), wantErr: true},
{name: "ExactlyAtLimit", input: strings.Repeat("a", codersdk.MaxUserSecretValueBytes)},
{name: "OverLimit", input: strings.Repeat("a", codersdk.MaxUserSecretValueBytes+1), wantErr: true},
}
for _, tt := range tests {
+29
View File
@@ -77,6 +77,35 @@ example `~/config` and `/home/coder/config`), only one of them ends up on
disk; the workspace agent logs a warning to help spot this. Use
distinct paths to avoid the collision.
## Limits
User secrets are subject to the following limits. Coder enforces these when you
create or update a secret and rejects the request with an explanatory 400 when
you exceed one. Delete or shrink an existing secret to make room.
| Cap | Value |
|------------------------------------------|-----------|
| Total secrets per user | 50 |
| Combined stored value bytes per user | 200 KiB |
| Combined stored env-injected value bytes | 24 KiB |
| Per-secret value bytes | 24 KiB |
| Env var name length | 256 bytes |
Only secrets created with `--env` count against the env-injected budget. Coder
injects these into the workspace agent's process environment, which on Windows
has a ~32 KiB total budget. The 24 KiB ceiling leaves room for Coder's own
variables (`CODER_*`, `PATH`, `HOME`, ...) plus any template-defined env. To
inject a value larger than this budget, use `--file` instead; file secrets do
not count against the env budget.
The per-secret cap matches the env aggregate cap because a value larger than
the env aggregate could never be injected successfully as an environment
variable.
These caps measure stored bytes, which is what Coder writes to the database.
In deployments with secret encryption enabled, stored bytes exceed the raw
value.
## Create a secret
You can create, edit, and delete user secrets in the Coder dashboard. Click your
+96 -10
View File
@@ -5230,17 +5230,103 @@ export const MaxChatFileSizeBytes = 10485760;
// From codersdk/usersecretvalidation.go
/**
* MaxSecretValueSize is the maximum size of a user secret value
* in bytes. This limit applies uniformly to both env var and
* file-destined secrets because the value field is shared and
* the destination can change after creation. 32KB is generous
* for env vars (most are under 1KB) but necessary for file
* content like SSH keys, TLS certificate chains, and JSON
* configs. We are not trying to be overly restrictive here;
* users can use the full 32KB for env var values even though
* it would be unusual.
* 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).
*/
export const MaxSecretValueSize = 32768; // 32KB
export const MaxUserSecretEnvNameLength = 256;
// From codersdk/usersecretvalidation.go
/**
* 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).
*/
export const MaxUserSecretValueBytes = 24576; // 24 KiB
// From codersdk/usersecretvalidation.go
/**
* 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.
*/
export const MaxUserSecretsPerUserCount = 50;
// From codersdk/usersecretvalidation.go
/**
* MaxUserSecretsTotalValueBytes caps the sum of stored value bytes
* per user. See MaxUserSecretsPerUserCount for the full rationale and
* math behind all three caps.
*/
export const MaxUserSecretsTotalValueBytes = 204800; // 200 KiB
// From codersdk/organizations.go
export interface MinimalOrganization {