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]

Reply via email to