morningman commented on PR #61440:
URL: https://github.com/apache/doris/pull/61440#issuecomment-5295017567
## 1. [Blocking] Remove the accidentally committed empty file `git`
A zero-byte file named `git` (blob `e69de29`, the empty object) was added at
the repository root. It has no ASF license header, and **it is the sole cause
of the failing License Check job**:
```
INFO Totally checked 40193 files, valid: 9, invalid: 1, ignored: 40183,
fixed: 0
WARNING Failed to determine the comment style of file: git
ERROR the following files don't have a valid license header:
git
```
This looks like a mistyped command (something along the lines of `git> git
status`) that later got swept up by `git add .`. `AGENTS.md` also requires that
a commit contain only files related to the change at hand.
```bash
git rm git && git commit --amend --no-edit
```
Every other CI job — CheckStyle, FE UT, P0 Regression, Cloud, Performance —
is green, so this one-line fix turns the whole board green.
## 2. [Blocking] Settle the default value with the maintainers, and add the
warning that was asked for
CalvinKirs asked for the default to be `false`:
> Default should be false. Defaulting to true keeps the vulnerable behavior
— insecure by default for a security fix. Both Spring Security and Doris reject
empty passwords unconditionally. Suggest defaulting false with true as explicit
opt-in; if it stays true, please flag the risk loudly in ldap.conf.
You replied with the backward-compatibility rationale and offered to change
it if that is the call. That thread never converged, and the code still
defaults to `true` — worth resolving before this moves forward.
I lean toward CalvinKirs's position, for reasons that go beyond "secure by
default":
1. **The legacy and plugin paths disagree.** The 4.1+ plugin path rejects
empty passwords unconditionally. Defaulting to `true` means a cluster's
behavior changes abruptly on upgrade to 4.1, with no warning to the operator
while they are still on 4.0. Defaulting to `false` aligns the two paths and
makes the upgrade path smooth.
2. **A missing `conf/ldap.conf` also lands on the default.**
`DorisFE.java:157` is `if (new File(.../ldap.conf).exists())` — if the file is
absent, the whole load is skipped. So deployments with no explicit
configuration get the most permissive value.
3. Your compatibility concern about the backports is real. A middle ground:
**default to `false` on master/4.1, keep `true` on the 3.1.x/4.0.x backports**,
and call it out prominently in the release note. That avoids breaking existing
deployments on a patch release without carrying an insecure default into the
new version.
Whichever value wins, the loud warning in `ldap.conf` that CalvinKirs asked
for is **still missing**. `conf/ldap.conf:53-54` currently reads as a neutral
description, and it also does not mention that the flag is legacy-only — which
is the point that took the most explaining in the PR discussion.
```diff
-## ldap_allow_empty_pass - allow to connect to ldap with empty pass
(enabled by default)
-# ldap_allow_empty_pass = true
+## ldap_allow_empty_pass - allow LDAP users to log in with an empty
password.
+##
+## SECURITY WARNING: LDAP treats a bind with a non-empty DN and an empty
password as an
+## unauthenticated bind and normally reports it as successful. Leaving this
enabled means
+## anyone who knows a valid LDAP user name can log in to Doris without a
password.
+## Set this to false unless you have a specific reason to keep the legacy
behaviour.
+##
+## This setting applies to the legacy LDAP authentication path only. The
plugin-based
+## LDAP authentication introduced in 4.1 always rejects empty passwords.
+# ldap_allow_empty_pass = true
```
## 3. [High] Drop `ERR_EMPTY_PASSWORD`, or actually report it to the client
Right now it falls between two stools: a new `ErrorCode` enum constant was
added, but it is only ever used as a format string inside two `LOG.debug` calls
and is **never reported to any client**. That carries three costs:
1. **It permanently consumes a public error code.** `ErrorCode` is part of
Doris's user-facing error contract; once 6001 is taken it is awkward to
reclaim. Spending an error code on a log message is a poor trade.
2. **The message text teaches users to weaken security.** `"Set
ldap_allow_empty_pass=true to allow."` — if this ever does get reported, it
discloses a server-side configuration key to an unauthenticated client and
tells them how to disable the protection. (The wording came from a bot
suggestion, but that suggestion assumed the message would be shown to users.)
3. **The SQL state does not match the use.** `HY000` is the generic bucket.
If this is meant for clients, an authentication error should use `28000`
(invalid authorization specification), consistent with MySQL's
`ER_ACCESS_DENIED_ERROR`. morrySnow made a related point earlier, pointing at
MySQL's `ER_LDAP_EMPTY_USERDN_PASSWORD` / `ER_NOT_VALID_PASSWORD`.
**Recommended: delete `ERR_EMPTY_PASSWORD` entirely** and express the same
thing through the single log line from suggestion 4.
The reasoning: returning a uniform `ERR_ACCESS_DENIED_ERROR` for every
authentication failure, without distinguishing the cause, is the correct
practice — it denies an attacker a signal to distinguish between failure modes.
The current code already behaves that way. If that behavior is right, the enum
constant is unnecessary.
If the maintainers do want users to see a specific message, then **report it
for real**, and make two changes at the same time: switch the SQL state to
`28000`, and drop `Set ldap_allow_empty_pass=true to allow.` from the text
(something like `Password is required.` instead). Keep the configuration key
name in the server-side log only.
## 4. [High] Collapse three log statements into one, include the client IP,
and use parameterized logging
Once the check moved down into `LdapManager` (per CalvinKirs's suggestion),
the log statements at the two call sites should have moved with it. They did
not, so a single rejected login now produces up to two entries at two different
levels in two different classes.
There is a more practical problem underneath that. For security auditing the
field that matters most is the **source IP**, and it currently ends up in the
wrong place:
- The `LOG.info` in `LdapManager` has **no IP** — the two-argument
`checkUserPasswd(fullName, passwd)` never receives `remoteIp`.
- The call-site logs **do** have the IP, but they are `LOG.debug`, which is
off by default in production.
So under a default configuration, the one line an operator can actually see
is the one missing the useful field.
Suggested consolidation — delete the `LOG.debug` blocks at
`LdapAuthenticator.java:117-121` and `Auth.java:241-244`, and rewrite the one
in `LdapManager`:
```diff
- //not allow to login in case when empty password is specified but such
mode is disabled by configuration
- public boolean checkLoginWithEmptyPasswordForLdapIsAllowed(String
fullName, String passwd) {
- if (Strings.isNullOrEmpty(passwd) &&
!LdapConfig.ldap_allow_empty_pass) {
- LOG.info("User: [" + fullName + "] login rejected: empty LDAP
password is prohibited");
- return false;
- } else {
- return true;
- }
- }
+ /**
+ * LDAP treats a bind with an empty password as an unauthenticated bind
and usually reports
+ * it as successful, so an empty password must be rejected before it
reaches the LDAP server.
+ */
+ @VisibleForTesting
+ boolean isEmptyPasswordLoginAllowed(String fullName, String passwd) {
+ if (!Strings.isNullOrEmpty(passwd) ||
LdapConfig.ldap_allow_empty_pass) {
+ return true;
+ }
+ LOG.warn("Rejected LDAP login with empty password, user={},
ldapAllowEmptyPass=false", fullName);
+ return false;
+ }
```
What changed and why:
- **Parameterized `{}`** instead of string concatenation, matching every
other log statement in `LdapManager` (e.g.
`LOG.debug("LdapManager.checkUserPasswd: user={}, result={}, elapsed={}ms",
...)`).
- **`WARN` instead of `INFO`.** A rejected authentication attempt is a
security event; `WARN` is visible under default settings and is easier for
log-alerting rules to match. If the concern is log amplification from an
unauthenticated, repeatable trigger, then the right move is the opposite — drop
it to `DEBUG` and rely on the existing `ERR_ACCESS_DENIED_ERROR` reporting.
Either is defensible; the current state (INFO, no IP, plus duplicate DEBUG
lines at the call sites) is the one position that gets the worst of both.
- `key=value` with camelCase keys (`ldapAllowEmptyPass=false`), per the FE
logging conventions.
- Early return, dropping the `else`.
- **Narrower visibility.** `LdapManagerTest` lives in the same package as
`LdapManager`, so package-private plus `@VisibleForTesting` is enough. There is
no need to promote an internal authentication predicate to `public` API just so
Mockito can spy on it.
- **Naming.** `checkLoginWithEmptyPasswordForLdapIsAllowed` is 43 characters
and reads awkwardly. `isEmptyPasswordLoginAllowed` follows the usual Java
convention for boolean methods.
If you want the IP in the log, it can be threaded down: `Auth` goes through
the four-argument overload which already has `remoteHost`, and
`LdapAuthenticator` has `remoteIp` in scope, so the plumbing is cheap.
## 5. [High] Revert the `fe-common` test dependency in the plugin module,
and trim those three tests
```xml
<!-- reference for fe-common for compatibility integration tests for PR
#61440 -->
<dependency>
<groupId>org.apache.doris</groupId>
<artifactId>fe-common</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
```
This is the **only** dependency from anywhere under `fe-authentication` to
`fe-common`. That module is deliberately decoupled from FE internals — the
javadoc at `LdapClient.java:41` says so explicitly: *"Configuration from Map
instead of global LdapConfig"*. This PR punches through that isolation for the
sake of three test methods.
To be fair about the risk: it is `test` scope, it does not reach the shaded
artifact, there is no dependency cycle (`fe-common` comes before
`fe-authentication` in the reactor and does not depend on it), and CI does
compile. But the cost is not trivial either — `fe-common` drags in
hadoop-common, hadoop-aws, netty, protobuf, antlr and their transitive closure,
so the plugin module's test classpath now has to coexist with Spring LDAP. The
next guava or jackson conflict there will be expensive to diagnose.
And the benefit is zero. The plugin's production code cannot read
`LdapConfig` at all; empty credentials are rejected at
`LdapAuthenticationPlugin.java:116` before anything else happens:
```java
byte[] credentialBytes = request.getCredential();
if (credentialBytes == null || credentialBytes.length == 0) {
return AuthenticationResult.failure("Password is required for LDAP
authentication");
}
```
So setting a static field the code under test structurally cannot observe,
then asserting the result is unchanged, is a vacuous assertion. It would not
reliably catch a future regression where the plugin *did* start reading that
field.
Worth noting separately:
`testAuthenticateEmptyPasswordAndAllowEmptyPassDefault` and
`testAuthenticateEmptyPasswordAndAllowEmptyPassTrue` are **byte-for-byte
identical** apart from the method name and comment — "default" and "true"
cannot differ here, because both explicitly assign `true`.
**Suggested: revert the pom change and restore the original single
`testAuthenticateEmptyPassword`.** It already covers the behavior that matters.
If you want to leave a record of the intent — "the plugin is unaffected by
the legacy flag" — the cheapest way is a comment on the existing test, or the
note in `ldap.conf` from suggestion 2. It does not justify a module dependency.
One more thing: drop `for PR #61440` from the pom comment. The PR number
tells a future reader nothing; a comment there should explain *why* the
dependency is needed.
## 6. [Medium] The assertion in `testCheckUserNullPasswdMocked` is a no-op
```java
LdapManager spyManager = Mockito.spy(ldapManager);
Assert.assertFalse(spyManager.checkUserPasswd(USER1, null));
Mockito.verify(spyManager,
Mockito.times(0)).checkLoginWithEmptyPasswordForLdapIsAllowed(USER1, "");
```
The comment says the test verifies that the method is not invoked in the
null-password case, but the `verify` uses `USER1, ""` as the expected
arguments. If the production code did call
`checkLoginWithEmptyPasswordForLdapIsAllowed(USER1, null)`, this assertion
would **still pass** — it only checks that no invocation happened with the
exact argument pair `(USER1, "")`, not that no invocation happened at all.
```diff
- Mockito.verify(spyManager,
Mockito.times(0)).checkLoginWithEmptyPasswordForLdapIsAllowed(USER1, "");
+ Mockito.verify(spyManager, Mockito.never())
+
.checkLoginWithEmptyPasswordForLdapIsAllowed(Mockito.anyString(),
Mockito.any());
```
## 7. [Medium] Remove the four spy-based call-sequence tests
`testCheckUserEmptyPasswdAllowedMocked`,
`testCheckUserEmptyPasswdDisabledMockedDenied`,
`testCheckUserEmptyPasswdDisabledMockedAllowed` and
`testCheckUserNullPasswdMocked` assert **implementation details** — that
`checkLoginWithEmptyPasswordForLdapIsAllowed` was called once and `getUserInfo`
zero times. The behavior they describe is already covered by their non-mocked
counterparts: `testCheckUserEmptyPasswdAllowed`,
`testCheckUserEmptyPasswdDisabled` and `testCheckUserNullPasswd`.
The problem with tests shaped this way is that any behavior-preserving
refactor — inlining the check into `checkUserPasswd`, restructuring the
short-circuit — turns them red even though nothing actually broke. They are
also the only reason `checkLoginWithEmptyPasswordForLdapIsAllowed` had to
become `public` (see suggestion 4).
The one assertion with independent value is
`Mockito.times(0)).getUserInfo(USER1)`: it guarantees that no query is sent to
the LDAP server when the flag is off. If you want to keep that property, keep a
single test and name it for the intent, e.g.
`testEmptyPasswordRejectedWithoutQueryingLdapServer`.
## 8. [Medium] Fix the inverted comment in `LdapManager`
```java
// extra check for PR 61440 to disable login with empty LDAP password in
case when specific property is true
```
**The logic is stated backwards**: empty-password login is disabled when the
property is `false`, and allowed when it is `true`. The comment also just
restates the line below it. Better to delete it and document the non-obvious
constraint instead — why the check has to run before `getUserInfo()`:
```diff
- // extra check for PR 61440 to disable login with empty LDAP
password in case when specific property is true
- if (!checkLoginWithEmptyPasswordForLdapIsAllowed(fullName, passwd))
{
+ // Must run before getUserInfo(): the cache may hold an empty
password from a login
+ // that happened while ldap_allow_empty_pass was still enabled.
+ if (!isEmptyPasswordLoginAllowed(fullName, passwd)) {
return false;
}
```
Along the same lines, `LdapAuthenticator.java:111` (`// extra check for
login with empty LDAP password was added in checkUserPasswd.`) and
`Auth.java:241` (`// extra log to identify case covered by PR 61440 ...`) can
be cleaned up together with suggestion 4.
## 9. [Low] Move `ldap_allow_empty_pass` up with the other fields
The new field sits **after** the static `getConnectionURL()` method at
`LdapConfig.java:214-218`, while the rest of the file follows a
fields-then-methods layout. Moving it below `ldap_use_ssl` and above
`getConnectionURL()` keeps the file consistent.
## 10. [Low] Test comment wording and redundant hooks
A few comments have grammar issues worth cleaning up in passing:
- `//running test with specified value - ldap_allow_empty_pass is be true` —
`is be`
- `//tests checks existing default flow method by method - what user with
empty ldap password can login` — `tests checks` → `test checks`; `what user ...
can login` → `that a user ... can log in` (the `what`/`that` mix-up recurs
across several comments)
- `//test check existing feature that user with null ldap password can't
login in any case` — `test check` → `test checks`
Also, `LdapAuthenticationPluginIntegrationTest` sets `ldap_allow_empty_pass
= true` in both `@BeforeEach` and `@AfterEach`, carrying the same typo'd
comment twice. If suggestion 5 is taken and that group is reverted, both hooks
disappear; if it is kept, only the `@AfterEach` reset is needed.
## 11. [Low] Add a test that pins "the cache must not bypass the check"
Nothing currently locks in the constraint that the check runs ahead of the
cache fast path. The scenario needs a restart to construct in practice, since
the config is not runtime-mutable — but it is precisely the property most
likely to be broken by a future refactor:
```java
@Test
public void testCachedEmptyPasswordIsRejectedAfterFlagDisabled() {
LdapManager ldapManager = new LdapManager();
Deencapsulation.setField(ldapManager, "ldapClient", ldapClient);
mockClient(true, true);
// Empty password succeeds and gets cached while the flag is still
enabled.
Assert.assertTrue(ldapManager.checkUserPasswd(USER1, ""));
Assert.assertEquals("", ldapManager.getUserInfo(USER1).getPasswd());
// Once disabled, the cached entry must not short-circuit the new check.
LdapConfig.ldap_allow_empty_pass = false;
Assert.assertFalse(ldapManager.checkUserPasswd(USER1, ""));
}
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]