snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
240
zerver/migrations/0209_user_profile_no_empty_password.py
Normal file
240
zerver/migrations/0209_user_profile_no_empty_password.py
Normal file
@@ -0,0 +1,240 @@
|
||||
# Generated by Django 1.11.24 on 2019-10-16 22:48
|
||||
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password, make_password
|
||||
from django.db import migrations
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
from django.db.migrations.state import StateApps
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from zerver.lib.cache import cache_delete, user_profile_by_api_key_cache_key
|
||||
from zerver.lib.queue import queue_json_publish_rollback_unsafe
|
||||
from zerver.lib.utils import generate_api_key
|
||||
|
||||
|
||||
def ensure_no_empty_passwords(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
|
||||
"""With CVE-2019-18933, it was possible for certain users created
|
||||
using social login (e.g. Google/GitHub auth) to have the empty
|
||||
string as their password in the Zulip database, rather than
|
||||
Django's "unusable password" (i.e. no password at all). This was a
|
||||
serious security issue for organizations with both password and
|
||||
Google/GitHub authentication enabled.
|
||||
|
||||
Combined with the code changes to prevent new users from entering
|
||||
this buggy state, this migration sets the intended "no password"
|
||||
state for any users who are in this buggy state, as had been
|
||||
intended.
|
||||
|
||||
While this bug was discovered by our own development team and we
|
||||
believe it hasn't been exploited in the wild, out of an abundance
|
||||
of caution, this migration also resets the personal API keys for
|
||||
all users where Zulip's database-level logging cannot **prove**
|
||||
that user's current personal API key was never accessed using this
|
||||
bug.
|
||||
|
||||
There are a few ways this can be proven: (1) the user's password
|
||||
has never been changed and is not the empty string,
|
||||
or (2) the user's personal API key has changed since that user last
|
||||
changed their password (which is not ''). Both constitute proof
|
||||
because this bug cannot be used to gain the access required to change
|
||||
or reset a user's password.
|
||||
|
||||
Resetting those API keys has the effect of logging many users out
|
||||
of the Zulip mobile and terminal apps unnecessarily (e.g. because
|
||||
the user changed their password at any point in the past, even
|
||||
though the user never was affected by the bug), but we're
|
||||
comfortable with that cost for ensuring that this bug is
|
||||
completely fixed.
|
||||
|
||||
To avoid this inconvenience for self-hosted servers which don't
|
||||
even have EmailAuthBackend enabled, we skip resetting any API keys
|
||||
if the server doesn't have EmailAuthBackend configured.
|
||||
"""
|
||||
|
||||
UserProfile = apps.get_model("zerver", "UserProfile")
|
||||
RealmAuditLog = apps.get_model("zerver", "RealmAuditLog")
|
||||
|
||||
# Because we're backporting this migration to the Zulip 2.0.x
|
||||
# series, we've given it migration number 0209, which is a
|
||||
# duplicate with an existing migration already merged into Zulip
|
||||
# main. Migration 0247_realmauditlog_event_type_to_int.py
|
||||
# changes the format of RealmAuditLog.event_type, so we need the
|
||||
# following conditional block to determine what values to use when
|
||||
# searching for the relevant events in that log.
|
||||
event_type_class = RealmAuditLog._meta.get_field("event_type").get_internal_type()
|
||||
if event_type_class == "CharField":
|
||||
USER_PASSWORD_CHANGED: int | str = "user_password_changed"
|
||||
USER_API_KEY_CHANGED: int | str = "user_api_key_changed"
|
||||
else:
|
||||
USER_PASSWORD_CHANGED = 122
|
||||
USER_API_KEY_CHANGED = 127
|
||||
|
||||
# First, we do some bulk queries to collect data we'll find useful
|
||||
# in the loop over all users below.
|
||||
|
||||
# Users who changed their password at any time since account
|
||||
# creation. These users could theoretically have started with an
|
||||
# empty password, but set a password later via the password reset
|
||||
# flow. If their API key has changed since they changed their
|
||||
# password, we can prove their current API key cannot have been
|
||||
# exposed; we store those users in
|
||||
# password_change_user_ids_no_reset_needed.
|
||||
password_change_user_ids = set(
|
||||
RealmAuditLog.objects.filter(event_type=USER_PASSWORD_CHANGED).values_list(
|
||||
"modified_user_id", flat=True
|
||||
)
|
||||
)
|
||||
password_change_user_ids_api_key_reset_needed: set[int] = set()
|
||||
password_change_user_ids_no_reset_needed: set[int] = set()
|
||||
|
||||
for user_id in password_change_user_ids:
|
||||
# Here, we check the timing for users who have changed
|
||||
# their password.
|
||||
|
||||
# We check if the user changed their API key since their first password change.
|
||||
query = RealmAuditLog.objects.filter(
|
||||
modified_user=user_id,
|
||||
event_type__in=[USER_PASSWORD_CHANGED, USER_API_KEY_CHANGED],
|
||||
).order_by("event_time")
|
||||
|
||||
earliest_password_change = query.filter(event_type=USER_PASSWORD_CHANGED).first()
|
||||
# Since these users are in password_change_user_ids, this must not be None.
|
||||
assert earliest_password_change is not None
|
||||
|
||||
latest_api_key_change = query.filter(event_type=USER_API_KEY_CHANGED).last()
|
||||
if latest_api_key_change is None:
|
||||
# This user has never changed their API key. As a
|
||||
# result, even though it's very likely this user never
|
||||
# had an empty password, they have changed their
|
||||
# password, and we have no record of the password's
|
||||
# original hash, so we can't prove the user's API key
|
||||
# was never affected. We schedule this user's API key
|
||||
# to be reset.
|
||||
password_change_user_ids_api_key_reset_needed.add(user_id)
|
||||
elif earliest_password_change.event_time <= latest_api_key_change.event_time:
|
||||
# This user has changed their password before
|
||||
# generating their current personal API key, so we can
|
||||
# prove their current personal API key could not have
|
||||
# been exposed by this bug.
|
||||
password_change_user_ids_no_reset_needed.add(user_id)
|
||||
else:
|
||||
password_change_user_ids_api_key_reset_needed.add(user_id)
|
||||
|
||||
if password_change_user_ids_no_reset_needed and settings.PRODUCTION:
|
||||
# We record in this log file users whose current API key was
|
||||
# generated after a real password was set, so there's no need
|
||||
# to reset their API key, but because they've changed their
|
||||
# password, we don't know whether or not they originally had a
|
||||
# buggy password.
|
||||
#
|
||||
# In theory, this list can be recalculated using the above
|
||||
# algorithm modified to only look at events before the time
|
||||
# this migration was installed, but it's helpful to log it as well.
|
||||
with open("/var/log/zulip/0209_password_migration.log", "w") as log_file:
|
||||
line = "No reset needed, but changed password: {}\n"
|
||||
log_file.write(line.format(password_change_user_ids_no_reset_needed))
|
||||
|
||||
AFFECTED_USER_TYPE_EMPTY_PASSWORD = "empty_password"
|
||||
AFFECTED_USER_TYPE_CHANGED_PASSWORD = "changed_password"
|
||||
MIGRATION_ID = "0209_user_profile_no_empty_password"
|
||||
|
||||
def write_realm_audit_log_entry(
|
||||
user_profile: Any, event_time: Any, event_type: Any, affected_user_type: str
|
||||
) -> None:
|
||||
RealmAuditLog.objects.create(
|
||||
realm=user_profile.realm,
|
||||
modified_user=user_profile,
|
||||
event_type=event_type,
|
||||
event_time=event_time,
|
||||
extra_data=orjson.dumps(
|
||||
{
|
||||
"migration_id": MIGRATION_ID,
|
||||
"affected_user_type": affected_user_type,
|
||||
}
|
||||
).decode(),
|
||||
)
|
||||
|
||||
# If Zulip's built-in password authentication is not enabled on
|
||||
# the server level, then we plan to skip resetting any users' API
|
||||
# keys, since the bug requires EmailAuthBackend.
|
||||
email_auth_enabled = "zproject.backends.EmailAuthBackend" in settings.AUTHENTICATION_BACKENDS
|
||||
|
||||
# A quick note: This query could in theory exclude users with
|
||||
# is_active=False, is_bot=True, or realm__deactivated=True here to
|
||||
# accessing only active human users in non-deactivated realms.
|
||||
# But it's better to just be thorough; users can be reactivated,
|
||||
# and e.g. a server admin could manually edit the database to
|
||||
# change a bot into a human user if they really wanted to. And
|
||||
# there's essentially no harm in rewriting state for a deactivated
|
||||
# account.
|
||||
for user_profile in UserProfile.objects.all().iterator():
|
||||
event_time = timezone_now()
|
||||
if check_password("", user_profile.password):
|
||||
# This user currently has the empty string as their password.
|
||||
|
||||
# Change their password and record that we did so.
|
||||
user_profile.password = make_password(None)
|
||||
update_fields = ["password"]
|
||||
write_realm_audit_log_entry(
|
||||
user_profile, event_time, USER_PASSWORD_CHANGED, AFFECTED_USER_TYPE_EMPTY_PASSWORD
|
||||
)
|
||||
|
||||
if email_auth_enabled and not user_profile.is_bot:
|
||||
# As explained above, if the built-in password authentication
|
||||
# is enabled, reset the API keys. We can skip bot accounts here,
|
||||
# because the `password` attribute on a bot user is useless.
|
||||
reset_user_api_key(user_profile)
|
||||
update_fields.append("api_key")
|
||||
|
||||
event_time = timezone_now()
|
||||
write_realm_audit_log_entry(
|
||||
user_profile,
|
||||
event_time,
|
||||
USER_API_KEY_CHANGED,
|
||||
AFFECTED_USER_TYPE_EMPTY_PASSWORD,
|
||||
)
|
||||
|
||||
user_profile.save(update_fields=update_fields)
|
||||
continue
|
||||
|
||||
elif (
|
||||
email_auth_enabled and user_profile.id in password_change_user_ids_api_key_reset_needed
|
||||
):
|
||||
# For these users, we just need to reset the API key.
|
||||
reset_user_api_key(user_profile)
|
||||
user_profile.save(update_fields=["api_key"])
|
||||
|
||||
write_realm_audit_log_entry(
|
||||
user_profile, event_time, USER_API_KEY_CHANGED, AFFECTED_USER_TYPE_CHANGED_PASSWORD
|
||||
)
|
||||
|
||||
|
||||
def reset_user_api_key(user_profile: Any) -> None:
|
||||
old_api_key = user_profile.api_key
|
||||
user_profile.api_key = generate_api_key()
|
||||
cache_delete(user_profile_by_api_key_cache_key(old_api_key))
|
||||
|
||||
# Like with any API key change, we need to clear any server-side
|
||||
# state for sending push notifications to mobile app clients that
|
||||
# could have been registered with the old API key. Fortunately,
|
||||
# we can just write to the queue processor that handles sending
|
||||
# those notices to the push notifications bouncer service.
|
||||
event = {"type": "clear_push_device_tokens", "user_profile_id": user_profile.id}
|
||||
queue_json_publish_rollback_unsafe("deferred_work", event)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
("zerver", "0208_add_realm_night_logo_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
ensure_no_empty_passwords, reverse_code=migrations.RunPython.noop, elidable=True
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user