langgenius / langgenius/dify

Change-email duplicate guards compare Account.email case-sensitively, allowing two accounts for one mailbox

Open
#41,096 0 comments 2 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
156k
Forks
24.6k
Avg merge
20h 50m
Merged PRs (30d)
586

Description

### Self Checks

- [x] I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
- [x] This is only for bug report, if you would like to ask a question, please head to [Discussions](https://github.com/langgenius/dify/discussions/categories/general).
- [x] I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
- [x] I confirm that I am using English to submit this report, otherwise it will be closed.
- [x] 【中文用户 & Non English User】请使用英语提交,否则会被关闭 :)
- [x] Please do not modify this template :) and fill in all the required fields.

### Dify version

main (`07119e3f00`). The change-email repository path was introduced in #40439 (2026-08-21); `email_exists` predates it.

### Cloud or Self Hosted

Cloud, Self Hosted (Docker), Self Hosted (Source) — the drift is in the shipped code, so it affects every deployment that carries legacy mixed-case `account.email` rows.

### Steps to reproduce

1. Have an account whose stored `account.email` is not all-lowercase — e.g. `Taken@Example.com`. Registration only started normalizing in #29978 (`chore: case insensitive email`, 2026-01-13), and that PR shipped no data migration, so rows created before it keep whatever casing the user typed. `AccountService.get_account_by_email_with_case_fallback` exists precisely for them, documented as keeping *"backward compatibility for older records that stored uppercase emails while the rest of the system gradually normalizes new inputs"* (`api/services/account_service.py:956-967`).
2. From a second account, complete the change-email flow (`/console/api/account/change-email/...`) targeting `taken@example.com`.
3. The reset succeeds and a second `Account` row is written for the same mailbox.

Reproduced as a repository-level unit test against the shipped SQLite test schema:

```python
def test_reset_email_rejects_case_variant_of_another_account_email(
sqlite_session: Session,
sqlite_session_factory: sessionmaker[Session],
) -> None:
_persist_account(sqlite_session) # account-1, account@example.com
_persist_account(sqlite_session, account_id="account-2", email="Taken@Example.com")
repository = SQLAlchemyAccountRepository(sqlite_session_factory)

result = repository.reset_email(
"account-1",
expected_old_email="account@example.com",
new_email="taken@example.com",
)

assert result.status == AccountEmailResetStatus.EMAIL_IN_USE
```

```
E AssertionError: assert ==
E - email_in_use
E + updated
```

### ✔️ Expected Behavior

The duplicate-email guard on the change-email path folds case, consistent with every neighbouring check:

- `SQLAlchemyAccountRepository.reset_email` already compares the *old* address case-insensitively — `account.email.lower() != expected_old_email.lower()` (`api/repositories/account_repository.py:134`).
- `AccountService.has_active_account_with_email` uses `func.lower(Account.email) == normalized` and is documented as *"the case-insensitive existence check that backs the SSO collision rule"* (`api/services/account_service.py:290-301`).

### ❌ Actual Behavior

Two lookups in `api/repositories/account_repository.py` compare `Account.email` **case-sensitively**, so an address that differs only in case is not seen as taken:

1. `email_exists` (line 120):
```python
return session.scalar(select(Account.id).where(Account.email == email).limit(1)) is not None
```
Its only production caller is `AccountChangeEmailService.ensure_available` (`api/services/account_change_email_service.py:200`), the pre-check behind the change-email validity endpoint.

2. The duplicate guard inside `reset_email` (line 136), three lines below the case-insensitive `expected_old_email` comparison:
```python
if session.scalar(select(Account.id).where(Account.email == new_email).limit(1)) is not None:
return AccountEmailResetResult(status=AccountEmailResetStatus.EMAIL_IN_USE)
```

`Account.email` carries no unique constraint — `__table_args__` is `PrimaryKeyConstraint("id")` plus a non-unique `Index("account_email_idx", "email")` (`api/models/account.py:91`), and the initial migration creates it with `unique=False` (`api/migrations/versions/64b051264f32_init.py:99`) — so these two application-level checks are the only thing preventing a duplicate on this path. Under PostgreSQL's default collation `=` on `varchar` is case-sensitive, and SQLite behaves the same for `TEXT`, so the guard misses `Taken@Example.com` when asked about `taken@example.com`.

Result: two `Account` rows for one real mailbox. Downstream, `has_active_account_with_email` then reports a collision, and `get_account_by_email_with_case_fallback` can resolve either row.

Scope note, so the severity is not overstated: every current write path normalizes before insert (`email_register.py:99`, `login.py:334`, `oauth.py:319`, `SetupService.initialize:77`, `commands/account.py`), and the change-email flow still requires a verification code delivered to the target mailbox — mailbox delivery is itself case-insensitive, so the actor must already control that mailbox. This is not an account-takeover, and it is only reachable on deployments carrying pre-#29978 mixed-case rows. It is a data-integrity and consistency defect, not a privilege issue.

Suggested fix: use `func.lower(Account.email) == email.lower()` in both places, and exclude the account's own row in `reset_email` so a user normalizing their own stored casing (`Foo@X.com` → `foo@x.com`) is not reported as colliding with themselves.

I have a fix with regression tests ready and will open a PR referencing this issue.

Contributor guide

Open the contributing guide

Research direction

Start with email_exists and reset_email in api/repositories/account_repository.py, then inspect their caller AccountChangeEmailService.ensure_available and the existing case-insensitive checks in AccountService. Reproduce the shown SQLite regression test and verify both change-email checks treat case variants as in use while allowing a user to normalize their own address.

Written by the indexing model from the issue text.

Assessment

Tech stack
postgresql, python, sqlalchemy, sqlite
Domain
authentication, backend, databases
Issue type
Bug
Difficulty
2/5
Estimated time
Half a day
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.