Refuse passwords over 72 bytes and confirm only stored ones (closes #144)
check / check (push) Waiting to run

bcrypt cannot hash a password longer than 72 bytes. PASS over the HTTP
API answered one with a 500, and the IRC listener confirmed it without
storing it.

Both transports now refuse a password outside 8 to 72 bytes with the
reply a short one already got: 461 over the HTTP API, a NOTICE over the
IRC listener. setPassword returns its error, and "Password set" is sent
only when the password was stored. A PASS given before registration is
checked after the welcome burst and gets the same NOTICE.

Model: opus-5-5
This commit is contained in:
2026-10-21 01:47:04 +00:00
parent e7e408c93e
commit 1ef44fd760
6 changed files with 231 additions and 24 deletions
+125
View File
@@ -1158,6 +1158,131 @@ func TestHandlePassPostRegistration(t *testing.T) {
)
}
// maxPasswordBytes is the longest password bcrypt can hash.
const maxPasswordBytes = 72
// passwordLengthNotice is the NOTICE text for a password that
// is too short or too long.
const passwordLengthNotice = "Password must be 8 to 72 bytes"
// assertCanLogin fails the test if nick cannot log in with
// password.
func assertCanLogin(t *testing.T, env *testEnv, nick, password string) {
t.Helper()
_, _, token, err := env.database.LoginUser(
t.Context(), nick, password, "127.0.0.1", "localhost",
)
if err != nil || token == "" {
t.Errorf("log in as %s: token %q, %v", nick, token, err)
}
}
// TestPassPostRegistrationLength checks that a PASS over
// maxPasswordBytes after registration is refused with a
// NOTICE, and that one of exactly maxPasswordBytes is stored
// and confirmed.
func TestPassPostRegistrationLength(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("longpass")
client.sendAndExpect(
"PASS :"+strings.Repeat("a", maxPasswordBytes+1),
passwordLengthNotice,
)
longest := strings.Repeat("a", maxPasswordBytes)
client.sendAndExpect("PASS :"+longest, "Password set")
assertCanLogin(t, env, "longpass", longest)
}
// TestPassPostRegistrationNotStored checks that PASS is not
// confirmed when the password could not be stored.
func TestPassPostRegistrationNotStored(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
client.register("failpass")
_, err := env.database.GetDB().ExecContext(t.Context(),
`CREATE TRIGGER refuse_password
BEFORE UPDATE OF password_hash ON sessions
BEGIN SELECT RAISE(ABORT, 'refused'); END`,
)
if err != nil {
t.Fatalf("create trigger: %v", err)
}
client.send("PASS :mypassword123")
// The server answers commands in order, so anything it
// sent about PASS comes before the PONG.
lines := client.sendAndExpect("PING :after", "PONG")
assertNotContains(
t, lines, "Password set",
"confirmation of a password that was not stored",
)
}
// TestPassBeforeRegistrationLength checks that a PASS before
// registration that is too short or too long is refused with
// a NOTICE once registration completes.
func TestPassBeforeRegistrationLength(t *testing.T) {
t.Parallel()
cases := []struct {
nick, password string
}{
{"preshort", "short"},
{"prelong", strings.Repeat("a", maxPasswordBytes+1)},
}
env := newTestEnv(t)
for _, tc := range cases {
t.Run(tc.nick, func(t *testing.T) {
t.Parallel()
client := env.dial(t)
client.send("PASS :" + tc.password)
client.register(tc.nick)
client.readUntil(func(line string) bool {
return strings.Contains(line, passwordLengthNotice)
})
})
}
}
// TestPassBeforeRegistrationStored checks that a PASS of
// exactly maxPasswordBytes before registration is stored.
func TestPassBeforeRegistrationStored(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
client := env.dial(t)
longest := strings.Repeat("a", maxPasswordBytes)
client.send("PASS :" + longest)
client.register("prestored")
// The password is stored after the welcome burst; the
// PONG comes after that.
client.sendAndExpect("PING :after", "PONG")
assertCanLogin(t, env, "prestored", longest)
}
func TestPreRegistrationNotRegistered(t *testing.T) {
t.Parallel()