mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-07-19 22:20:19 -04:00
fix: harden LDAP and OIDC authentication providers (#7902)
This commit is contained in:
@@ -51,6 +51,7 @@ test('oidc initial login', async ({ page }) => {
|
||||
const claims = {
|
||||
"sub": username,
|
||||
"email": `${username}@example.com`,
|
||||
"email_verified": true,
|
||||
"preferred_username": username,
|
||||
"name": name,
|
||||
"groups": ["user"]
|
||||
@@ -73,6 +74,7 @@ test('oidc login with user not in propery group', async ({ page }) => {
|
||||
const claims = {
|
||||
"sub": username,
|
||||
"email": `${username}@example.com`,
|
||||
"email_verified": true,
|
||||
"preferred_username": username,
|
||||
"name": name,
|
||||
"groups": []
|
||||
@@ -93,6 +95,7 @@ test('oidc sequential login', async ({ page }) => {
|
||||
const claims = {
|
||||
"sub": username,
|
||||
"email": `${username}@example.com`,
|
||||
"email_verified": true,
|
||||
"preferred_username": username,
|
||||
"name": name,
|
||||
"groups": ["user"]
|
||||
@@ -121,6 +124,7 @@ test('settings page verify oidc', async ({ page }) => {
|
||||
const claims = {
|
||||
"sub": username,
|
||||
"email": `${username}@example.com`,
|
||||
"email_verified": true,
|
||||
"preferred_username": username,
|
||||
"name": name,
|
||||
"groups": ["user"]
|
||||
@@ -156,6 +160,7 @@ test('oidc admin user', async ({ page }) => {
|
||||
const claims = {
|
||||
"sub": username,
|
||||
"email": `${username}@example.com`,
|
||||
"email_verified": true,
|
||||
"preferred_username": username,
|
||||
"name": name,
|
||||
"groups": ["user", "admin"]
|
||||
|
||||
@@ -53,6 +53,7 @@ def test_missing_groups_claim(monkeypatch: MonkeyPatch):
|
||||
data = {
|
||||
"preferred_username": "dude1",
|
||||
"email": "email@email.com",
|
||||
"email_verified": True,
|
||||
"name": "Firstname Lastname",
|
||||
}
|
||||
auth_provider = OpenIDProvider(None, data)
|
||||
@@ -68,6 +69,7 @@ def test_missing_groups_claim_admin(monkeypatch: MonkeyPatch):
|
||||
data = {
|
||||
"preferred_username": "dude1",
|
||||
"email": "email@email.com",
|
||||
"email_verified": True,
|
||||
"name": "Firstname Lastname",
|
||||
}
|
||||
auth_provider = OpenIDProvider(None, data)
|
||||
@@ -83,6 +85,7 @@ def test_missing_groups_claim_with_default(monkeypatch: MonkeyPatch):
|
||||
data = {
|
||||
"preferred_username": "dude1",
|
||||
"email": "email@email.com",
|
||||
"email_verified": True,
|
||||
"name": "Firstname Lastname",
|
||||
}
|
||||
auth_provider = OpenIDProvider(None, data, True)
|
||||
@@ -97,6 +100,7 @@ def test_missing_groups_claim_admin_group_with_default(monkeypatch: MonkeyPatch,
|
||||
data = {
|
||||
"preferred_username": "dude1",
|
||||
"email": unique_user.email,
|
||||
"email_verified": True,
|
||||
"name": "Firstname Lastname",
|
||||
}
|
||||
auth_provider = OpenIDProvider(unique_user.repos.session, data, True)
|
||||
@@ -111,6 +115,7 @@ def test_missing_user_group(monkeypatch: MonkeyPatch):
|
||||
data = {
|
||||
"preferred_username": "dude1",
|
||||
"email": "email@email.com",
|
||||
"email_verified": True,
|
||||
"name": "Firstname Lastname",
|
||||
"groups": ["not_mealie_user"],
|
||||
}
|
||||
@@ -126,6 +131,7 @@ def test_has_user_group_existing_user(monkeypatch: MonkeyPatch, unique_user: Tes
|
||||
data = {
|
||||
"preferred_username": "dude1",
|
||||
"email": unique_user.email,
|
||||
"email_verified": True,
|
||||
"name": "Firstname Lastname",
|
||||
"groups": ["mealie_user"],
|
||||
}
|
||||
@@ -142,6 +148,7 @@ def test_has_admin_group_existing_user(monkeypatch: MonkeyPatch, unique_user: Te
|
||||
data = {
|
||||
"preferred_username": "dude1",
|
||||
"email": unique_user.email,
|
||||
"email_verified": True,
|
||||
"name": "Firstname Lastname",
|
||||
"groups": ["mealie_admin"],
|
||||
}
|
||||
@@ -158,6 +165,7 @@ def test_has_user_group_new_user(monkeypatch: MonkeyPatch, session: Session):
|
||||
data = {
|
||||
"preferred_username": "dude1",
|
||||
"email": "dude1@email.com",
|
||||
"email_verified": True,
|
||||
"name": "Firstname Lastname",
|
||||
"groups": ["mealie_user"],
|
||||
}
|
||||
@@ -179,6 +187,7 @@ def test_has_admin_group_new_user(monkeypatch: MonkeyPatch, session: Session):
|
||||
data = {
|
||||
"preferred_username": "dude2",
|
||||
"email": "dude2@email.com",
|
||||
"email_verified": True,
|
||||
"name": "Firstname Lastname",
|
||||
"groups": ["mealie_admin"],
|
||||
}
|
||||
@@ -208,6 +217,7 @@ def test_ldap_user_creation_invalid_group_or_household(
|
||||
data = {
|
||||
"preferred_username": random_string(),
|
||||
"email": random_email(),
|
||||
"email_verified": True,
|
||||
"name": random_string(),
|
||||
"groups": ["mealie_user"],
|
||||
}
|
||||
@@ -227,11 +237,92 @@ def test_ldap_user_creation_invalid_group_or_household(
|
||||
assert user is None
|
||||
|
||||
|
||||
def test_claims_logging(caplog, session: Session):
|
||||
def test_rejects_unverified_email(monkeypatch: MonkeyPatch, session: Session):
|
||||
monkeypatch.setenv("OIDC_REQUIRES_EMAIL_VERIFICATION", "true")
|
||||
get_app_settings.cache_clear()
|
||||
|
||||
data = {
|
||||
"preferred_username": random_string(),
|
||||
"email": random_email(),
|
||||
"email_verified": False,
|
||||
"name": random_string(),
|
||||
}
|
||||
auth_provider = OpenIDProvider(session, data)
|
||||
|
||||
with pytest.raises(MissingClaimException):
|
||||
auth_provider.authenticate()
|
||||
|
||||
|
||||
def test_rejects_missing_email_verified(monkeypatch: MonkeyPatch, session: Session):
|
||||
monkeypatch.setenv("OIDC_REQUIRES_EMAIL_VERIFICATION", "true")
|
||||
get_app_settings.cache_clear()
|
||||
|
||||
data = {
|
||||
"preferred_username": random_string(),
|
||||
"email": random_email(),
|
||||
"name": random_string(),
|
||||
}
|
||||
auth_provider = OpenIDProvider(session, data)
|
||||
|
||||
with pytest.raises(MissingClaimException):
|
||||
auth_provider.authenticate()
|
||||
|
||||
|
||||
def test_does_not_adopt_existing_account_with_unverified_email(monkeypatch: MonkeyPatch, unique_user: TestUser):
|
||||
"""An unverified email must not be able to log into (take over) an existing account."""
|
||||
monkeypatch.setenv("OIDC_REQUIRES_EMAIL_VERIFICATION", "true")
|
||||
get_app_settings.cache_clear()
|
||||
|
||||
data = {
|
||||
"preferred_username": random_string(),
|
||||
"email": unique_user.email,
|
||||
"email_verified": False,
|
||||
"name": random_string(),
|
||||
}
|
||||
auth_provider = OpenIDProvider(unique_user.repos.session, data)
|
||||
|
||||
with pytest.raises(MissingClaimException):
|
||||
auth_provider.authenticate()
|
||||
|
||||
|
||||
def test_adopts_existing_account_with_verified_email(monkeypatch: MonkeyPatch, unique_user: TestUser):
|
||||
"""A verified email legitimately links to an existing (non-OIDC) account."""
|
||||
monkeypatch.setenv("OIDC_REQUIRES_EMAIL_VERIFICATION", "true")
|
||||
get_app_settings.cache_clear()
|
||||
|
||||
data = {
|
||||
"preferred_username": random_string(),
|
||||
"email": unique_user.email,
|
||||
"email_verified": True,
|
||||
"name": random_string(),
|
||||
}
|
||||
auth_provider = OpenIDProvider(unique_user.repos.session, data)
|
||||
|
||||
assert auth_provider.authenticate() is not None
|
||||
|
||||
|
||||
def test_allows_unverified_email_when_verification_disabled(monkeypatch: MonkeyPatch, session: Session):
|
||||
monkeypatch.setenv("OIDC_REQUIRES_EMAIL_VERIFICATION", "false")
|
||||
get_app_settings.cache_clear()
|
||||
|
||||
data = {
|
||||
"preferred_username": random_string(),
|
||||
"email": random_email(),
|
||||
"name": random_string(),
|
||||
}
|
||||
auth_provider = OpenIDProvider(session, data)
|
||||
|
||||
assert auth_provider.authenticate() is not None
|
||||
|
||||
|
||||
def test_claims_logging(monkeypatch: MonkeyPatch, caplog, session: Session):
|
||||
monkeypatch.setenv("OIDC_REQUIRES_EMAIL_VERIFICATION", "true")
|
||||
get_app_settings.cache_clear()
|
||||
caplog.set_level(logging.DEBUG)
|
||||
data = {
|
||||
"preferred_username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"email_verified": True,
|
||||
"name": "Test User",
|
||||
"groups": ["mealie_user"],
|
||||
}
|
||||
|
||||
@@ -344,3 +344,66 @@ def test_user_login_ldap_auth_method(monkeypatch: MonkeyPatch, ldap_user: Privat
|
||||
assert result.full_name == ldap_user.full_name
|
||||
assert result.admin == ldap_user.admin
|
||||
assert result.auth_method == AuthMethod.LDAP
|
||||
|
||||
|
||||
class PermissiveBindConnMock(LdapConnMock):
|
||||
"""Simulates a directory that accepts a bind with a valid DN and an empty
|
||||
password (anonymous/unauthenticated bind) as success."""
|
||||
|
||||
def simple_bind_s(self, dn, bind_pw):
|
||||
if bind_pw == "":
|
||||
return
|
||||
return super().simple_bind_s(dn, bind_pw)
|
||||
|
||||
|
||||
class CapturingConnMock(LdapConnMock):
|
||||
"""Records the search filter passed to search_s and returns no matches."""
|
||||
|
||||
captured_filter: str | None = None
|
||||
|
||||
def search_s(self, dn, scope, filter, attrlist):
|
||||
if filter != self.app_settings.LDAP_ADMIN_FILTER:
|
||||
CapturingConnMock.captured_filter = filter
|
||||
return []
|
||||
|
||||
|
||||
def test_ldap_rejects_empty_password(monkeypatch: MonkeyPatch):
|
||||
user, mail, name, _, query_bind, query_password = setup_env(monkeypatch)
|
||||
|
||||
def ldap_initialize_mock(url):
|
||||
# Even against a directory that would accept the empty-password bind,
|
||||
# the provider must refuse before binding.
|
||||
return PermissiveBindConnMock(user, "", False, query_bind, query_password, mail, name)
|
||||
|
||||
monkeypatch.setattr(ldap, "initialize", ldap_initialize_mock)
|
||||
|
||||
get_app_settings.cache_clear()
|
||||
|
||||
with session_context() as session:
|
||||
provider = get_provider(session, user, "")
|
||||
result = provider.get_user()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_ldap_escapes_username_in_search_filter(monkeypatch: MonkeyPatch):
|
||||
_, mail, name, password, query_bind, query_password = setup_env(monkeypatch)
|
||||
injected_username = "a*)(uid=*"
|
||||
CapturingConnMock.captured_filter = None
|
||||
|
||||
def ldap_initialize_mock(url):
|
||||
return CapturingConnMock(injected_username, password, False, query_bind, query_password, mail, name)
|
||||
|
||||
monkeypatch.setattr(ldap, "initialize", ldap_initialize_mock)
|
||||
|
||||
get_app_settings.cache_clear()
|
||||
|
||||
with session_context() as session:
|
||||
provider = get_provider(session, injected_username, password)
|
||||
provider.get_user()
|
||||
|
||||
captured = CapturingConnMock.captured_filter
|
||||
assert captured is not None
|
||||
# Metacharacters from the username must be escaped, never present as raw syntax.
|
||||
assert "\\2a" in captured # '*' -> \2a
|
||||
assert "*" not in captured
|
||||
|
||||
Reference in New Issue
Block a user