Skip to content

Commit d336c1e

Browse files
committed
fix: enforce account lockout on Flask-Security's /login endpoint (#9904)
pgAdmin enforces MAX_LOGIN_ATTEMPTS only inside its own /authenticate/login view. Flask-Security's default /login view (registered automatically by security.init_app) was reachable but never consulted the locked field: - User inherited Flask-Security's UserMixin.is_locked(), which always returns True (= "not locked, proceed"). - User inherited Flask-Login's is_active, which only checks the active column, not locked. So an attacker with valid credentials could bypass a lockout by posting to /login after triggering the counter at /authenticate/login. Override both contracts on the User model so every auth path inherits them: - is_active: False when active=False OR locked=True, so flask_security.login_user() refuses to mint a session. - is_locked(form_error): returns False (= "locked") and appends the same "Your account is locked" message /authenticate/login uses, so LoginForm.validate() rejects the submission cleanly. Only INTERNAL accounts are reachable by the bypass (LDAP/OAuth2/Kerberos/ Webserver users have no local password, which LoginForm.validate rejects before the locked check) and lockout itself is internal-only (auth_source filter at authenticate/__init__.py:124), so the overrides only take effect for INTERNAL accounts. Adds a unit test covering the four (active, locked) combinations of the contract LoginForm.validate() depends on. Also includes a SQLite-only data-cleanup migration. Migration 6650c52670c2 added the locked column with server_default='false'. SQLite has no native BOOLEAN affinity, so the literal 'false' was stored as TEXT both in the column DEFAULT and on existing rows backfilled by ALTER TABLE. SQLAlchemy's Boolean processor then reads that TEXT back as Python True (because bool('false') is True for any non-empty string), which means the new is_active override would otherwise see every legacy row as locked and refuse login. The new migration normalize_locked_text_default rewrites every SQLite row's locked to integer 0/1 (NULL -> 0); on a PostgreSQL config DB the column was always stored as a proper BOOLEAN so the migration is a no-op there (and integer literals would fail on a BOOLEAN column anyway). SCHEMA_VERSION is bumped from 51 to 52. Reported-by: Fernando Bortotti <fernando.bortotti@bsd.com.br>
1 parent 7f230d0 commit d336c1e

3 files changed

Lines changed: 176 additions & 1 deletion

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
##########################################################################
2+
#
3+
# pgAdmin 4 - PostgreSQL Tools
4+
#
5+
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
6+
# This software is released under the PostgreSQL Licence
7+
#
8+
##########################################################################
9+
10+
"""Normalize User.locked column data from TEXT to integer 0/1 (SQLite only)
11+
12+
Migration 6650c52670c2 added the locked column with
13+
``server_default='false'``. SQLite has no native BOOLEAN affinity, so the
14+
literal 'false' was stored as TEXT both in the column DEFAULT and on
15+
existing rows backfilled by the ALTER TABLE. SQLAlchemy's Boolean
16+
processor then reads that TEXT back as Python ``True`` (because
17+
``bool('false')`` is ``True`` for any non-empty string), which means any
18+
code that branches on ``User.locked`` sees a locked-out account where
19+
the data should be unlocked.
20+
21+
This was harmless for years because the only writer (the lockout block in
22+
``/authenticate/login``) writes Python ``True``/``False`` (stored as
23+
integer 1/0), and no reader branched on the field. The ``User.is_active``
24+
and ``User.is_locked`` overrides added for the lockout-bypass fix are
25+
the first readers, so they trip over every legacy row.
26+
27+
The data corruption is SQLite-specific. PostgreSQL accepts the string
28+
``'false'`` for a BOOLEAN column and coerces it on insert, so existing
29+
rows on a PostgreSQL config DB already hold proper boolean values and
30+
need no fix. Restrict the data normalization to SQLite — the integer
31+
literals below would also fail on PostgreSQL because the locked column
32+
is BOOLEAN, not INTEGER.
33+
34+
Revision ID: normalize_locked_text_default
35+
Revises: sharedserver_feature_parity
36+
Create Date: 2026-05-05
37+
38+
"""
39+
from alembic import op
40+
41+
42+
# revision identifiers, used by Alembic.
43+
revision = 'normalize_locked_text_default'
44+
down_revision = 'sharedserver_feature_parity'
45+
branch_labels = None
46+
depends_on = None
47+
48+
49+
def upgrade():
50+
bind = op.get_bind()
51+
if bind.dialect.name != 'sqlite':
52+
# On PostgreSQL the locked column was always stored as a proper
53+
# BOOLEAN, so there is nothing to normalize. Skip — UPDATEs with
54+
# integer literals against a BOOLEAN column would otherwise raise
55+
# "column locked is of type boolean but expression is of type
56+
# integer" on PostgreSQL.
57+
return
58+
59+
op.execute(
60+
"UPDATE \"user\" SET locked = 1 "
61+
"WHERE LOWER(CAST(locked AS TEXT)) IN ('true', 't', 'yes', 'y', '1')"
62+
)
63+
op.execute(
64+
"UPDATE \"user\" SET locked = 0 "
65+
"WHERE locked IS NULL OR LOWER(CAST(locked AS TEXT)) NOT IN "
66+
"('true', 't', 'yes', 'y', '1')"
67+
)
68+
69+
70+
def downgrade():
71+
# pgAdmin only upgrades, downgrade not implemented.
72+
pass

