Skip to content

Commit 0951f90

Browse files
f0sselsreya
andauthored
fix(coderd): use a random value for a simulated hash for built-in users (#26205) (#26319)
Backport of #26205 to `release/2.29` (SEC-81 / PLAT-272). Conflict resolution: import-only conflict in `coderd/users_test.go` (kept the `db2sdk` import still used on 2.29). > 🤖 Backport prepared by Coder Agents on behalf of @f0ssel. Co-authored-by: Jon Ayers <jon@coder.com>
1 parent c1889d0 commit 0951f90

3 files changed

Lines changed: 85 additions & 7 deletions

File tree

coderd/userpassword/userpassword.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,12 @@ var (
3939
// used.
4040
defaultSaltSize = 16
4141

42-
// The simulated hash is used when trying to simulate password checks for
43-
// users that don't exist. It's meant to preserve the timing of the hash
44-
// comparison.
42+
// The simulated hash is used when comparing against an empty stored hash
43+
// (e.g. nonexistent or SSO users). It hashes a random value generated on
44+
// first use, so no attacker-supplied password can ever match it. It exists
45+
// purely to keep failed comparisons constant-time.
4546
simulatedHash = lazy.New(func() string {
46-
h, err := Hash("hunter2")
47+
h, err := Hash(rand.Text())
4748
if err != nil {
4849
panic(err)
4950
}
@@ -72,10 +73,10 @@ func init() {
7273
// uses pbkdf2 to ensure FIPS 140-2 compliance. See:
7374
// https://csrc.nist.gov/csrc/media/templates/cryptographic-module-validation-program/documents/security-policies/140sp2261.pdf
7475
func Compare(hashed string, password string) (bool, error) {
75-
// If the hased password provided is empty, simulate comparing a real hash.
76+
// If the hashed password provided is empty, simulate comparing a real hash
77+
// to preserve timing. The simulated hash is derived from a random value, so
78+
// the comparison below can never succeed.
7679
if hashed == "" {
77-
// TODO: this seems ripe for creating a vulnerability where
78-
// hunter2 can log into any account.
7980
hashed = simulatedHash.Load()
8081
}
8182

coderd/userpassword/userpassword_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,30 @@ func TestUserPasswordCompare(t *testing.T) {
8989
wantErr: true,
9090
wantEqual: false,
9191
},
92+
{
93+
name: "EmptyHashHunter2",
94+
passwordToValidate: "",
95+
password: "hunter2",
96+
shouldHash: false,
97+
wantErr: false,
98+
wantEqual: false,
99+
},
100+
{
101+
name: "EmptyHashEmptyPassword",
102+
passwordToValidate: "",
103+
password: "",
104+
shouldHash: false,
105+
wantErr: false,
106+
wantEqual: false,
107+
},
108+
{
109+
name: "EmptyHashArbitraryPassword",
110+
passwordToValidate: "",
111+
password: "anyOtherPassword",
112+
shouldHash: false,
113+
wantErr: false,
114+
wantEqual: false,
115+
},
92116
}
93117

94118
for _, tt := range tests {

coderd/users_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,59 @@ func TestPostLogin(t *testing.T) {
167167
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs()[numLogs-1].Action)
168168
})
169169

170+
// "hunter2" was the input of the previous hardcoded simulated hash, which
171+
// an empty stored hash wrongly matched; this is a regression test.
172+
t.Run("NonexistentUser401", func(t *testing.T) {
173+
t.Parallel()
174+
client := coderdtest.New(t, nil)
175+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
176+
defer cancel()
177+
178+
_, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
179+
Email: "does-not-exist@coder.com",
180+
Password: "hunter2",
181+
})
182+
var apiErr *codersdk.Error
183+
require.ErrorAs(t, err, &apiErr)
184+
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode())
185+
require.Equal(t, "Incorrect email or password.", apiErr.Message)
186+
})
187+
188+
// Attempting built-in login as an SSO user returns a 401 to avoid
189+
// divulging login type.
190+
t.Run("SSOReturns401", func(t *testing.T) {
191+
t.Parallel()
192+
client, db := coderdtest.NewWithDatabase(t, nil)
193+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
194+
defer cancel()
195+
196+
// An SSO user has no password hash stored. Create one directly in the
197+
// database since the API requires OIDC to be configured. dbgen.User
198+
// substitutes a random hash for an empty one, so clear it explicitly.
199+
ssoUser := dbgen.User(t, db, database.User{
200+
Email: "sso-user@coder.com",
201+
LoginType: database.LoginTypeOIDC,
202+
})
203+
//nolint:gocritic // Test setup requires a system context to clear the hash.
204+
err := db.UpdateUserHashedPassword(dbauthz.AsSystemRestricted(ctx), database.UpdateUserHashedPasswordParams{
205+
ID: ssoUser.ID,
206+
HashedPassword: []byte{},
207+
})
208+
require.NoError(t, err)
209+
210+
anonClient := codersdk.New(client.URL)
211+
_, err = anonClient.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
212+
Email: ssoUser.Email,
213+
Password: "hunter2",
214+
})
215+
var apiErr *codersdk.Error
216+
require.ErrorAs(t, err, &apiErr)
217+
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode())
218+
require.Equal(t, "Incorrect email or password.", apiErr.Message)
219+
// The login type must not be leaked.
220+
require.NotContains(t, apiErr.Message, string(codersdk.LoginTypeOIDC))
221+
})
222+
170223
t.Run("Suspended", func(t *testing.T) {
171224
t.Parallel()
172225
auditor := audit.NewMock()

0 commit comments

Comments
 (0)