This is an automated email from the ASF dual-hosted git repository.
CalvinKirs pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 9614286a2d1 [improvement](auth) Enable HTTP API authentication by
default on FE (#66205)
9614286a2d1 is described below
commit 9614286a2d13a11003b7b3903dba1069e6d26b58
Author: Calvin Kirs <[email protected]>
AuthorDate: Fri Aug 14 16:31:05 2026 +0800
[improvement](auth) Enable HTTP API authentication by default on FE (#66205)
### What problem does this PR solve?
`enable_all_http_auth` gates authentication and privilege checks on part
of the FE HTTP surface. It shipped **off**, so those endpoints answered
without checking credentials. This PR turns it **on by default on FE**,
makes it non-mutable, and fixes the bugs that made the flag unusable
when turned on.
**BE is deliberately not changed.** Its default stays `false`. BE 8040
is an internal port that operators are already required to keep off
end-user networks, and the compatibility cost of flipping it (metrics
scrapers point at it) outweighs the gain. Extending the flip to BE is a
separate decision.
#### What changes
1. **FE default flipped to `true`.** The FE `/api/**` and `/rest/v2/**`
REST surface now authenticates and enforces privileges in the shipped
configuration.
2. **The flag is no longer runtime-mutable.** `mutable = true` is
dropped from the `@ConfField`, so `ADMIN SET FRONTEND CONFIG
("enable_all_http_auth" = ...)` is rejected. Turning authentication off
requires editing `fe.conf` and restarting. Rationale: disabling
authentication should be a recorded, on-disk decision that survives a
restart, not a runtime command. It also means `fe.conf` is authoritative
when triaging a report — you can tell what a cluster was actually
running.
3. **Three bugs that only surface with the flag on.** These make the
recommended configuration unusable today, so they are fixed here:
- `BaseController.checkWithCookie()` never populated
`authInfo.userIdentity`. Every `checkAdminAuth(authInfo.userIdentity)`
built on its return value threw `NullPointerException`, taking out the
whole `/api/meta/**` path and with it the Doris-to-Doris external
catalog.
- `/api/get_small_file` demanded a user password on top of the cluster
token. BE (kafka certificates) and the CDC client (SSL CA) call it with
the token only, so both started downloading a 401 body instead of the
file — surfacing as `Small file MD5 mismatch`. The token is the
credential for this endpoint, the same way an auth token is on the BE
HTTP interfaces.
- `/api/bootstrap` had the same problem for an FE joining the cluster,
which presents cluster id + token rather than a password.
4. **Security documentation.** `threat-model.md` moves FE HTTP
*authentication* from a disclaimed property to a default-config one:
unauthenticated access to FE 8030 is now a valid finding. Authorization
is documented as a separate, narrower property — this change adds no
centralized privilege check, and the password-only handlers that remain
(`AddStoragePolicyAction`, `ESCatalogAction`, `ImportAction`,
`StatisticAction`, `query_schema`) are recorded as a known, tracked gap
rather than implied to be covered. BE 8040, FE `/metrics`, FE
`/api/health` and the cluster-token endpoints stay explicitly
disclaimed, with the asymmetry spelled out so triagers do not over-apply
the change. `SECURITY.md` states the security-testing baseline:
assessments of the FE HTTP surface must run with the flag on, verified
against the *effective* config (`fe_custom.conf` is read after `fe.conf`
and overwrites it).
5. **Regression tests.** No change to the shared `curl()` /
`http_client()` helpers — endpoints that need credentials get them at
their own call sites, so the helpers keep their anonymous mode and never
carry the JDBC password to non-FE destinations. The auth suites drop
their `ADMIN SET ... CONFIG` toggles, which the non-mutable config no
longer accepts; `test_http_api_auth` exercises the disabled path with a
second docker cluster started with the flag off in `fe.conf`, and both
clusters assert against an endpoint whose behaviour actually depends on
the flag. New `auth_p0/test_http_legacy_meta_auth` gives the legacy
`/api/meta/**` controller its first regression coverage (the existing
`test_http_meta_*` suites exercise `/rest/v2/api/meta/**`, a different
class).
### Release note
FE now enables `enable_all_http_auth` by default, so the FE `/api/**`
and `/rest/v2/**` HTTP surface requires credentials and enforces the
caller's privileges. Callers that previously polled those endpoints
anonymously (health probes, ops scripts, management tooling) will
receive 401 until they present credentials. FE `/metrics` is unaffected
— it is public by design and not gated by this flag — and the BE default
is unchanged. The flag is no longer runtime-mutable: to fall back during
a migration, set `enable_all_http_auth = false` in `fe.conf` and
restart. That is a temporary aid, not a supported steady state.
### Check List (For Author)
- Test <!-- At least one of them must be included. -->
- [x] Regression test
- [ ] Unit Test
- [ ] Manual test (add detailed scripts or steps below)
- [ ] No need to test or manual test. Explain why:
- Behavior changed:
- [ ] No.
- [x] Yes. FE HTTP endpoints that previously answered anonymously now
require credentials; `enable_all_http_auth` can no longer be changed
with `ADMIN SET`.
- Does this need documentation?
- [ ] No.
- [x] Yes. Upgrade guide and the `enable_all_http_auth` config reference
need updating in apache/doris-website.
---
SECURITY.md | 20 ++
.../main/java/org/apache/doris/common/Config.java | 22 +-
.../doris/httpv2/controller/BaseController.java | 25 ++
.../doris/httpv2/rest/BootstrapFinishAction.java | 20 +-
.../doris/httpv2/rest/GetSmallFileAction.java | 11 +-
.../apache/doris/httpv2/rest/MetaInfoAction.java | 51 +--
.../doris/httpv2/rest/StmtExecutionAction.java | 11 +-
.../doris/httpv2/rest/manager/NodeAction.java | 2 +-
.../doris/httpv2/restv2/MetaInfoActionV2.java | 21 +-
.../suites/auth/test_http_api_auth.groovy | 126 +++++--
.../auth_p0/test_http_legacy_meta_auth.groovy | 134 ++++++++
.../auth_p0/test_http_meta_databases_auth.groovy | 4 +-
.../auth_p0/test_http_meta_tables_auth.groovy | 4 +-
.../test_http_meta_tables_schema_auth.groovy | 4 +-
.../auth_p0/test_http_node_action_auth.groovy | 3 -
.../auth_p0/test_http_table_count_auth.groovy | 4 +-
.../auth_p0/test_http_table_data_auth.groovy | 4 +-
.../es/test_es_catalog_http_open_api.groovy | 8 +-
threat-model.md | 363 ++++++++++++++++++++-
19 files changed, 731 insertions(+), 106 deletions(-)
diff --git a/SECURITY.md b/SECURITY.md
index d711fa4659f..a12d814ecb5 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -13,3 +13,23 @@ scanners, review agents, and vulnerability triagers should
read
Findings that are out of model or by design under `threat-model.md`
should be reported with that disposition instead of being treated as
Doris vulnerabilities.
+
+Before testing the FE HTTP surface, confirm `enable_all_http_auth` is
+`true` on the running FE — it is the default, and it must not be
+turned off for the test run. Confirm it against the *effective*
+runtime value, not against `fe.conf` alone: FE reads `fe.conf` and
+then `${custom_config_dir}/fe_custom.conf`, and the latter overwrites
+the former. In releases where this flag was mutable, `ADMIN SET
+FRONTEND CONFIG (...) PROPERTIES("persist" = "true")` could write
+`enable_all_http_auth=false` into `fe_custom.conf`; making the flag
+non-mutable does not remove or migrate such a value, so an upgraded
+cluster can have no `false` entry anywhere in `fe.conf` and still be
+running with authentication off. Read the value back from
+`/api/show_config` (or `ADMIN SHOW FRONTEND CONFIG`) and check both
+files. With it off, FE serves part of its HTTP surface (metadata,
+statistics and import REST endpoints) without checking credentials,
+and every finding that depends on it being off is out of model. On BE
+the same flag still defaults to `false`; BE 8040 is an internal port
+that operators are required to keep off end-user networks, so findings
+there are disclaimed rather than valid. See the security-testing
+baseline in §4.5a of `threat-model.md`.
diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
index 3ce60affd46..5b7f64d2d1f 100644
--- a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
+++ b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
@@ -318,9 +318,25 @@ public class Config extends ConfigBase {
+ "BDBJE. The connection is abandoned if the clock skew is larger
than this value.")
public static long max_bdbje_clock_delta_ms = 5000; // 5s
- @ConfField(mutable = true, description = "Whether to enable "
- + "authentication for all HTTP " + "interfaces", varType =
VariableAnnotation.EXPERIMENTAL)
- public static boolean enable_all_http_auth = false;
+ @ConfField(description = "Whether to enable authentication for all HTTP
interfaces. On by default. "
+ + "While it is off, some HTTP interfaces (for example parts of the
metadata, statistics "
+ + "and admin surface) serve requests without checking the caller's
privileges, and a few "
+ + "(statistics and import endpoints) without any credentials at
all. "
+ + "This config is deliberately NOT mutable: turning authentication
off must be a recorded, "
+ + "on-disk decision in fe.conf that survives a restart, not a
runtime command. "
+ + "Upgrade note: a cluster upgrading from a version where this
defaulted to false may have "
+ + "callers that poll those interfaces anonymously (monitoring,
metrics scrapers, ops "
+ + "scripts, health probes); those callers will start getting 401
until they present "
+ + "credentials. The fix is to give them credentials -- setting
this back to false in "
+ + "fe.conf is a temporary migration aid that leaves those
interfaces unauthenticated. "
+ + "Also check fe_custom.conf when upgrading: it is read after
fe.conf and overwrites it, "
+ + "and in releases where this flag was mutable an 'ADMIN SET
FRONTEND CONFIG' with "
+ + "persist=true could have written false into it. Making the flag
non-mutable does not "
+ + "remove or migrate such a value, so a cluster can look clean in
fe.conf and still start "
+ + "with authentication off; delete the entry from fe_custom.conf
to pick up the new default. "
+ + "Security scanning and penetration testing must never be run
with this off.",
+ varType = VariableAnnotation.EXPERIMENTAL)
+ public static boolean enable_all_http_auth = true;
@ConfField(description = "Whether to enable FE unified TLS configuration.
When enabled, protocols not listed in "
+ "tls_excluded_protocols will use TLS implementation.")
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/controller/BaseController.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/controller/BaseController.java
index 610f72c9258..72c549f35a0 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/controller/BaseController.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/controller/BaseController.java
@@ -75,6 +75,9 @@ public class BaseController {
// If has Authorization header, check auth info
ActionAuthorizationInfo authInfo = getAuthorizationInfo(request);
UserIdentity currentUser = checkPassword(authInfo, request);
+ // Callers do privilege checks on the returned authInfo, so the
resolved identity must be
+ // carried back out. Leaving it null makes every such check throw
NPE.
+ authInfo.userIdentity = currentUser;
if (Config.isCloudMode() && checkAuth) {
checkInstanceOverdue(currentUser);
@@ -165,6 +168,7 @@ public class BaseController {
authInfo.fullUserName = sessionValue.currentUser.getQualifiedUser();
authInfo.remoteIp = request.getRemoteHost();
authInfo.password = sessionValue.password;
+ authInfo.userIdentity = sessionValue.currentUser;
return authInfo;
}
@@ -215,6 +219,27 @@ public class BaseController {
}
}
+ /**
+ * The overdue-warehouse fence for handlers that call checkWithCookie(..,
false).
+ *
+ * checkWithCookie's `checkAuth` flag gates two unrelated things at once:
the global
+ * ADMIN_OR_NODE requirement and, in cloud mode, the overdue check. A
handler that passes false
+ * is saying "I do my own, narrower authorization" -- it is not saying
"serve this from an
+ * overdue warehouse". Such a handler calls this to get the fence back
without the ADMIN
+ * requirement.
+ *
+ * This is deliberately opt-in per handler rather than unconditional
inside checkWithCookie:
+ * /api/query also passes false, but it hands the statement to a real JDBC
session that
+ * enforces the overdue state itself and reports it as a common error.
Moving that rejection
+ * up to this layer would silently change that endpoint's response from
COMMON_ERROR to
+ * UNAUTHORIZED.
+ */
+ protected void checkInstanceOverdueIfCloud(UserIdentity currentUser) {
+ if (Config.isCloudMode()) {
+ checkInstanceOverdue(currentUser);
+ }
+ }
+
protected void checkInstanceOverdue(UserIdentity currentUsr) {
Cloud.InstanceInfoPB.Status s = ((CloudSystemInfoService)
Env.getCurrentSystemInfo()).getInstanceStatus();
if (!currentUsr.isRootUser()
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/BootstrapFinishAction.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/BootstrapFinishAction.java
index 1cd299803e5..33afac8e89e 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/BootstrapFinishAction.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/BootstrapFinishAction.java
@@ -60,7 +60,21 @@ public class BootstrapFinishAction extends
RestBaseController {
@RequestMapping(path = "/api/bootstrap", method = RequestMethod.GET)
public ResponseEntity execute(HttpServletRequest request,
HttpServletResponse response) {
- if (Config.enable_all_http_auth) {
+ String clusterIdStr = request.getParameter(CLUSTER_ID);
+ String token = request.getParameter(TOKEN);
+ // A caller that presents the cluster id and token is an FE joining or
probing this cluster;
+ // that pair is its credential, so do not also demand a user password
from it. Callers that
+ // present neither are anonymous and must authenticate.
+ //
+ // Note the asymmetry, which is deliberate: the pair is only
*verified* on the ready path
+ // below. While this FE is not ready it is accepted unverified, so any
caller that supplies
+ // two non-empty strings can learn that this node is not ready. That
is the entire
+ // disclosure -- the not-ready path returns nothing else. Validating
the pair here instead
+ // would mean calling getClusterId()/getToken() before the cluster is
established, where
+ // they are not yet meaningful, and would turn "not ready" into
"invalid cluster id" for a
+ // legitimately joining FE. Keep the check where the values it
compares against exist.
+ boolean presentsToken = !Strings.isNullOrEmpty(clusterIdStr) &&
!Strings.isNullOrEmpty(token);
+ if (Config.enable_all_http_auth && !presentsToken) {
ActionAuthorizationInfo authInfo = executeCheckPassword(request,
response);
checkAdminAuth(authInfo.userIdentity);
}
@@ -70,9 +84,7 @@ public class BootstrapFinishAction extends RestBaseController
{
// to json response
BootstrapResult result = new BootstrapResult();
if (isReady) {
- String clusterIdStr = request.getParameter(CLUSTER_ID);
- String token = request.getParameter(TOKEN);
- if (!Strings.isNullOrEmpty(clusterIdStr) &&
!Strings.isNullOrEmpty(token)) {
+ if (presentsToken) {
// cluster id or token is provided, return more info
int clusterId = 0;
try {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/GetSmallFileAction.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/GetSmallFileAction.java
index 3543c94cc27..e74a5432b70 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/GetSmallFileAction.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/GetSmallFileAction.java
@@ -18,9 +18,7 @@
package org.apache.doris.httpv2.rest;
import org.apache.doris.catalog.Env;
-import org.apache.doris.common.Config;
import org.apache.doris.common.util.SmallFileMgr;
-import
org.apache.doris.httpv2.controller.BaseController.ActionAuthorizationInfo;
import org.apache.doris.httpv2.entity.ResponseEntityBuilder;
import com.google.common.base.Strings;
@@ -40,11 +38,6 @@ public class GetSmallFileAction extends RestBaseController {
@RequestMapping(path = "/api/get_small_file", method = RequestMethod.GET)
public Object execute(HttpServletRequest request, HttpServletResponse
response) {
- if (Config.enable_all_http_auth) {
- ActionAuthorizationInfo authInfo = executeCheckPassword(request,
response);
- checkAdminAuth(authInfo.userIdentity);
- }
-
String token = request.getParameter("token");
String fileIdStr = request.getParameter("file_id");
// check param empty
@@ -53,6 +46,10 @@ public class GetSmallFileAction extends RestBaseController {
}
// check token
+ // The cluster token is the credential the internal callers of this
endpoint present: BE
+ // downloads small files with `token=<cluster token>` and no user, and
so does the CDC
+ // client. It authenticates the request the same way an auth token
does on the BE HTTP
+ // interfaces, so requiring credentials on top of it would lock those
callers out.
if (!token.equals(Env.getCurrentEnv().getToken())) {
return ResponseEntityBuilder.okWithCommonError("Invalid token");
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/MetaInfoAction.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/MetaInfoAction.java
index 935457fff70..eb9b591e1e2 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/MetaInfoAction.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/MetaInfoAction.java
@@ -21,7 +21,6 @@ import org.apache.doris.catalog.Database;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.OlapTable;
import org.apache.doris.catalog.Table;
-import org.apache.doris.common.Config;
import org.apache.doris.common.DdlException;
import org.apache.doris.common.FeConstants;
import org.apache.doris.common.MetaNotFoundException;
@@ -51,7 +50,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
-import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -89,11 +87,14 @@ public class MetaInfoAction extends RestBaseController {
public Object getAllDatabases(
@PathVariable(value = NS_KEY) String ns,
HttpServletRequest request, HttpServletResponse response) {
- boolean checkAuth = Config.enable_all_http_auth ? true : false;
- ActionAuthorizationInfo authInfo = checkWithCookie(request, response,
checkAuth);
- if (Config.enable_all_http_auth) {
- checkAdminAuth(authInfo.userIdentity);
- }
+ // Authenticate, but do not demand global ADMIN: the per-database SHOW
check below is what
+ // authorizes this response, so a least-privilege account (for example
the user a
+ // Doris-to-Doris external catalog is configured with) can list
exactly the databases it is
+ // allowed to see. A caller that presents no credential at all is
still rejected. Passing
+ // false also skips checkWithCookie's cloud overdue check, which is
unrelated to privilege
+ // level, so it is re-applied explicitly on the next line.
+ ActionAuthorizationInfo authInfo = checkWithCookie(request, response,
false);
+ checkInstanceOverdueIfCloud(authInfo.userIdentity);
// use NS_KEY as catalog, but NS_KEY's default value is
'default_cluster'.
if (ns.equalsIgnoreCase(SystemInfoService.DEFAULT_CLUSTER)) {
@@ -105,22 +106,26 @@ public class MetaInfoAction extends RestBaseController {
if (catalog == null) {
return ResponseEntityBuilder.badRequest("Unknown catalog " + ns);
}
- List<String> dbNames = new ArrayList<>(catalog.getDbNames());
- List<String> dbNameSet = Lists.newArrayList();
+ // No defensive copy of getDbNames(): this method only iterates the
returned list and
+ // sorts its own filtered copy, so it does not care whether an
implementation hands back
+ // a fresh list or a shared one.
+ List<String> dbNames = catalog.getDbNames();
+ List<String> visibleDbNames = Lists.newArrayList();
for (String db : dbNames) {
+ // Check the privilege against the catalog actually being listed,
not always the
+ // internal one, or the filter answers about the wrong object for
external catalogs.
if (!Env.getCurrentEnv().getAccessManager()
- .checkDbPriv(ConnectContext.get(),
InternalCatalog.INTERNAL_CATALOG_NAME, db,
- PrivPredicate.SHOW)) {
+ .checkDbPriv(ConnectContext.get(), ns, db,
PrivPredicate.SHOW)) {
continue;
}
- dbNameSet.add(db);
+ visibleDbNames.add(db);
}
- Collections.sort(dbNames);
+ Collections.sort(visibleDbNames);
// handle limit offset
- Pair<Integer, Integer> fromToIndex = getFromToIndex(request,
dbNames.size());
- return ResponseEntityBuilder.ok(dbNames.subList(fromToIndex.first,
fromToIndex.second));
+ Pair<Integer, Integer> fromToIndex = getFromToIndex(request,
visibleDbNames.size());
+ return
ResponseEntityBuilder.ok(visibleDbNames.subList(fromToIndex.first,
fromToIndex.second));
}
/** Get all tables of a database
@@ -140,11 +145,9 @@ public class MetaInfoAction extends RestBaseController {
public Object getTables(
@PathVariable(value = NS_KEY) String ns, @PathVariable(value =
DB_KEY) String dbName,
HttpServletRequest request, HttpServletResponse response) {
- boolean checkAuth = Config.enable_all_http_auth ? true : false;
- ActionAuthorizationInfo authInfo = checkWithCookie(request, response,
checkAuth);
- if (Config.enable_all_http_auth) {
- checkAdminAuth(authInfo.userIdentity);
- }
+ // Authenticate only; the per-table SHOW check below is what
authorizes the response.
+ ActionAuthorizationInfo authInfo = checkWithCookie(request, response,
false);
+ checkInstanceOverdueIfCloud(authInfo.userIdentity);
if (!ns.equalsIgnoreCase(SystemInfoService.DEFAULT_CLUSTER)) {
return ResponseEntityBuilder.badRequest("Only support
'default_cluster' now");
@@ -220,11 +223,9 @@ public class MetaInfoAction extends RestBaseController {
@PathVariable(value = NS_KEY) String ns, @PathVariable(value =
DB_KEY) String dbName,
@PathVariable(value = TABLE_KEY) String tblName,
HttpServletRequest request, HttpServletResponse response) throws
UserException {
- boolean checkAuth = Config.enable_all_http_auth ? true : false;
- ActionAuthorizationInfo authInfo = checkWithCookie(request, response,
checkAuth);
- if (Config.enable_all_http_auth) {
- checkAdminAuth(authInfo.userIdentity);
- }
+ // Authenticate only; checkTblAuth below authorizes the response
against the requested table.
+ ActionAuthorizationInfo authInfo = checkWithCookie(request, response,
false);
+ checkInstanceOverdueIfCloud(authInfo.userIdentity);
if (!ns.equalsIgnoreCase(SystemInfoService.DEFAULT_CLUSTER)) {
return ResponseEntityBuilder.badRequest("Only support
'default_cluster' now");
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/StmtExecutionAction.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/StmtExecutionAction.java
index 6b58bf420b9..254ae250dab 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/StmtExecutionAction.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/StmtExecutionAction.java
@@ -19,12 +19,10 @@ package org.apache.doris.httpv2.rest;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.TableIf;
-import org.apache.doris.common.Config;
import org.apache.doris.datasource.InternalCatalog;
import org.apache.doris.httpv2.entity.ResponseEntityBuilder;
import org.apache.doris.httpv2.util.ExecutionResultSet;
import org.apache.doris.httpv2.util.StatementSubmitter;
-import org.apache.doris.mysql.privilege.PrivPredicate;
import org.apache.doris.nereids.NereidsPlanner;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.parser.NereidsParser;
@@ -90,10 +88,13 @@ public class StmtExecutionAction extends RestBaseController
{
return redirectToHttps(request);
}
+ // Authenticate only. Deliberately no privilege pre-gate here:
executeQuery hands the
+ // statement to StatementSubmitter, which opens a JDBC connection to
this FE's MySQL port
+ // as the caller (authInfo.fullUserName / authInfo.password), so the
statement runs through
+ // the ordinary protocol path with full per-statement RBAC and, in
cloud mode, the
+ // overdue-warehouse fence. A database-ADMIN pre-gate would add
nothing to that and would
+ // lock out every least-privilege account whose SQL privileges are
already sufficient.
ActionAuthorizationInfo authInfo = checkWithCookie(request, response,
false);
- if (Config.enable_all_http_auth) {
- checkDbAuth(ConnectContext.get().getCurrentUserIdentity(), dbName,
PrivPredicate.ADMIN);
- }
if (ns.equalsIgnoreCase(SystemInfoService.DEFAULT_CLUSTER)) {
ns = InternalCatalog.INTERNAL_CATALOG_NAME;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/manager/NodeAction.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/manager/NodeAction.java
index fcb3a58a5d5..67aa7036f6a 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/manager/NodeAction.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/manager/NodeAction.java
@@ -261,7 +261,7 @@ public class NodeAction extends RestBaseController {
public Object config(HttpServletRequest request, HttpServletResponse
response) {
// This endpoint lists all FE config, matching the SQL "SHOW FRONTEND
CONFIG", which
// requires ADMIN. Use an unconditional ADMIN check: checkAdminAuth
only enforces the
- // privilege when enable_all_http_auth is true, so it would be a no-op
by default.
+ // privilege when enable_all_http_auth is true, which an operator can
turn off.
// Sensitive config values (e.g. fe_meta_auth_token) are additionally
masked by ConfigBase,
// so they are never returned in plaintext even to an admin.
ActionAuthorizationInfo authInfo = executeCheckPassword(request,
response);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/MetaInfoActionV2.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/MetaInfoActionV2.java
index c260e7b64de..b4f57278b22 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/MetaInfoActionV2.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/MetaInfoActionV2.java
@@ -29,6 +29,7 @@ import org.apache.doris.common.Pair;
import org.apache.doris.common.UserException;
import org.apache.doris.datasource.CatalogIf;
import org.apache.doris.datasource.InternalCatalog;
+import
org.apache.doris.httpv2.controller.BaseController.ActionAuthorizationInfo;
import org.apache.doris.httpv2.entity.ResponseEntityBuilder;
import org.apache.doris.httpv2.exception.BadRequestException;
import org.apache.doris.httpv2.rest.RestBaseController;
@@ -83,7 +84,9 @@ public class MetaInfoActionV2 extends RestBaseController {
method = {RequestMethod.GET})
public Object getAllCatalogs(
HttpServletRequest request, HttpServletResponse response) {
- checkWithCookie(request, response, false);
+ // Authenticate; the per-object SHOW filters below authorize. See
checkInstanceOverdueIfCloud.
+ ActionAuthorizationInfo authInfo = checkWithCookie(request, response,
false);
+ checkInstanceOverdueIfCloud(authInfo.userIdentity);
// 1. get all catalogs with privilege
List<CatalogIf> ctls = Env.getCurrentEnv().getCatalogMgr()
@@ -117,7 +120,9 @@ public class MetaInfoActionV2 extends RestBaseController {
public Object getAllDatabases(
@PathVariable(value = NS_KEY) String ns,
HttpServletRequest request, HttpServletResponse response) {
- checkWithCookie(request, response, false);
+ // Authenticate; the per-object SHOW filters below authorize. See
checkInstanceOverdueIfCloud.
+ ActionAuthorizationInfo authInfo = checkWithCookie(request, response,
false);
+ checkInstanceOverdueIfCloud(authInfo.userIdentity);
String catalogName = ns.equalsIgnoreCase("default_cluster") ?
InternalCatalog.INTERNAL_CATALOG_NAME : ns;
CatalogIf catalog =
Env.getCurrentEnv().getCatalogMgr().getCatalog(catalogName);
@@ -131,7 +136,7 @@ public class MetaInfoActionV2 extends RestBaseController {
for (String fullName : dbNames) {
final String db = fullName;
if (!Env.getCurrentEnv().getAccessManager()
- .checkDbPriv(ConnectContext.get(),
InternalCatalog.INTERNAL_CATALOG_NAME, fullName,
+ .checkDbPriv(ConnectContext.get(), catalogName, fullName,
PrivPredicate.SHOW)) {
continue;
}
@@ -162,7 +167,9 @@ public class MetaInfoActionV2 extends RestBaseController {
public Object getTables(
@PathVariable(value = NS_KEY) String ns, @PathVariable(value =
DB_KEY) String dbName,
HttpServletRequest request, HttpServletResponse response) {
- checkWithCookie(request, response, false);
+ // Authenticate; the per-object SHOW filters below authorize. See
checkInstanceOverdueIfCloud.
+ ActionAuthorizationInfo authInfo = checkWithCookie(request, response,
false);
+ checkInstanceOverdueIfCloud(authInfo.userIdentity);
String catalogName = ns.equalsIgnoreCase("default_cluster") ?
InternalCatalog.INTERNAL_CATALOG_NAME : ns;
CatalogIf catalog =
Env.getCurrentEnv().getCatalogMgr().getCatalog(catalogName);
@@ -182,7 +189,7 @@ public class MetaInfoActionV2 extends RestBaseController {
try {
for (TableIf tbl : db.getTables()) {
if (!Env.getCurrentEnv().getAccessManager()
- .checkTblPriv(ConnectContext.get(),
InternalCatalog.INTERNAL_CATALOG_NAME, dbName,
+ .checkTblPriv(ConnectContext.get(), catalogName,
dbName,
tbl.getName(), PrivPredicate.SHOW)) {
continue;
}
@@ -234,7 +241,9 @@ public class MetaInfoActionV2 extends RestBaseController {
@PathVariable(value = NS_KEY) String ns, @PathVariable(value =
DB_KEY) String dbName,
@PathVariable(value = TABLE_KEY) String tblName,
HttpServletRequest request, HttpServletResponse response) throws
UserException {
- checkWithCookie(request, response, false);
+ // Authenticate; the per-object SHOW filters below authorize. See
checkInstanceOverdueIfCloud.
+ ActionAuthorizationInfo authInfo = checkWithCookie(request, response,
false);
+ checkInstanceOverdueIfCloud(authInfo.userIdentity);
String catalogName = ns.equalsIgnoreCase("default_cluster") ?
InternalCatalog.INTERNAL_CATALOG_NAME : ns;
CatalogIf catalog =
Env.getCurrentEnv().getCatalogMgr().getCatalog(catalogName);
diff --git a/regression-test/suites/auth/test_http_api_auth.groovy
b/regression-test/suites/auth/test_http_api_auth.groovy
index e8c5d266608..0ba5ab05b96 100644
--- a/regression-test/suites/auth/test_http_api_auth.groovy
+++ b/regression-test/suites/auth/test_http_api_auth.groovy
@@ -19,34 +19,27 @@ import org.apache.doris.regression.suite.ClusterOptions
import groovy.json.JsonSlurper
suite("test_http_api_auth", "docker") {
- def options = new ClusterOptions()
- options.cloudMode = false // 存算一体模式
+ def jsonSlurper = new JsonSlurper()
- docker(options) {
- // Get FE and BE HTTP addresses from cluster
+ // Helper to check JSON response code
+ def checkJsonCode = { bodyStr, expectedCode ->
+ def json = jsonSlurper.parseText(bodyStr)
+ assertEquals(expectedCode, json.code)
+ }
+
+ // ========== Test Scenario 1: enable_all_http_auth = false ==========
+ // enable_all_http_auth is not a mutable config, so the disabled path can
only be exercised
+ // by starting a cluster with it turned off in fe.conf.
+ def authOffOptions = new ClusterOptions()
+ authOffOptions.cloudMode = false // 存算一体模式
+ authOffOptions.feConfigs += ['enable_all_http_auth=false']
+
+ docker(authOffOptions) {
def fe = cluster.getFeByIndex(1)
def be = cluster.getBeByIndex(1)
def feHost = fe.host + ":" + fe.httpPort
def beHost = be.host + ":" + be.httpPort
- def jsonSlurper = new JsonSlurper()
-
- // Helper to check JSON response code
- def checkJsonCode = { bodyStr, expectedCode ->
- def json = jsonSlurper.parseText(bodyStr)
- assertEquals(expectedCode, json.code)
- }
-
- // ========== Setup ==========
- sql """CREATE USER IF NOT EXISTS 'test_user'@'%' IDENTIFIED BY
'test_password'"""
- sql """GRANT SELECT_PRIV ON *.* TO 'test_user'@'%'"""
-
- sql """CREATE USER IF NOT EXISTS 'admin_user'@'%' IDENTIFIED BY
'admin_password'"""
- sql """GRANT ADMIN_PRIV ON *.*.* TO 'admin_user'@'%'"""
-
- // ========== Test Scenario 1: enable_all_http_auth = false ==========
- sql """ADMIN SET FRONTEND CONFIG ("enable_all_http_auth" = "false")"""
-
// FE Health - no auth needed
httpTest {
endpoint feHost
@@ -79,9 +72,41 @@ suite("test_http_api_auth", "docker") {
}
}
- // ========== Test Scenario 2: enable_all_http_auth = true - Public
APIs ==========
+ // The assertions above hold no matter how the flag is set, so on
their own they would
+ // still pass if this cluster's startup override were ignored or
misspelled. This one
+ // depends on the flag: /api/show_runtime_info gates its whole auth
block on
+ // enable_all_http_auth, so with the flag off an anonymous caller gets
the payload, and
+ // the default-on block below proves the same request is rejected when
it is on.
+ httpTest {
+ endpoint feHost
+ uri "/api/show_runtime_info"
+ op "get"
+ check { code, body ->
+ assertEquals(200, code)
+ checkJsonCode(body, 0)
+ assertTrue("${body}".contains("thread_cnt"))
+ }
+ }
+ }
+
+ // ========== enable_all_http_auth = true (the default) ==========
+ def options = new ClusterOptions()
+ options.cloudMode = false // 存算一体模式
+
+ docker(options) {
+ // Get FE HTTP address from cluster
+ def fe = cluster.getFeByIndex(1)
+ def feHost = fe.host + ":" + fe.httpPort
+
+ // ========== Setup ==========
+ sql """CREATE USER IF NOT EXISTS 'test_user'@'%' IDENTIFIED BY
'test_password'"""
+ sql """GRANT SELECT_PRIV ON *.* TO 'test_user'@'%'"""
+
+ sql """CREATE USER IF NOT EXISTS 'admin_user'@'%' IDENTIFIED BY
'admin_password'"""
+ sql """GRANT ADMIN_PRIV ON *.*.* TO 'admin_user'@'%'"""
+
+ // ========== Test Scenario 2: Public APIs ==========
// Health and Metrics endpoints are always public (no auth required)
- sql """ADMIN SET FRONTEND CONFIG ("enable_all_http_auth" = "true")"""
// FE Health - still accessible without auth (public endpoint)
httpTest {
@@ -129,7 +154,46 @@ suite("test_http_api_auth", "docker") {
}
}
- // ========== Test Scenario 3: Admin APIs ==========
+ // ========== Test Scenario 3: The flag actually took effect ==========
+
+ // Counterpart of the auth-off block's request: with the flag on, the
same anonymous
+ // request to /api/show_runtime_info must be rejected.
+ httpTest {
+ endpoint feHost
+ uri "/api/show_runtime_info"
+ op "get"
+ check { code, body ->
+ assertEquals(200, code)
+ checkJsonCode(body, 401)
+ }
+ }
+
+ // A valid but non-admin account is authenticated and then refused by
the ADMIN check.
+ httpTest {
+ endpoint feHost
+ uri "/api/show_runtime_info"
+ op "get"
+ basicAuthorization "test_user", "test_password"
+ check { code, body ->
+ assertEquals(200, code)
+ checkJsonCode(body, 401) // "Access denied; you need ...
Admin_priv"
+ }
+ }
+
+ // An admin account gets the payload.
+ httpTest {
+ endpoint feHost
+ uri "/api/show_runtime_info"
+ op "get"
+ basicAuthorization "admin_user", "admin_password"
+ check { code, body ->
+ assertEquals(200, code)
+ checkJsonCode(body, 0)
+ assertTrue("${body}".contains("thread_cnt"))
+ }
+ }
+
+ // ========== Test Scenario 3b: Password-only APIs ==========
// FE Backends API - no auth returns 401 in JSON body
httpTest {
@@ -142,9 +206,10 @@ suite("test_http_api_auth", "docker") {
}
}
- // FE Backends API - normal user returns 401 (Access denied, need
Admin_priv)
- // Note: The current implementation returns 401 for both
authentication failure
- // and authorization failure (insufficient privileges)
+ // BackendsAction only calls executeCheckPassword -- it deliberately
has no privilege
+ // check, because the Flink/Spark connectors call it with an ordinary
load account to
+ // discover backends before a stream load. So a valid non-admin user
succeeds here. This
+ // endpoint authenticates; it does not authorize.
httpTest {
endpoint feHost
uri "/api/backends"
@@ -152,7 +217,7 @@ suite("test_http_api_auth", "docker") {
basicAuthorization "test_user", "test_password"
check { code, body ->
assertEquals(200, code)
- checkJsonCode(body, 401) // Returns 401 with "Access denied;
you need Admin_priv"
+ checkJsonCode(body, 0)
}
}
@@ -209,8 +274,5 @@ suite("test_http_api_auth", "docker") {
// ========== Cleanup ==========
sql """DROP USER IF EXISTS 'test_user'@'%'"""
sql """DROP USER IF EXISTS 'admin_user'@'%'"""
-
- // Restore default config
- sql """ADMIN SET FRONTEND CONFIG ("enable_all_http_auth" = "false")"""
}
}
diff --git a/regression-test/suites/auth_p0/test_http_legacy_meta_auth.groovy
b/regression-test/suites/auth_p0/test_http_legacy_meta_auth.groovy
new file mode 100644
index 00000000000..e3432090908
--- /dev/null
+++ b/regression-test/suites/auth_p0/test_http_legacy_meta_auth.groovy
@@ -0,0 +1,134 @@
+// 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 org.junit.Assert;
+
+// Covers the LEGACY metadata controller, MetaInfoAction, served at
/api/meta/**.
+// Note the path: the sibling test_http_meta_* suites use
/rest/v2/api/meta/**, which is a
+// different class (MetaInfoActionV2). Before this suite the legacy controller
had no regression
+// coverage at all.
+//
+// What it pins down:
+// 1. The endpoints authenticate. No credential -> rejected, whatever the
privileges would be.
+// 2. They do NOT require global ADMIN. A least-privilege account gets a
successful response --
+// this is what a Doris-to-Doris external catalog (RemoteDorisRestClient)
relies on, since it
+// calls exactly these routes with the catalog's configured user.
+// 3. The response is privilege-filtered per object, and the filter is
actually applied to what
+// is returned. getAllDatabases used to compute a SHOW-filtered list and
then return the
+// unfiltered one, so a non-admin saw every database on the cluster.
+suite("test_http_legacy_meta_auth", "p0,auth,nonConcurrent") {
+ String suiteName = "test_http_legacy_meta_auth"
+ String dbName = context.config.getDbNameByFile(context.file)
+ String tableName = "${suiteName}_table"
+ String hiddenTableName = "${suiteName}_hidden_table"
+ String user = "${suiteName}_user"
+ String pwd = 'C123_567p'
+
+ try_sql("DROP USER ${user}")
+ sql """CREATE USER '${user}' IDENTIFIED BY '${pwd}'"""
+ sql """DROP TABLE IF EXISTS `${tableName}`"""
+ sql """DROP TABLE IF EXISTS `${hiddenTableName}`"""
+ sql """
+ CREATE TABLE `${tableName}` (
+ `k1` int,
+ `k2` int
+ ) ENGINE=OLAP
+ DISTRIBUTED BY random BUCKETS auto
+ PROPERTIES ('replication_num' = '1') ;
+ """
+ sql """
+ CREATE TABLE `${hiddenTableName}` (
+ `k1` int,
+ `k2` int
+ ) ENGINE=OLAP
+ DISTRIBUTED BY random BUCKETS auto
+ PROPERTIES ('replication_num' = '1') ;
+ """
+
+ try {
+ def legacyGet = { uriPath, user_name, password, check_func ->
+ httpTest {
+ if (user_name != null) {
+ basicAuthorization "${user_name}", "${password}"
+ }
+ endpoint "${context.config.feHttpAddress}"
+ uri uriPath
+ op "get"
+ check check_func
+ }
+ }
+
+ String dbsUri = "/api/meta/namespaces/default_cluster/databases"
+ String tblsUri =
"/api/meta/namespaces/default_cluster/databases/${dbName}/tables"
+
+ // 1. Authentication is required. Anonymous callers are rejected.
+ legacyGet.call(dbsUri, null, null) {
+ respCode, body ->
+ log.info("legacy databases (anonymous) respCode:${respCode}
body:${body}")
+ assertTrue(respCode == 401 || "${body}".contains("401")
+ || "${body}".contains("Unauthorized") ||
"${body}".contains("Need auth"))
+ }
+
+ // 2. A valid but non-admin account is accepted -- no global ADMIN is
demanded.
+ // 3. ... and sees nothing it has no SHOW privilege on.
+ legacyGet.call(dbsUri, user, pwd) {
+ respCode, body ->
+ log.info("legacy databases (no grants) respCode:${respCode}
body:${body}")
+ assertEquals(200, respCode)
+ assertFalse("${body}".contains("Unauthorized"))
+ assertFalse("${body}".contains("Admin_priv"))
+ assertFalse("${body}".contains("${dbName}"))
+ }
+
+ sql """grant select_priv on ${dbName}.${tableName} to ${user}"""
+
+ // The grant on one table makes the database visible...
+ legacyGet.call(dbsUri, user, pwd) {
+ respCode, body ->
+ log.info("legacy databases (after grant) respCode:${respCode}
body:${body}")
+ assertEquals(200, respCode)
+ assertTrue("${body}".contains("${dbName}"))
+ }
+
+ // ... but only the granted table inside it. The other table stays
hidden.
+ legacyGet.call(tblsUri, user, pwd) {
+ respCode, body ->
+ log.info("legacy tables (after grant) respCode:${respCode}
body:${body}")
+ assertEquals(200, respCode)
+ assertTrue("${body}".contains("${tableName}"))
+ assertFalse("${body}".contains("${hiddenTableName}"))
+ }
+
+ // The schema route authorizes per table: granted table succeeds,
ungranted one does not.
+ legacyGet.call("${tblsUri}/${tableName}/schema", user, pwd) {
+ respCode, body ->
+ log.info("legacy schema (granted) respCode:${respCode}
body:${body}")
+ assertEquals(200, respCode)
+ assertTrue("${body}".contains("k1"))
+ }
+
+ legacyGet.call("${tblsUri}/${hiddenTableName}/schema", user, pwd) {
+ respCode, body ->
+ log.info("legacy schema (ungranted) respCode:${respCode}
body:${body}")
+ assertTrue("${body}".contains("401") ||
"${body}".contains("Access denied"))
+ }
+ } finally {
+ try_sql("DROP TABLE IF EXISTS `${tableName}`")
+ try_sql("DROP TABLE IF EXISTS `${hiddenTableName}`")
+ try_sql("DROP USER ${user}")
+ }
+}
diff --git
a/regression-test/suites/auth_p0/test_http_meta_databases_auth.groovy
b/regression-test/suites/auth_p0/test_http_meta_databases_auth.groovy
index c515b5c83ea..dd0f71b1566 100644
--- a/regression-test/suites/auth_p0/test_http_meta_databases_auth.groovy
+++ b/regression-test/suites/auth_p0/test_http_meta_databases_auth.groovy
@@ -26,7 +26,6 @@
suite("test_http_meta_databases_auth","p0,auth,nonConcurrent") {
try_sql("DROP USER ${user}")
sql """CREATE USER '${user}' IDENTIFIED BY '${pwd}'"""
try {
- sql """ ADMIN SET ALL FRONTENDS CONFIG ("enable_all_http_auth" =
"true"); """
def getDatabases = { check_func ->
httpTest {
basicAuthorization "${user}","${pwd}"
@@ -51,8 +50,7 @@
suite("test_http_meta_databases_auth","p0,auth,nonConcurrent") {
assertTrue("${body}".contains("${dbName}"))
}
- try_sql("DROP USER ${user}")
} finally {
- sql """ ADMIN SET ALL FRONTENDS CONFIG ("enable_all_http_auth" =
"false"); """
+ try_sql("DROP USER ${user}")
}
}
diff --git a/regression-test/suites/auth_p0/test_http_meta_tables_auth.groovy
b/regression-test/suites/auth_p0/test_http_meta_tables_auth.groovy
index b2fd5914352..65d2bacdc0e 100644
--- a/regression-test/suites/auth_p0/test_http_meta_tables_auth.groovy
+++ b/regression-test/suites/auth_p0/test_http_meta_tables_auth.groovy
@@ -35,7 +35,6 @@ suite("test_http_meta_tables_auth","p0,auth,nonConcurrent") {
PROPERTIES ('replication_num' = '1') ;
"""
try {
- sql """ ADMIN SET ALL FRONTENDS CONFIG ("enable_all_http_auth" =
"true"); """
def getTables = { check_func ->
httpTest {
basicAuthorization "${user}","${pwd}"
@@ -61,9 +60,8 @@ suite("test_http_meta_tables_auth","p0,auth,nonConcurrent") {
}
sql """drop table if exists `${tableName}`"""
- try_sql("DROP USER ${user}")
} finally {
- sql """ ADMIN SET ALL FRONTENDS CONFIG ("enable_all_http_auth" =
"false"); """
+ try_sql("DROP USER ${user}")
}
diff --git
a/regression-test/suites/auth_p0/test_http_meta_tables_schema_auth.groovy
b/regression-test/suites/auth_p0/test_http_meta_tables_schema_auth.groovy
index f03d5a55bd3..04b01b6937b 100644
--- a/regression-test/suites/auth_p0/test_http_meta_tables_schema_auth.groovy
+++ b/regression-test/suites/auth_p0/test_http_meta_tables_schema_auth.groovy
@@ -36,7 +36,6 @@
suite("test_http_meta_tables_schema_auth","p0,auth,nonConcurrent") {
"""
try {
- sql """ ADMIN SET ALL FRONTENDS CONFIG ("enable_all_http_auth" = "true");
"""
def getSchema = { check_func ->
httpTest {
basicAuthorization "${user}","${pwd}"
@@ -62,8 +61,7 @@
suite("test_http_meta_tables_schema_auth","p0,auth,nonConcurrent") {
}
sql """drop table if exists `${tableName}`"""
- try_sql("DROP USER ${user}")
} finally {
- sql """ ADMIN SET ALL FRONTENDS CONFIG ("enable_all_http_auth" =
"false"); """
+ try_sql("DROP USER ${user}")
}
}
diff --git a/regression-test/suites/auth_p0/test_http_node_action_auth.groovy
b/regression-test/suites/auth_p0/test_http_node_action_auth.groovy
index 5b1774a44d0..ab7d580e19e 100644
--- a/regression-test/suites/auth_p0/test_http_node_action_auth.groovy
+++ b/regression-test/suites/auth_p0/test_http_node_action_auth.groovy
@@ -37,8 +37,6 @@ suite("test_http_node_action_auth", "p0,auth,nonConcurrent") {
sql """CREATE USER '${user}' IDENTIFIED BY '${pwd}'"""
try {
- sql """ ADMIN SET ALL FRONTENDS CONFIG ("enable_all_http_auth" =
"true"); """
-
def operateFe = { user_name, password, action, check_func ->
httpTest {
basicAuthorization "${user_name}", "${password}"
@@ -107,7 +105,6 @@ suite("test_http_node_action_auth",
"p0,auth,nonConcurrent") {
}
}
} finally {
- sql """ ADMIN SET ALL FRONTENDS CONFIG ("enable_all_http_auth" =
"false"); """
try_sql("DROP USER ${user}")
}
}
diff --git a/regression-test/suites/auth_p0/test_http_table_count_auth.groovy
b/regression-test/suites/auth_p0/test_http_table_count_auth.groovy
index 2cf222b1f58..5a9f8169d25 100644
--- a/regression-test/suites/auth_p0/test_http_table_count_auth.groovy
+++ b/regression-test/suites/auth_p0/test_http_table_count_auth.groovy
@@ -36,7 +36,6 @@ suite("test_http_table_count_auth","p0,auth,nonConcurrent") {
"""
sql """insert into ${tableName} values(1,1)"""
try {
- sql """ ADMIN SET ALL FRONTENDS CONFIG ("enable_all_http_auth" = "true");
"""
def getCount = { check_func ->
httpTest {
basicAuthorization "${user}","${pwd}"
@@ -62,8 +61,7 @@ suite("test_http_table_count_auth","p0,auth,nonConcurrent") {
}
sql """drop table if exists `${tableName}`"""
- try_sql("DROP USER ${user}")
} finally {
- sql """ ADMIN SET ALL FRONTENDS CONFIG ("enable_all_http_auth" =
"false"); """
+ try_sql("DROP USER ${user}")
}
}
diff --git a/regression-test/suites/auth_p0/test_http_table_data_auth.groovy
b/regression-test/suites/auth_p0/test_http_table_data_auth.groovy
index 3a773894a56..f8f33a93ed0 100644
--- a/regression-test/suites/auth_p0/test_http_table_data_auth.groovy
+++ b/regression-test/suites/auth_p0/test_http_table_data_auth.groovy
@@ -36,7 +36,6 @@ suite("test_http_table_data_auth","p0,auth,nonConcurrent") {
"""
sql """insert into ${tableName} values(1,1)"""
try {
- sql """ ADMIN SET ALL FRONTENDS CONFIG ("enable_all_http_auth" =
"true"); """
def getTableData = { check_func ->
httpTest {
basicAuthorization "${user}","${pwd}"
@@ -84,8 +83,7 @@ suite("test_http_table_data_auth","p0,auth,nonConcurrent") {
}
sql """drop table if exists `${tableName}`"""
- try_sql("DROP USER ${user}")
} finally {
- sql """ ADMIN SET ALL FRONTENDS CONFIG ("enable_all_http_auth" =
"false"); """
+ try_sql("DROP USER ${user}")
}
}
diff --git
a/regression-test/suites/external_table_p2/es/test_es_catalog_http_open_api.groovy
b/regression-test/suites/external_table_p2/es/test_es_catalog_http_open_api.groovy
index 67d7bbc95b7..9723f9d72e1 100644
---
a/regression-test/suites/external_table_p2/es/test_es_catalog_http_open_api.groovy
+++
b/regression-test/suites/external_table_p2/es/test_es_catalog_http_open_api.groovy
@@ -66,10 +66,14 @@ suite("test_es_catalog_http_open_api", "p2,external") {
"""
List<String> feHosts = getFrontendIpHttpPort()
+ // These FE endpoints require credentials: ESCatalogAction calls
executeCheckPassword when
+ // enable_all_http_auth is on, which is the default.
+ String feUser = context.config.jdbcUser
+ String fePwd = context.config.jdbcPassword ?: ""
// for each catalog 5..8, send a request
for (int i = 5; i <= 8; i++) {
String catalog =
String.format("test_es_catalog_http_open_api_es%s", i)
- def (code, out, err) = curl("GET",
String.format("http://%s/rest/v2/api/es_catalog/get_mapping?catalog=%s&table=test1",
feHosts[0], catalog))
+ def (code, out, err) = curl("GET",
String.format("http://%s/rest/v2/api/es_catalog/get_mapping?catalog=%s&table=test1",
feHosts[0], catalog), null, 10, feUser, fePwd)
logger.info("Get mapping response: code=" + code + ", out=" + out
+ ", err=" + err)
assertTrue(code == 0)
assertTrue(out.toLowerCase().contains("success"))
@@ -77,7 +81,7 @@ suite("test_es_catalog_http_open_api", "p2,external") {
assertTrue(out.toLowerCase().contains(catalog))
String body =
'{"query":{"match_all":{}},"stored_fields":"_none_","docvalue_fields":["test6"],"sort":["_doc"],"size":4064}';
- def (code1, out1, err1) = curl("POST",
String.format("http://%s/rest/v2/api/es_catalog/search?catalog=%s&table=test1",
feHosts[0], catalog), body)
+ def (code1, out1, err1) = curl("POST",
String.format("http://%s/rest/v2/api/es_catalog/search?catalog=%s&table=test1",
feHosts[0], catalog), body, 10, feUser, fePwd)
logger.info("Search index response: code=" + code1 + ", out=" +
out1 + ", err=" + err1)
assertTrue(code1 == 0)
assertTrue(out1.toLowerCase().contains("success"))
diff --git a/threat-model.md b/threat-model.md
index f3dd4bc97b5..5d7097ded83 100644
--- a/threat-model.md
+++ b/threat-model.md
@@ -3,7 +3,8 @@
> **Status: v1.0 — accepted (technical content). Pending wave-4 process
> items.** Wave-1/2/3/4 maintainer interviews completed 2026-05-14
> (Doris committer morningman). All technical `(inferred)` tags from
-> v0.1 have been resolved or consciously deferred.
+> v0.1 have been resolved or consciously deferred. Amended 2026-07-29
+> with wave 5 (M19, FE `enable_all_http_auth` / HTTP auth posture).
This document is the **security contract** for Apache Doris: what the
project assumes, what it guarantees given those assumptions, what it
@@ -280,6 +281,8 @@ Operational assumptions:
| `enable_python_udf_support` (BE) | **off** *(maintainer, M10)* |
Intentional. Operator must opt in to actually run Python UDFs | Default
deployment cannot execute Python UDFs even if FE accepts them |
| `numFailedLogin` (per-user, `CREATE USER ... FAILED_LOGIN_ATTEMPTS N`) | **0
/ DISABLED** *(maintainer, M11)* | (A) Off IS supported production posture;
operator must enable per user | §4.10 (NEW) requires per-user enable for any
account on a network-reachable client port |
| `passwordLockSeconds` (per-user, `... PASSWORD_LOCK_TIME T`) | **0 /
DISABLED** *(maintainer, M11)* | Same | Same |
+| `enable_all_http_auth` (FE, HTTP 8030) | **on**, **not runtime-mutable**
*(maintainer, M19)* | On **IS** the supported production posture. Turning it
off requires editing `fe.conf` and restarting — deliberately not an `ADMIN SET`
command, so disabling authentication is a recorded on-disk decision. A
migration aid for clusters upgrading from a release where it defaulted off, not
a supported steady state | On: §4.8 (11) (**authentication**) applies
unconditionally and a bypass is `VALID` [...]
+| `enable_all_http_auth` (BE, webserver 8040) | **off**, **not
runtime-mutable** *(maintainer, M19)* | Unchanged in this release — only the FE
default was flipped. BE 8040 is a Zone-2 port (§4.4) that operators are already
required to keep off end-user networks, so the compatibility cost of flipping
it was judged to outweigh the gain. Changing it requires editing `be.conf` and
restarting (`DEFINE_Bool`, not `DEFINE_mBool`) | Off: BE 8040 handlers declared
with the `NONE` privilege type a [...]
| `auth_type` | native | LDAP / Kerberos / OIDC are non-default backends | Out
of this row's scope (handled in family row 4) |
| Cluster shape: `on-prem` vs `cloud/` | on-prem | Both shapes supported
*(maintainer, Q2)* | Cloud adds Meta Service component (family row 3); cloud
has additional tenant-boundary claim per §4.8 |
@@ -289,6 +292,60 @@ per Q7 → §4.9 / §4.10. A vulnerability report of "I
brute-forced
account `analytics_user` in default config (no `FAILED_LOGIN_ATTEMPTS`
set)" is closed the same way per M11 → §4.9 / §4.10.
+**`enable_all_http_auth` now defaults on for FE** *(maintainer,
+M19)*. Prior releases shipped it off on both sides, so part of the FE
+HTTP surface — the metadata, statistics and admin REST endpoints, and
+the privilege check on part of the FE admin surface — answered without
+checking credentials or privileges. That default was a compatibility
+concession, not a security stance, and it has been flipped **on FE
+8030 only**. The consequence for the model: unauthenticated access to
+those FE endpoints is no longer disclaimed; it is a violation of §4.8
+(11) and reports of it are `VALID`. **Two carve-outs**: (a) FE
+`/metrics` on 8030 is public by design and the flag does not gate it —
+see §4.9; (b) **BE 8040 is unchanged** — its default stays off, and
+its `NONE`-privilege handlers remain disclaimed under §4.9. Do not
+assume the two ports behave alike.
+
+**Upgrade consequence, and what an operator turning it back off is
+buying.** A cluster upgrading from a release where the flag defaulted
+off may have callers that poll those FE endpoints anonymously —
+monitoring agents, health probes, ops scripts, and any tooling driving
+the FE `/api/**` REST surface. They start receiving 401 on upgrade.
+Metrics scrapers pointed at FE `/metrics` or at BE 8040 are **not**
+affected, since neither is gated by the FE flag. The supported fix
+is to give those callers credentials (§4.10 (12)). An operator who
+instead sets `enable_all_http_auth = false` has flipped a §4.5a knob
+toward the less-secure side and is back to the pre-flip exposure:
+findings that require the flag off are then closed
+`OUT-OF-MODEL: non-default-build`, and the operator owns the risk.
+
+**Where the effective value comes from.** FE reads `fe.conf` and then
+`${custom_config_dir}/fe_custom.conf`, and values in the latter
+overwrite the former. In releases where this flag was mutable, `ADMIN
+SET FRONTEND CONFIG (...) PROPERTIES("persist" = "true")` wrote to
+`fe_custom.conf`; making the flag non-mutable in this release neither
+removes nor migrates such a value. An upgraded cluster can therefore
+have no `false` entry anywhere in `fe.conf` and still start with
+authentication off. "`fe.conf` says true" is **not** sufficient
+evidence that a cluster is running with the flag on — read the
+effective value back (`/api/show_config`, `ADMIN SHOW FRONTEND
+CONFIG`) and check `fe_custom.conf`. Operators upgrading should delete
+any stale entry from `fe_custom.conf` to pick up the new default; see
+§4.10.
+
+**Security-testing baseline.** Security scans, penetration tests,
+fuzzing campaigns, and automated review agents MUST run against a
+cluster whose **effective** `enable_all_http_auth` is `true` on **FE**
+— i.e. **do not turn it off** for the test run, and confirm it is on
+(per the paragraph above, not by reading `fe.conf` alone) before
+reporting FE HTTP-surface coverage. On BE it defaults off; a test run may turn
+it on to probe BE 8040, but findings that require it to be on there
+are hardening observations, not defects. A run performed with the flag
+off does not constitute coverage of the HTTP surface: every finding it
+produces is `OUT-OF-MODEL: non-default-build`, and the findings that
+actually matter — endpoints that skip authentication or skip the
+caller's privilege check *with the flag on* — go unfound.
+
---
## 4.6 Assumptions about inputs
@@ -304,7 +361,13 @@ set)" is closed the same way per M11 → §4.9 / §4.10.
| FE MySQL 9030 | `iceberg.rest.uri` and similar URLs in `CREATE EXTERNAL
CATALOG` | **post-auth, attacker-controllable** *(maintainer, M13)* | operator:
only grant `CREATE CATALOG` privilege to admins; otherwise SSRF surface (§4.9) |
| FE HTTP 8030 | request bytes (pre-auth) | **untrusted** | memory safety |
| FE HTTP 8030 | request body (post-auth) | **untrusted within RBAC** | RBAC |
-| FE HTTP 8030 | `/api/show_proc`, admin REST surface | **post-auth,
privileged** | RBAC; admin-only endpoints must check |
+| FE HTTP 8030 | `/api/show_proc`, admin REST surface | **post-auth,
privileged** | RBAC; admin-only endpoints must check. The privilege check on
part of this surface is gated on `enable_all_http_auth` *(maintainer, M19)*,
**on** by default (§4.5a) — operator: do not turn it off |
+| FE HTTP 8030 | metadata / statistic / import REST endpoints | **post-auth**
*(maintainer, M19)* | nothing in default config — authenticated by §4.8 (11).
Callers must present credentials |
+| **FE HTTP 8030 `/metrics`** | **anonymous by design — NOT covered by
`enable_all_http_auth`** *(code-verified, M19)* | `MetricsAction` is
deliberately public and is not routed through the FE auth path; the flag does
not change it. Operator: keep 8030 off untrusted networks if FE metrics are
sensitive |
+| **FE HTTP 8030 `/api/health`** | **anonymous by design — NOT covered by
`enable_all_http_auth`** *(code-verified, M19)* | `HealthAction` is
deliberately public and the flag does not gate it. It discloses liveness plus
total/online backend counts. Excluded from §4.8 (11); see §4.11a before filing
it as a bypass |
+| **FE HTTP 8030 `/api/get_small_file`, `/api/bootstrap`** | **cluster token
(+ cluster id) instead of a user password** *(code-verified, M19)* | these are
the FE endpoints that BE, the CDC client and a joining FE call with only the
cluster token; the token is verified and is the credential, so
`enable_all_http_auth` does not additionally demand a password on the
token-bearing path. A leaked cluster token is a Zone-2 compromise (§4.5) |
operator: treat the cluster token as a secret; do n [...]
+| **FE HTTP 8030 `/api/streaming/commit_offset`,
`/api/streaming/report_task_failure`** | **cluster token only — no user
credential is accepted at all** *(code-verified, M19)* |
`StreamingJobAction.checkAuth` requires a `token` header and verifies it
against the cluster token; there is no password path. Same disposition as the
row above: token absent or invalid → `VALID`; no user password while a valid
token is presented → by design |
+| **FE HTTP 8030 `/api/{db}/{table}/_stream_load`** | **user password, or the
cluster token on the token-bearing path** *(code-verified, M19)* | the token
branch is a credential in the same sense as the rows above; the password branch
is covered by §4.8 (11) |
| FE Arrow Flight 8070 | handshake | **untrusted** | memory safety |
| FE Arrow Flight 8070 | result-stream consumption | mostly post-auth | RBAC |
| **BE Arrow Flight 8050** | **handshake bytes (pre-auth)** *(maintainer, M7)*
| **untrusted** | memory safety |
@@ -312,7 +375,7 @@ set)" is closed the same way per M11 → §4.9 / §4.10.
| FE 9020 (RPC) | all parameters | **trusted (Zone-2)** *(maintainer, Q1)* |
operator: network isolation |
| FE 9010 (edit log) | all parameters | **trusted (Zone-2)** | operator:
network isolation |
| BE 8060 (BRPC) | all parameters | **trusted (Zone-2)** *(maintainer, Q1)* |
operator: network isolation |
-| BE 8040 (webserver) | all requests | **trusted (Zone-2)** | operator: do not
expose to authenticated end-users (§4.11) |
+| BE 8040 (webserver) | all requests | **trusted (Zone-2)** | operator: do not
expose to authenticated end-users (§4.11). `enable_all_http_auth` still
defaults **off** here — only the FE default was flipped *(maintainer, M19)* —
so handlers declared with the `NONE` privilege type answer without credentials
in default config. Network isolation is the control |
| BE 9050 (heartbeat) | FE→BE control msgs | **trusted (Zone-2)** | operator:
network isolation |
| BE 9060 (BE↔BE) | fragment exec, data transfer | **trusted (Zone-2)** |
operator: network isolation |
| Broker (Thrift) | all parameters | **trusted (Zone-2)** | operator: network
isolation |
@@ -424,6 +487,124 @@ provenance.
take effect; counter resets unexpectedly; wraparound. *Severity*:
**security-critical** when the configured behavior is broken
(NOT when default is unconfigured — see §4.9).
+11. **HTTP *authentication* on the FE HTTP surface** *(maintainer,
+ M19)*. *Condition*: default config — `enable_all_http_auth` ships
+ **on** for FE (8030) (§4.5a). Scope:
+ - **FE 8030 — every endpoint that routes through the FE auth
+ path** (the `/api/**` and `/rest/v2/**` REST actions), which
+ must establish a caller identity — valid user credentials, or
+ one of the credential forms listed under *Excluded* below —
+ before returning data or performing an action.
+
+ **Read the property title literally: this is a claim about
+ authentication, not a blanket claim about authorization.** The
+ default flip made the FE HTTP surface demand a credential; it did
+ **not** introduce a centralized privilege check. Authorization on
+ FE HTTP is per-handler, and its coverage is uneven. Handlers that
+ do run a privilege check (for example `checkAdminAuth`, or a
+ per-object `PrivPredicate.SHOW` filter) are covered by property
+ (12) below; the ones that do not are listed as known gaps there.
+ Do not cite this property as evidence that a given FE endpoint
+ enforces the caller's privileges — check the handler.
+
+ **BE 8040 is not in this property.** Its `enable_all_http_auth`
+ default is unchanged (off), so its `NONE`-privilege handlers stay
+ disclaimed under §4.9 and are governed by Zone-2 network
+ isolation, not by this property.
+
+ *Violation symptom*: an in-scope endpoint answers a request that
+ carries no credential of any accepted form, or accepts invalid
+ credentials. *Severity*: **security-critical**. Reports of this
+ shape are `VALID`.
+
+ *Excluded from this property* — this list is meant to be
+ exhaustive; a path that belongs here and is missing is a defect in
+ this document, not a finding:
+ - (a) **FE `/metrics`**, deliberately anonymous, not gated by the
+ flag — see §4.9.
+ - (b) **FE `/api/health`** (`HealthAction`), deliberately
+ anonymous and not gated by the flag either. It returns liveness
+ plus total/online backend counts. A report that `/api/health`
+ answers an unauthenticated request is
+ `BY-DESIGN: property-disclaimed`, not a violation.
+ - (c) Clusters where the operator has set
+ `enable_all_http_auth = false` — a §4.5a knob flipped toward the
+ less-secure side, closed `OUT-OF-MODEL: non-default-build`.
+ Note that the effective value can come from `fe_custom.conf`,
+ which is read after and overwrites `fe.conf`; see §4.5a.
+ - (d) The **cluster-token authenticated** endpoints, where the
+ cluster token *is* the credential — authenticated, just not by
+ user password: FE `/api/get_small_file`; FE `/api/bootstrap`
+ when `cluster_id`+`token` are presented; FE
+ `/api/streaming/commit_offset` and
+ `/api/streaming/report_task_failure`, which accept **only** a
+ `token` header and no user credential at all; the token branch
+ of `/api/{db}/{table}/_stream_load`; and the BE handlers that
+ accept an auth token. A report that one of these serves a
+ request without a user password, while presenting a valid
+ cluster token, is `BY-DESIGN: property-disclaimed`. A report
+ that one of them accepts an *invalid* or absent token is
+ `VALID` — **with one recorded exception**: `/api/bootstrap`
+ validates the pair only on the ready path, so while the FE is
+ not ready any two non-empty strings are accepted and the caller
+ learns "not ready" and nothing else. That branch is a known,
+ deliberate asymmetry (§4.14), not a bypass of this property.
+
+12. **Per-handler authorization on the FE HTTP surface, where the
+ handler implements it** *(maintainer, M19)*. *Condition*: property
+ (11) holds and the endpoint in question runs a privilege check.
+ *Violation symptom*: a handler that performs a privilege check
+ returns data, or performs an action, that the authenticated caller
+ has no privilege for — for example a metadata listing that leaks
+ objects the caller has no `SHOW` privilege on, or an admin action
+ reachable without `ADMIN_PRIV`. *Severity*:
+ **security-critical**. Reports of this shape are `VALID`.
+
+ **Known gaps — password-only handlers, not yet covered by this
+ property.** These authenticate but perform no SQL-equivalent
+ authorization. They are recorded here so that a report against
+ them is triaged as a *known, accepted gap* rather than silently
+ treated as covered; closing them is tracked in §4.14. **This list
+ is maintained by inspection and has been wrong before — treat it
+ as the current best inventory, not as a proof of completeness. A
+ password-only handler that is missing from it is a defect in this
+ document, and the finding against the handler still stands.**
+ - `/api/backends` (`BackendsAction`) — any valid account can
+ enumerate backend host/port/liveness. Intentional: the
+ Flink/Spark connectors call it with an ordinary load account to
+ discover backends before a stream load.
+ - `POST /rest/v2/api/storage_policy` (`AddStoragePolicyAction`) —
+ any valid account can journal a global storage policy, whereas
+ the SQL equivalent (`CREATE POLICY`) requires global
+ `ADMIN_PRIV`. This is a **privilege gap, not a design choice**.
+ - `GET /rest/v2/api/es_catalog/get_mapping` and
+ `POST /rest/v2/api/es_catalog/search` (`ESCatalogAction`) — any
+ valid account can issue raw mapping/search requests through the
+ catalog's server-side credentials with no `SHOW`/`SELECT` check.
+ - `POST /rest/v2/api/import/file_review` (`ImportAction`) — any
+ valid account can make FE list and read a caller-supplied
+ external location with no `LOAD` or `ADMIN` check (outbound
+ request from FE).
+ - `GET /rest/v2/api/cluster_overview` (`StatisticAction`) — any
+ valid account reads cluster-wide `dbCount` / `tblCount` /
+ `beCount` / `feCount` / `diskOccupancy` / `remainDisk`.
+ - `POST /api/query_schema/{ns}/{db}` (`StmtExecutionAction`) — any
+ valid account can dump the `CREATE TABLE` DDL of arbitrary
+ tables by naming them in the submitted SQL. The mechanism is
+ worth recording because it is not obvious: the handler plans the
+ statement at `ExplainLevel.ANALYZED_PLAN`, and
+ `NereidsPlanner.planWithoutLock` returns at that level *before*
+ running the rewriter — while `CheckPrivileges` is a **rewrite**
+ rule (`Rewriter`, `RuleType.CHECK_PRIVILEGES`). So the usual
+ "Nereids checks privileges" assumption does not hold on this
+ path. Unchanged by the default flip: this handler always
+ required a credential and never checked privileges.
+
+ Until these carry a privilege check, a report of "authenticated
+ low-privilege user performed X here" against one of them is
+ `BY-DESIGN: property-disclaimed` **with this section cited**, and
+ should be filed against the §4.14 follow-up rather than closed as
+ noise.
**Resource properties** — *threshold*: **NONE**. Doris explicitly
makes **no** quantitative or categorical resource guarantee on a
@@ -445,6 +626,29 @@ State plainly:
— accounts have no lockout unless the operator enables it via
`CREATE USER ... FAILED_LOGIN_ATTEMPTS N PASSWORD_LOCK_TIME T`.
See §4.10 for the obligation.
+- **No authentication on FE `/metrics`** *(code-verified, M19)*.
+ `MetricsAction` on FE 8030 is public by design and is **not**
+ gated by `enable_all_http_auth`; turning the flag on does not
+ change it. Anyone who can reach 8030 can read FE metrics —
+ cluster topology hints, query and load counters, JVM state.
+ Operator: if FE metrics are sensitive, restrict 8030 at the
+ network layer (§4.10 (1)).
+- **No authentication on the BE 8040 handlers declared with the
+ `NONE` privilege type** *(maintainer, M19)*. BE ships
+ `enable_all_http_auth = false`; only the FE default was flipped.
+ BE metrics, health, and some debug / metadata endpoints answer
+ without credentials. BE 8040 is a Zone-2 port (§4.4) and §4.3 (2)
+ already requires it to be unreachable from end users; network
+ isolation is the control. An operator who wants it closed sets
+ `enable_all_http_auth = true` in `be.conf` and restarts.
+- **No HTTP API authentication once the operator sets
+ `enable_all_http_auth = false` on FE** *(maintainer, M19)*. The FE
+ flag ships **on** and FE HTTP authn/authz is a stated property in
+ default config (§4.8 (11)). Turning it off restores the pre-flip
+ behavior — the metadata, statistics and import REST endpoints, and
+ the privilege check on part of the FE admin REST surface, stop
+ checking credentials. Doris makes no security claim about a cluster
+ running that way; see §4.5a and §4.10 (12).
- **No defense against query-DoS or query-OOM by an authenticated
user** *(maintainer, Q5)*. Operator must use the §4.10 (3) knob
set.
@@ -570,6 +774,38 @@ The operator MUST:
them issue HTTP requests from FE to attacker-chosen URLs (SSRF)
via Iceberg REST catalog. If you must grant it more broadly,
apply network egress controls at the FE host level.
+12. **Keep `enable_all_http_auth` on, and migrate HTTP callers onto
+ credentials** *(maintainer, M19)*. It ships on; do not turn it
+ off. When upgrading from a release where it defaulted off, the
+ migration the operator owes:
+ (a) enumerate everything that calls the FE 8030 `/api/**` and
+ `/rest/v2/**` surface — monitoring and alerting agents, load
+ tooling, health probes, Kubernetes liveness/readiness checks,
+ internal ops scripts. FE `/metrics` and BE 8040 are not affected;
+ scrapers pointed only at those need no change;
+ (b) give each of them credentials (HTTP Basic) and a Doris user
+ with only the privileges it needs;
+ (c) verify in staging before upgrading production — anything
+ still polling anonymously will start getting 401;
+ (d) **check `fe_custom.conf`, not only `fe.conf`.** It is read
+ after `fe.conf` and overwrites it, and an earlier release where
+ this flag was mutable may have persisted
+ `enable_all_http_auth=false` into it via `ADMIN SET FRONTEND
+ CONFIG ... persist=true`. That value survives the upgrade, so the
+ new default silently does not take effect. Delete the stale entry,
+ then confirm the effective value with `ADMIN SHOW FRONTEND CONFIG`.
+ The flag is **not runtime-mutable**: turning it off means editing
+ `fe.conf` and restarting the node.
+ That is deliberate (M19) — disabling authentication should be a
+ recorded, on-disk decision, not a runtime command, so the running
+ posture is always auditable from the config files. "The config
+ files" is plural on purpose: audit `fe.conf` **and**
+ `fe_custom.conf`. It also means
+ an upgrade surprise cannot be papered over with
+ `ADMIN SET FRONTENDS CONFIG`; if you must fall back, do it in the
+ config file and treat it as a temporary migration aid, not a
+ resting state — it re-opens the §4.9 exposure and puts the
+ cluster outside §4.8 (11).
---
@@ -601,6 +837,15 @@ The operator MUST:
- **Leaving accounts on a network-adjacent client port without
`FAILED_LOGIN_ATTEMPTS`** *(maintainer, M11)*. Brute-forceable
with no server-side lockout.
+- **Turning FE `enable_all_http_auth` off to unbreak an anonymous
+ monitoring agent or ops script after an upgrade, and leaving it
+ off** *(maintainer, M19)*. It is a migration aid. The fix is to
+ give the caller credentials; see §4.10 (12).
+- **Performing a security assessment of the FE HTTP surface with
+ `enable_all_http_auth` turned off** *(maintainer, M19)*. Every
+ finding it produces is `OUT-OF-MODEL: non-default-build`, and the
+ real bypasses — endpoints that skip authn/authz with the flag on —
+ go unfound. See the security-testing baseline in §4.5a.
---
@@ -657,6 +902,43 @@ primary; cite externally only when closing a specific
report**
- **"Workload group / resource tag does not isolate cross-user
data."** — `BY-DESIGN: property-disclaimed` per §4.9 false-friends
(M12).
+- **"FE `/metrics` (port 8030) is readable without
+ credentials."** — `BY-DESIGN: property-disclaimed` per §4.9
+ (M19). Public by design; not gated by `enable_all_http_auth`.
+- **"FE `/api/health` (port 8030) answers without credentials and
+ discloses backend counts."** — `BY-DESIGN: property-disclaimed` per
+ §4.8 (11) exclusion (b) (M19). `HealthAction` is deliberately public
+ and the flag does not gate it; the response is liveness plus
+ total/online backend counts. Excluded from property (11) explicitly,
+ so this is not a bypass.
+- **"FE `/api/streaming/commit_offset` (or
+ `/api/streaming/report_task_failure`) accepts a request with no user
+ credential."** — `BY-DESIGN: property-disclaimed` per §4.8 (11)
+ exclusion (d) (M19). These take a cluster-token `token` header and
+ nothing else; the token is the credential. A report that they accept
+ an *absent or invalid* token is `VALID`.
+- **"An authenticated low-privilege user could create a storage
+ policy / query Elasticsearch / make FE fetch an external URL over
+ HTTP."** — `BY-DESIGN: property-disclaimed` per §4.8 (12) known
+ gaps (M19), and file against the §4.14 follow-up. These handlers
+ authenticate but do not authorize; that is a recorded open gap, not
+ a violation of a property the project currently claims.
+- **"BE 8040 endpoint X answers an unauthenticated request."** —
+ `BY-DESIGN: property-disclaimed` per §4.9 (M19). The BE default is
+ unchanged (off) and BE 8040 is a Zone-2 port; network isolation is
+ the control. An operator may set `enable_all_http_auth = true` in
+ `be.conf` to close it, but the default is not a defect.
+- **"FE 8030 endpoint X returns metadata to an unauthenticated
+ caller"** *(maintainer, M19)* — **not a non-finding by default.**
+ The FE default is on, so this is `VALID` per §4.8 (11) unless the
+ reporter had turned the flag off, in which case it is
+ `OUT-OF-MODEL: non-default-build` per §4.5a. **Always confirm which
+ config was tested before closing** — ask for the *effective*
+ `enable_all_http_auth` value on the tested cluster, i.e. the output
+ of `ADMIN SHOW FRONTEND CONFIG` or `/api/show_config`, plus both
+ `fe.conf` and `fe_custom.conf`. The flag is not runtime-mutable, but
+ `fe_custom.conf` is read after `fe.conf` and overwrites it, so
+ `fe.conf` alone is **not** authoritative (§4.5a).
---
@@ -674,6 +956,17 @@ periodic review). Triggers:
§4.9 disclaimer drops, §4.11a entry drops.
- `enable_java_udf` default flips off (M10): §4.10 (4) becomes
conditional on a §4.5a non-default knob.
+- `enable_all_http_auth` FE default flips back to **off** (M19
+ inverted, e.g. if the upgrade breakage forces a revert): §4.8 (11)
+ reverts to a conditional property, §4.9 re-states the
+ unauthenticated-HTTP disclaimer unconditionally, §4.10 (12)
+ becomes "turn it on" rather than "keep it on", and the §4.11a
+ entry inverts back to a default non-finding. *(This trigger fired
+ once already, in the on direction, on 2026-07-29 — see §4.14
+ wave 5.)*
+- `enable_all_http_auth` BE default flips **on** (M19 extended to
+ BE 8040): §4.8 (11) extends to every `HttpHandlerWithAuth` handler,
+ the §4.9 BE disclaimer and the §4.11a BE non-finding both drop.
- Iceberg REST URL gains validation / localhost-blocking (M13):
SSRF moves from §4.9 attack-class to §4.8 property; §4.11 misuse
drops.
@@ -740,6 +1033,12 @@ the body. Summary table:
| M17 | Revision cadence | Trigger-driven only (§4.12 events); no periodic
review |
| M18 | §4.11a publication | Internal primary; cite externally only when
closing a specific report |
+**Wave 5 — RESOLVED 2026-07-29.** HTTP surface:
+
+| ID | Topic | Outcome |
+|---|---|---|
+| M19 | `enable_all_http_auth` (FE) | **FE default flipped to `true` on
2026-07-29, and the flag is not runtime-mutable** — it can only be changed on
disk (`fe.conf`, or `fe_custom.conf` which overwrites it) with a restart, so
the running posture is auditable from disk and cannot be silently dropped at
runtime — but auditing it means reading **both** files, since a pre-flip
release could have persisted `false` into `fe_custom.conf` (§4.5a). FE HTTP
**authentication** is now a default-con [...]
+
**Open follow-up items (not blocking v1.0 acceptance):**
- Add `model-version` field to top of this doc per M15. Currently
@@ -748,6 +1047,64 @@ the body. Summary table:
- Consider opening upstream issues per M10 (UDF default-off
proposal), M11 (default lockout proposal), M13 (Iceberg URL
validation). Each is a §4.12 trigger if accepted.
+- Per M19, the FE `enable_all_http_auth` default flip is a **breaking
+ change for anonymous callers of the FE `/api/**` and `/rest/v2/**`
+ surface** (health probes, ops scripts, management tooling) upgrading
+ from an earlier release. Release notes and the upgrade guide must
+ carry it, with the `enable_all_http_auth = false` fallback (in
+ `fe.conf`, requiring a restart — the flag is not runtime-mutable)
+ documented as temporary per §4.10 (12).
+- Per M19, whether to extend the flip to BE 8040 is **open**. The BE
+ default stays off in this change; the compatibility cost there is
+ higher (metrics scrapers point at BE 8040) and BE 8040 is already
+ required to be off end-user networks by §4.3 (2). Tracked as a
+ §4.12 trigger.
+- Per M19, **the password-only FE handlers listed under §4.8 (12) are
+ an open gap**: `AddStoragePolicyAction`, `ESCatalogAction`,
+ `ImportAction`, `StatisticAction` and `StmtExecutionAction`'s
+ `query_schema` authenticate but perform no SQL-equivalent
+ authorization, so any valid account can respectively journal a
+ global storage policy, query Elasticsearch through the catalog's
+ server-side credentials, make FE read a caller-supplied external
+ location, read cluster-wide capacity statistics, and dump arbitrary
+ table DDL. The default flip does not close these — it only means the
+ caller must now hold *some* account. Adding the privilege checks
+ (with negative low-privilege regression tests) is deliberately out
+ of scope for the flip itself and is tracked here. Until then they
+ are triaged per §4.8 (12), not as noise.
+ The `query_schema` one deserves priority: it is a genuine read of
+ another tenant's schema, and the reason it slips through — planning
+ stops at `ANALYZED_PLAN`, before the rewrite-phase `CheckPrivileges`
+ rule — may well apply to other callers of the planner that ask for
+ an analyzed-only plan. That is worth a sweep, not just a point fix.
+- Per M19, **the cloud overdue-warehouse fence on FE HTTP is opt-in
+ per handler, not centralized.** `BaseController.checkWithCookie`'s
+ `checkAuth` flag gates two unrelated things at once: the global
+ `ADMIN_OR_NODE` requirement and, in cloud mode, the overdue check.
+ Handlers that pass `false` to do their own narrower authorization
+ therefore also lose the fence, and must call
+ `checkInstanceOverdueIfCloud` explicitly — the metadata controllers
+ (`MetaInfoAction`, `MetaInfoActionV2`) now do. This is deliberately
+ not folded into `checkWithCookie`: `/api/query` also passes `false`
+ but hands the statement to a real JDBC session that enforces the
+ overdue state itself and reports it as `COMMON_ERROR`; moving that
+ rejection up would silently change the endpoint's response code to
+ `UNAUTHORIZED`. Anyone "simplifying" this back into
+ `checkWithCookie` will break that contract — the overloaded flag is
+ the underlying wart, and unpicking it properly is a follow-up.
+- Per M19, **`/api/bootstrap` accepts the cluster-id/token pair
+ unverified while the FE is not ready.** The pair is only validated
+ on the ready path, so any caller supplying two non-empty strings can
+ learn that a node is not ready; nothing else is disclosed on that
+ branch. Validating earlier would mean comparing against
+ `getClusterId()`/`getToken()` before the cluster is established and
+ would turn "not ready" into "invalid cluster id" for a legitimately
+ joining FE, so it was left alone here. If the not-ready branch ever
+ grows a richer response, this must be revisited first.
+- Per M19, the upgrade guide must also tell operators to check
+ `fe_custom.conf`, not just `fe.conf`: a persisted
+ `enable_all_http_auth=false` written by an earlier mutable release
+ survives the upgrade and silently keeps authentication off (§4.5a).
---
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]