‎web/pgadmin/model/__init__.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
made to the config database to upgrade it to the new version.
1919
"""
2020

21+
from flask_babel import gettext
2122
from flask_security import UserMixin, RoleMixin
2223
from flask_sqlalchemy import SQLAlchemy
2324
from sqlalchemy.ext.mutable import MutableDict
@@ -33,7 +34,7 @@
3334
#
3435
##########################################################################
3536

36-
SCHEMA_VERSION = 51
37+
SCHEMA_VERSION = 52
3738

3839
##########################################################################
3940
#
@@ -211,6 +212,28 @@ class User(db.Model, UserMixin):
211212
login_attempts = db.Column(db.Integer, default=0)
212213
locked = db.Column(db.Boolean(), default=False)
213214

215+
@property
216+
def is_active(self):
217+
# Treat a locked account as inactive so Flask-Login's login_user()
218+
# refuses to mint a session, regardless of which view authenticated
219+
# the password. Without this, a lockout set by /authenticate/login
220+
# is bypassed by a direct POST to Flask-Security's /login.
221+
return self.active and not self.locked
222+
223+
def is_locked(self, form_error=None):
224+
# Flask-Security's LoginForm.validate() calls this after password
225+
# verification and treats the return value inverted: True means
226+
# "not locked, proceed"; False means "locked, fail validation".
227+
# The default UserMixin.is_locked unconditionally returns True,
228+
# which is what allows the /login bypass on a locked account.
229+
if self.locked:
230+
if form_error is not None:
231+
form_error.append(gettext(
232+
'Your account is locked. Please contact the '
233+
'Administrator.'))
234+
return False
235+
return True
236+
214237

215238
class Setting(db.Model, UserScopedMixin):
216239
"""Define a setting object"""
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
##########################################################################
2+
#
3+
# pgAdmin 4 - PostgreSQL Tools
4+
#
5+
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
6+
# This software is released under the PostgreSQL Licence
7+
#
8+
##########################################################################
9+
10+
"""Regression tests for User model lockout enforcement.
11+
12+
Flask-Security's default `/login` view (registered by `security.init_app`)
13+
calls `User.is_active` and `User.is_locked()` during form validation.
14+
Without these overrides on pgAdmin's User model, an account that has been
15+
locked via repeated failed attempts at `/authenticate/login` could still
16+
obtain a session by posting valid credentials directly to `/login`.
17+
"""
18+
19+
from pgadmin.utils.route import BaseTestGenerator
20+
from pgadmin.model import User
21+
22+
23+
class TestLockedUser(BaseTestGenerator):
24+
"""Verifies the contract `flask_security.forms.LoginForm.validate()`
25+
relies on:
26+
27+
- `is_active` -> False when the account is deactivated OR locked
28+
(Flask-Login's `login_user()` then refuses the session).
29+
- `is_locked(errors)` -> False when locked, True otherwise; populates
30+
the supplied error list when locked so the form surfaces a message.
31+
"""
32+
33+
# Pure model-contract test - no Postgres server interaction needed.
34+
# Skip BaseTestGenerator.setUp's connect_server() which would otherwise
35+
# make this test depend on a healthy local PG.
36+
def setUp(self):
37+
pass
38+
39+
scenarios = [
40+
('active and not locked: login allowed',
41+
dict(active=True, locked=False,
42+
expect_is_active=True, expect_is_locked=True)),
43+
('active and locked: login blocked',
44+
dict(active=True, locked=True,
45+
expect_is_active=False, expect_is_locked=False)),
46+
('inactive and not locked: login blocked',
47+
dict(active=False, locked=False,
48+
expect_is_active=False, expect_is_locked=True)),
49+
('inactive and locked: login blocked',
50+
dict(active=False, locked=True,
51+
expect_is_active=False, expect_is_locked=False)),
52+
]
53+
54+
def runTest(self):
55+
user = User()
56+
user.active = self.active
57+
user.locked = self.locked
58+
59+
self.assertEqual(
60+
user.is_active, self.expect_is_active,
61+
"is_active expected {0} for active={1}, locked={2}".format(
62+
self.expect_is_active, self.active, self.locked))
63+
64+
form_errors = []
65+
result = user.is_locked(form_errors)
66+
self.assertEqual(
67+
result, self.expect_is_locked,
68+
"is_locked() expected {0} for locked={1}".format(
69+
self.expect_is_locked, self.locked))
70+
71+
if self.locked:
72+
self.assertTrue(
73+
form_errors,
74+
"is_locked() must populate form_error when account is "
75+
"locked so the login form can surface the message")
76+
else:
77+
self.assertFalse(
78+
form_errors,
79+
"is_locked() must not append errors when account is not "
80+
"locked")

0 commit comments

Comments
 (0)