Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Code
5efd69d77d feat(security): enforce password complexity policy [DRAFT]
SupersetSecurityManager did not enforce any password policy, so DB-auth
self-registration / password changes accepted trivially short or common
passwords (ASVS 6.2.1 / 6.2.4, CWE-521).

Add superset.security.password_complexity.validate_password_complexity (minimum
length via AUTH_PASSWORD_MIN_LENGTH, default 8, plus a common-password
blocklist extendable via AUTH_PASSWORD_COMMON_BLOCKLIST), and wire it through
Flask-AppBuilder's FAB_PASSWORD_COMPLEXITY_ENABLED / _VALIDATOR. FAB runs this
callable from both the WTForms password fields (self-registration, user edit,
reset password) and the User REST API, so one function covers all
password-setting flows. The policy is intentionally less draconian than FAB's
built-in default_password_complexity.

DRAFT: enabling the policy by default changes registration / password-change
behavior (short or common passwords are now rejected) and any API-driven user
provisioning that used weak passwords. Needs validation of the end-to-end flows
before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:03:37 -07:00
3 changed files with 180 additions and 0 deletions

View File

@@ -343,6 +343,20 @@ RATELIMIT_ENABLED = os.environ.get("SUPERSET_ENV") == "production"
RATELIMIT_APPLICATION = "50 per second"
AUTH_RATE_LIMITED = True
AUTH_RATE_LIMIT = "5 per second"
# Password complexity policy, enforced (via Flask-AppBuilder) across
# self-registration, the user edit/reset forms, and the User REST API.
# The Superset validator requires a minimum length and rejects common
# passwords; tune via AUTH_PASSWORD_MIN_LENGTH / AUTH_PASSWORD_COMMON_BLOCKLIST,
# or replace FAB_PASSWORD_COMPLEXITY_VALIDATOR with your own callable.
from superset.security.password_complexity import ( # noqa: E402
validate_password_complexity as _validate_password_complexity,
)
FAB_PASSWORD_COMPLEXITY_ENABLED = True
FAB_PASSWORD_COMPLEXITY_VALIDATOR = _validate_password_complexity
AUTH_PASSWORD_MIN_LENGTH = 8
AUTH_PASSWORD_COMMON_BLOCKLIST: list[str] = []
# A storage location conforming to the scheme in storage-scheme. See the limits
# library for allowed values: https://limits.readthedocs.io/en/stable/storage.html
# RATELIMIT_STORAGE_URI = "redis://host:port"

View File

@@ -0,0 +1,105 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Superset password-complexity validator.
Wired in via ``FAB_PASSWORD_COMPLEXITY_VALIDATOR`` (with
``FAB_PASSWORD_COMPLEXITY_ENABLED``). Flask-AppBuilder runs this callable from
both the WTForms password fields (self-registration, user edit, reset password)
and the User REST API, so a single function enforces the policy across all
password-setting flows.
The default policy is a minimum length plus a common-password blocklist —
intentionally less draconian than FAB's built-in ``default_password_complexity``
(which requires 2 uppercase, 1 special, 2 digits, 3 lowercase and length 10).
"""
from __future__ import annotations
from flask import current_app
from flask_appbuilder.exceptions import PasswordComplexityValidationError
from flask_babel import gettext as __
# A small built-in blocklist of the most common/guessable passwords. Operators
# can extend it with AUTH_PASSWORD_COMMON_BLOCKLIST. (A fuller list or a
# Have-I-Been-Pwned k-anonymity check is a possible follow-up.)
COMMON_PASSWORDS: frozenset[str] = frozenset(
{
"123456",
"123456789",
"12345678",
"1234567890",
"12345",
"111111",
"123123",
"000000",
"password",
"password1",
"password123",
"passw0rd",
"qwerty",
"qwerty123",
"qwertyuiop",
"abc123",
"letmein",
"welcome",
"welcome1",
"admin",
"admin123",
"administrator",
"root",
"superset",
"changeme",
"iloveyou",
"monkey",
"dragon",
"sunshine",
"princess",
"football",
"baseball",
"trustno1",
"login",
"master",
"hello123",
"secret",
"default",
}
)
DEFAULT_MIN_LENGTH = 8
def validate_password_complexity(password: str) -> None:
"""Validate a plaintext password against the configured policy.
:raises PasswordComplexityValidationError: if the password is too short or
appears in the common-password blocklist.
"""
min_length = current_app.config.get("AUTH_PASSWORD_MIN_LENGTH", DEFAULT_MIN_LENGTH)
if len(password) < min_length:
raise PasswordComplexityValidationError(
__(
"Password must be at least %(min_length)s characters long.",
min_length=min_length,
)
)
extra = current_app.config.get("AUTH_PASSWORD_COMMON_BLOCKLIST") or []
blocklist = COMMON_PASSWORDS | {str(item).lower() for item in extra}
if password.lower() in blocklist:
raise PasswordComplexityValidationError(
__("This password is too common; please choose a less guessable one.")
)

View File

@@ -0,0 +1,61 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import pytest
from flask import current_app
from flask_appbuilder.exceptions import PasswordComplexityValidationError
from superset.security.password_complexity import validate_password_complexity
def test_validate_password_complexity_accepts_strong_password() -> None:
# No exception for a sufficiently long, uncommon password.
validate_password_complexity("a-Good-Long-Passphrase-42")
def test_validate_password_complexity_rejects_short_password() -> None:
with pytest.raises(PasswordComplexityValidationError):
validate_password_complexity("short1") # < 8 chars
def test_validate_password_complexity_rejects_common_password() -> None:
# Common even though length >= 8.
with pytest.raises(PasswordComplexityValidationError):
validate_password_complexity("password123")
# Case-insensitive.
with pytest.raises(PasswordComplexityValidationError):
validate_password_complexity("PASSWORD123")
def test_validate_password_complexity_honors_configured_min_length() -> None:
original = current_app.config.get("AUTH_PASSWORD_MIN_LENGTH")
current_app.config["AUTH_PASSWORD_MIN_LENGTH"] = 16
try:
with pytest.raises(PasswordComplexityValidationError):
validate_password_complexity("only12chars!") # 12 < 16
finally:
current_app.config["AUTH_PASSWORD_MIN_LENGTH"] = original
def test_validate_password_complexity_honors_extra_blocklist() -> None:
original = current_app.config.get("AUTH_PASSWORD_COMMON_BLOCKLIST")
current_app.config["AUTH_PASSWORD_COMMON_BLOCKLIST"] = ["AcmeCorp2024"]
try:
with pytest.raises(PasswordComplexityValidationError):
validate_password_complexity("acmecorp2024")
finally:
current_app.config["AUTH_PASSWORD_COMMON_BLOCKLIST"] = original