Skip to content

Commit 67beeaa

Browse files
committed
web: avoid username enumeration on login timing
When the submitted username did not exist, the handler returned without running the expensive password hash, so a missing account answered noticeably faster than a wrong password. That timing difference lets an attacker enumerate valid usernames. Always verify the password, comparing against a fixed dummy hash when the account is absent, so both cases take about the same time. Compute that dummy hash lazily on the first login: it runs 870k PBKDF2 iterations, and every pw subcommand links in this package, so computing it at init would needlessly slow down unrelated commands. Signed-off-by: Robin Jarry <robin@jarry.cc>
1 parent 9129ebb commit 67beeaa

1 file changed

Lines changed: 22 additions & 1 deletion

File tree

‎pkg/web/session.go‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"encoding/hex"
1313
"net/http"
1414
"strings"
15+
"sync"
1516

1617
"github.com/getpatchwork/patchwork/pkg/db"
1718
)
@@ -20,6 +21,16 @@ type contextKey int
2021

2122
const webUserKey contextKey = iota
2223

24+
// dummyPasswordHash is compared against when the submitted username has
25+
// no account, so the login handler spends roughly the same time as it
26+
// would verifying a real password. This keeps failed logins from
27+
// leaking whether a username exists. It is computed lazily on the first
28+
// login so the expensive hash does not slow down unrelated commands that
29+
// merely link in this package.
30+
var dummyPasswordHash = sync.OnceValue(func() string {
31+
return db.HashPassword("patchwork-login-timing-guard")
32+
})
33+
2334
func getWebUser(r *http.Request) *db.User {
2435
u, _ := r.Context().Value(webUserKey).(*db.User)
2536
return u
@@ -82,7 +93,17 @@ func (h *webHandler) LoginSubmit(w http.ResponseWriter, r *http.Request) {
8293

8394
q := db.GetQueries(r.Context())
8495
user, err := q.GetUserByUsername(username)
85-
if err != nil || !db.CheckPassword(password, user.Password) {
96+
var hash string
97+
if user != nil {
98+
hash = user.Password
99+
} else {
100+
// Always run a password check, even for an unknown user, so
101+
// that a missing account takes about as long as a wrong
102+
// password and cannot be distinguished by timing (username
103+
// enumeration).
104+
hash = dummyPasswordHash()
105+
}
106+
if !db.CheckPassword(password, hash) || err != nil {
86107
w.WriteHeader(http.StatusForbidden)
87108
loginPage(pc, "Invalid username or password.", next).Render(r.Context(), w)
88109
return

0 commit comments

Comments
 (0)