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 163a10a3c44 [improvement](fe) add fe_meta_auth_token for FE 
meta-service internal HTTP auth (#65551)
163a10a3c44 is described below

commit 163a10a3c44bf298ad964ea84e360667ab3abd99
Author: Calvin Kirs <[email protected]>
AuthorDate: Thu Jul 16 12:06:28 2026 +0800

    [improvement](fe) add fe_meta_auth_token for FE meta-service internal HTTP 
auth (#65551)
    
    ### What problem does this PR solve?
    
    The FE meta-service endpoints
    (`image`/`role`/`check`/`put`/`journal_id`, etc.) authenticate callers
    only by the `CLIENT_NODE_HOST`/`CLIENT_NODE_PORT` headers — i.e. whether
    the claimed host:port is a registered FE. That is a plaintext claim with
    no secret, so any host that knows a valid FE address can impersonate it.
    
    ### What this PR does
    
    Add an optional cluster token `fe_meta_auth_token`:
    
    - **Empty (default)** — behavior is unchanged: node-host check only.
    Existing clusters and rolling upgrades are unaffected.
    - **Set** — `checkFromValidFe` additionally requires the request to
    carry a matching token header, **on top of** the existing node-host
    check (additive, does not replace the host check).
    
    The token is a static `fe.conf` item, so a scaling-out FE already holds
    it before the bootstrap handshake — no chicken-and-egg with the token
    that `/check` itself hands out. It must be identical on all FEs.
    
    Additional hardening of the meta-service:
    - `/put` rejects a port other than the FE HTTP port.
    - `/dump` always checks the admin password.
    - meta-helper logs header **names** only, never the token value.
    
    ### Tests
    
    - `MetaServiceTest`: matching / missing / wrong token,
    no-token-when-unconfigured, unknown-host-rejected-even-with-token,
    `/put` port check, `/dump` auth.
    - `HttpURLUtilTest`: token header emission (present/absent) and internal
    URL building.
    
    This supersedes #63782 (rebased onto latest master with a cleaner,
    switch-free design).
---
 .../main/java/org/apache/doris/common/Config.java  |  14 +-
 .../java/org/apache/doris/common/ConfigBase.java   |  20 +-
 .../java/org/apache/doris/common/ConfigTest.java   |  58 ++++
 .../org/apache/doris/common/util/HttpURLUtil.java  |  10 +
 .../org/apache/doris/httpv2/meta/MetaService.java  |  77 +++++-
 .../doris/httpv2/rest/manager/NodeAction.java      |  13 +-
 .../java/org/apache/doris/master/MetaHelper.java   |   2 +-
 .../apache/doris/common/util/HttpURLUtilTest.java  |  59 +++++
 .../apache/doris/httpv2/meta/MetaServiceTest.java  | 295 +++++++++++++++++++++
 .../suites/meta_action_p0/test_dump_image.groovy   |   1 +
 10 files changed, 532 insertions(+), 17 deletions(-)

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 39f29b958d5..d59d4728256 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
@@ -491,7 +491,7 @@ public class Config extends ConfigBase {
                     + "starts for the first time. You can also specify one."})
     public static int cluster_id = -1;
 
-    @ConfField(description = {"Cluster token used for internal 
authentication."})
+    @ConfField(sensitive = true, description = {"Cluster token used for 
internal authentication."})
     public static String auth_token = "";
 
     @ConfField(mutable = true, masterOnly = true,
@@ -825,6 +825,18 @@ public class Config extends ConfigBase {
     // check token when download image file.
     @ConfField public static boolean enable_token_check = true;
 
+    @ConfField(sensitive = true, description = {"Cluster token for FE 
meta-service internal HTTP authentication. "
+            + "When set (non-empty), FE meta-service endpoints (such as 
image/role/check/put/journal_id) "
+            + "additionally require the caller to present a matching token 
header, on top of the existing "
+            + "node-host check. Empty (default) keeps the legacy behavior of 
node-host check only, so "
+            + "existing clusters and rolling upgrades are unaffected. Must be 
identical on all FEs and "
+            + "provisioned in fe.conf before enabling, otherwise FEs will 
reject each other.",
+            "FE meta-service 内部 HTTP 鉴权使用的集群 token。设置(非空)后,meta-service 端点(如 "
+            + "image/role/check/put/journal_id)在原有 node-host 校验之上,额外要求调用方携带匹配的 
token 头。"
+            + "为空(默认)时维持仅 node-host 校验的旧行为,存量集群与滚动升级不受影响。必须在所有 FE 上取值一致,"
+            + "并在启用前写入 fe.conf,否则 FE 之间会互相拒绝。"})
+    public static String fe_meta_auth_token = "";
+
     /**
      * Set to true if you deploy Palo using thirdparty deploy manager
      * Valid options are:
diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/ConfigBase.java 
b/fe/fe-common/src/main/java/org/apache/doris/common/ConfigBase.java
index 192fdea7c8b..e58fea913d7 100644
--- a/fe/fe-common/src/main/java/org/apache/doris/common/ConfigBase.java
+++ b/fe/fe-common/src/main/java/org/apache/doris/common/ConfigBase.java
@@ -52,6 +52,10 @@ public class ConfigBase {
 
         boolean masterOnly() default false;
 
+        // If true, the value is a secret (e.g. a token or password) and is 
masked in every
+        // config dump API (Config.dump / getConfigInfo), so it is never 
returned in plaintext.
+        boolean sensitive() default false;
+
         String comment() default "";
 
         VariableAnnotation varType() default VariableAnnotation.NONE;
@@ -191,13 +195,26 @@ public class ConfigBase {
         }
     }
 
+    // Placeholder returned instead of a sensitive config's real value in any 
dump API.
+    public static final String SENSITIVE_CONF_MASK = "********";
+
+    // Mask the value of a sensitive config (a non-empty secret) so it is 
never dumped in plaintext.
+    // An empty value is left as-is: it reveals nothing and keeps "unset" 
visible.
+    private static String maskIfSensitive(Field field, String value) {
+        ConfField anno = field.getAnnotation(ConfField.class);
+        if (anno != null && anno.sensitive() && !Strings.isNullOrEmpty(value)) 
{
+            return SENSITIVE_CONF_MASK;
+        }
+        return value;
+    }
+
     public static HashMap<String, String> dump() {
         HashMap<String, String> map = new HashMap<>();
         Field[] fields = confClass.getFields();
         for (Field f : fields) {
             ConfField anno = f.getAnnotation(ConfField.class);
             if (anno != null) {
-                map.put(f.getName(), getConfValue(f));
+                map.put(f.getName(), maskIfSensitive(f, getConfValue(f)));
             }
         }
         return map;
@@ -441,6 +458,7 @@ public class ConfigBase {
                 if (confKey.equals("sys_log_dir") && 
Strings.isNullOrEmpty(value)) {
                     value = System.getenv("DORIS_HOME") + "/log";
                 }
+                value = maskIfSensitive(f, value);
                 config.add(value);
                 config.add(f.getType().getSimpleName());
                 config.add(String.valueOf(confField.mutable()));
diff --git a/fe/fe-common/src/test/java/org/apache/doris/common/ConfigTest.java 
b/fe/fe-common/src/test/java/org/apache/doris/common/ConfigTest.java
index 0d305298270..d48d7ae8ed3 100644
--- a/fe/fe-common/src/test/java/org/apache/doris/common/ConfigTest.java
+++ b/fe/fe-common/src/test/java/org/apache/doris/common/ConfigTest.java
@@ -23,6 +23,8 @@ import org.junit.Test;
 
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
 
 public class ConfigTest {
     @BeforeClass
@@ -34,6 +36,62 @@ public class ConfigTest {
         config.init(tempFile.toAbsolutePath().toString());
     }
 
+    // A sensitive config (fe_meta_auth_token) must never be dumped in 
plaintext by any config
+    // API: both Config.dump() and ConfigBase.getConfigInfo() return the mask 
instead of the value.
+    @Test
+    public void testSensitiveConfigIsMaskedWhenSet() {
+        String old = Config.fe_meta_auth_token;
+        try {
+            Config.fe_meta_auth_token = "super-secret-token";
+
+            Map<String, String> dumped = ConfigBase.dump();
+            Assert.assertEquals(ConfigBase.SENSITIVE_CONF_MASK, 
dumped.get("fe_meta_auth_token"));
+
+            String value = configInfoValue("fe_meta_auth_token");
+            Assert.assertEquals(ConfigBase.SENSITIVE_CONF_MASK, value);
+        } finally {
+            Config.fe_meta_auth_token = old;
+        }
+    }
+
+    // The legacy cluster secret auth_token is also marked sensitive, so it is 
masked by every
+    // config dump API too (it leaks through /rest/v1/config/fe otherwise).
+    @Test
+    public void testAuthTokenIsMaskedWhenSet() {
+        String old = Config.auth_token;
+        try {
+            Config.auth_token = "super-secret-auth-token";
+
+            Assert.assertEquals(ConfigBase.SENSITIVE_CONF_MASK, 
ConfigBase.dump().get("auth_token"));
+            Assert.assertEquals(ConfigBase.SENSITIVE_CONF_MASK, 
configInfoValue("auth_token"));
+        } finally {
+            Config.auth_token = old;
+        }
+    }
+
+    // An empty sensitive config is left as-is (no secret to hide), so "unset" 
stays visible.
+    @Test
+    public void testEmptySensitiveConfigIsNotMasked() {
+        String old = Config.fe_meta_auth_token;
+        try {
+            Config.fe_meta_auth_token = "";
+
+            Assert.assertEquals("", 
ConfigBase.dump().get("fe_meta_auth_token"));
+            Assert.assertEquals("", configInfoValue("fe_meta_auth_token"));
+        } finally {
+            Config.fe_meta_auth_token = old;
+        }
+    }
+
+    private static String configInfoValue(String key) {
+        for (List<String> row : ConfigBase.getConfigInfo(null)) {
+            if (row.get(0).equals(key)) {
+                return row.get(1);
+            }
+        }
+        throw new IllegalStateException("config not found: " + key);
+    }
+
     @Test
     public void testSetEmptyArray() throws ConfigException {
         ConfigBase.setMutableConfig("s3_load_endpoint_white_list", "a,b,c");
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/common/util/HttpURLUtil.java 
b/fe/fe-core/src/main/java/org/apache/doris/common/util/HttpURLUtil.java
index feb88abbb1c..f1fa2f87d40 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/common/util/HttpURLUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/common/util/HttpURLUtil.java
@@ -20,8 +20,10 @@ package org.apache.doris.common.util;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.cloud.security.SecurityChecker;
 import org.apache.doris.common.Config;
+import org.apache.doris.httpv2.meta.MetaBaseAction;
 import org.apache.doris.system.SystemInfoService.HostInfo;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.Maps;
 import org.apache.http.conn.ssl.NoopHostnameVerifier;
 
@@ -50,6 +52,10 @@ public class HttpURLUtil {
             HostInfo selfNode = Env.getServingEnv().getSelfNode();
             conn.setRequestProperty(Env.CLIENT_NODE_HOST_KEY, 
selfNode.getHost());
             conn.setRequestProperty(Env.CLIENT_NODE_PORT_KEY, 
selfNode.getPort() + "");
+            String token = Config.fe_meta_auth_token;
+            if (!Strings.isNullOrEmpty(token)) {
+                conn.setRequestProperty(MetaBaseAction.TOKEN, token);
+            }
             return conn;
         } catch (Exception e) {
             throw new IOException(e);
@@ -65,6 +71,10 @@ public class HttpURLUtil {
         HostInfo selfNode = Env.getServingEnv().getSelfNode();
         headers.put(Env.CLIENT_NODE_HOST_KEY, selfNode.getHost());
         headers.put(Env.CLIENT_NODE_PORT_KEY, selfNode.getPort() + "");
+        String token = Config.fe_meta_auth_token;
+        if (!Strings.isNullOrEmpty(token)) {
+            headers.put(MetaBaseAction.TOKEN, token);
+        }
         return headers;
     }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/meta/MetaService.java 
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/meta/MetaService.java
index 79a34ad4d38..4a8acdce33d 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/httpv2/meta/MetaService.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/httpv2/meta/MetaService.java
@@ -20,11 +20,15 @@ package org.apache.doris.httpv2.meta;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.common.Config;
 import org.apache.doris.common.DdlException;
+import org.apache.doris.common.util.HttpURLUtil;
 import org.apache.doris.common.util.NetUtils;
 import org.apache.doris.ha.FrontendNodeType;
+import 
org.apache.doris.httpv2.controller.BaseController.ActionAuthorizationInfo;
 import org.apache.doris.httpv2.entity.ResponseEntityBuilder;
+import org.apache.doris.httpv2.exception.UnauthorizedException;
 import org.apache.doris.httpv2.rest.RestBaseController;
 import org.apache.doris.master.MetaHelper;
+import org.apache.doris.mysql.privilege.PrivPredicate;
 import org.apache.doris.persist.MetaCleaner;
 import org.apache.doris.persist.Storage;
 import org.apache.doris.persist.StorageInfo;
@@ -55,33 +59,76 @@ public class MetaService extends RestBaseController {
 
     private File imageDir = MetaHelper.getMasterImageDir();
 
-    private boolean isFromValidFe(String clientHost, String clientPortStr) {
+    private Frontend getValidFe(String clientHost, String clientPortStr) {
         Integer clientPort;
         try {
             clientPort = Integer.valueOf(clientPortStr);
         } catch (Exception e) {
             LOG.warn("get clientPort error. clientPortStr: {}", clientPortStr, 
e.getMessage());
-            return false;
+            return null;
         }
 
         Frontend fe = Env.getCurrentEnv().checkFeExist(clientHost, clientPort);
         if (fe == null) {
             LOG.warn("request is not from valid FE. client: {}, {}", 
clientHost, clientPortStr);
-            return false;
         }
-        return true;
+        return fe;
     }
 
     private void checkFromValidFe(HttpServletRequest request)
-            throws InvalidClientException {
+            throws UnauthorizedException {
         String clientHost = request.getHeader(Env.CLIENT_NODE_HOST_KEY);
         String clientPort = request.getHeader(Env.CLIENT_NODE_PORT_KEY);
-        if (!isFromValidFe(clientHost, clientPort)) {
-            throw new InvalidClientException("invalid client host: " + 
clientHost + ":" + clientPort
-                + ", request from " + request.getRemoteHost());
+        Frontend fe = getValidFe(clientHost, clientPort);
+        if (fe == null) {
+            throw unauthorized(clientHost, clientPort, request);
+        }
+
+        // If a cluster meta auth token is configured, additionally require 
the request to
+        // carry a matching token. An empty token keeps the legacy 
node-host-only behavior,
+        // so existing clusters and rolling upgrades are unaffected.
+        String clusterToken = Config.fe_meta_auth_token;
+        if (!Strings.isNullOrEmpty(clusterToken)) {
+            String requestToken = request.getHeader(MetaBaseAction.TOKEN);
+            if (!clusterToken.equals(requestToken)) {
+                // Log a masked prefix of both tokens so token-rotation issues 
are diagnosable
+                // (e.g. expected "abc***" vs actual "<empty>" means the peer 
sent no token),
+                // while never revealing the full secret.
+                LOG.warn("reject meta request with invalid token. client: {}, 
{}, request from: {}, "
+                                + "expected: {}, actual: {}",
+                        clientHost, clientPort, request.getRemoteAddr(),
+                        maskToken(clusterToken), maskToken(requestToken));
+                throw unauthorized(clientHost, clientPort, request);
+            }
         }
     }
 
+    private UnauthorizedException unauthorized(String clientHost, String 
clientPort, HttpServletRequest request) {
+        return new UnauthorizedException("invalid client host: " + clientHost 
+ ":" + clientPort
+                + ", request from " + request.getRemoteAddr());
+    }
+
+    // Minimum token length required before we reveal a masked prefix in logs. 
Shorter tokens would
+    // leak too large a fraction of the secret, so they are hidden entirely 
with only a length hint.
+    private static final int MIN_TOKEN_LEN_FOR_PREFIX = 8;
+    private static final int TOKEN_PREFIX_LEN = 3;
+
+    /**
+     * Masks a token for logging: reveals only a short leading prefix (e.g. 
"abc***") so that a
+     * token mismatch is diagnosable during rotation, while never logging the 
full secret. Empty
+     * tokens and tokens too short to safely show a prefix are hidden.
+     */
+    private static String maskToken(String token) {
+        if (Strings.isNullOrEmpty(token)) {
+            return "<empty>";
+        }
+        if (token.length() < MIN_TOKEN_LEN_FOR_PREFIX) {
+            // Too short to reveal any prefix without leaking a large fraction 
of the secret.
+            return "<hidden, token length " + token.length() + " < " + 
MIN_TOKEN_LEN_FOR_PREFIX + ">";
+        }
+        return token.substring(0, TOKEN_PREFIX_LEN) + "***";
+    }
+
     @RequestMapping(path = "/image", method = RequestMethod.GET)
     public Object image(HttpServletRequest request, HttpServletResponse 
response) {
         checkFromValidFe(request);
@@ -149,6 +196,12 @@ public class MetaService extends RestBaseController {
         if (port < 0 || port > 65535) {
             return ResponseEntityBuilder.badRequest("port is invalid. The port 
number is between 0-65535");
         }
+        // The master pushes image using HttpURLUtil.getHttpPort() (https_port 
when enable_https=true,
+        // otherwise http_port), so the expected port must follow the same 
rule to stay consistent.
+        int expectedPort = HttpURLUtil.getHttpPort();
+        if (port != expectedPort) {
+            return ResponseEntityBuilder.badRequest("port must be FE HTTP 
port: " + expectedPort);
+        }
 
         String versionStr = request.getParameter(VERSION);
         if (Strings.isNullOrEmpty(versionStr)) {
@@ -245,9 +298,11 @@ public class MetaService extends RestBaseController {
 
     @RequestMapping(value = "/dump", method = RequestMethod.GET)
     public Object dump(HttpServletRequest request, HttpServletResponse 
response) throws DdlException {
-        if (Config.enable_all_http_auth) {
-            executeCheckPassword(request, response);
-        }
+        // /dump triggers a full metadata image dump (takes catalog/db/table 
locks and writes an
+        // image file), so it must be ADMIN-gated. executeCheckPassword only 
authenticates the
+        // caller; enforce the ADMIN privilege explicitly, matching other 
metadata/debug operations.
+        ActionAuthorizationInfo authInfo = executeCheckPassword(request, 
response);
+        checkGlobalAuth(authInfo.userIdentity, PrivPredicate.ADMIN);
 
         /*
          * Before dump, we acquired the catalog read lock and all databases' 
read lock and all
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 3a8dc73bbe2..fcb3a58a5d5 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
@@ -259,8 +259,13 @@ public class NodeAction extends RestBaseController {
      */
     @RequestMapping(path = "/config", method = RequestMethod.GET)
     public Object config(HttpServletRequest request, HttpServletResponse 
response) {
-        executeCheckPassword(request, response);
-        checkDbAuth(ConnectContext.get().getCurrentUserIdentity(), 
InfoSchemaDb.DATABASE_NAME, PrivPredicate.SELECT);
+        // 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.
+        // 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);
+        checkGlobalAuth(authInfo.userIdentity, PrivPredicate.ADMIN);
 
         List<List<String>> configs = ConfigBase.getConfigInfo(null);
         // Sort all configs by config key.
@@ -320,8 +325,10 @@ public class NodeAction extends RestBaseController {
     public Object configurationInfo(HttpServletRequest request, 
HttpServletResponse response,
             @RequestParam(value = "type") String type,
             @RequestBody(required = false) ConfigInfoRequestBody requestBody) {
+        // Reads FE/BE config via fan-out to the per-node config endpoints, so 
it must be
+        // ADMIN-gated too. Unconditional check (see config() above for why 
checkAdminAuth is not).
         ActionAuthorizationInfo authInfo = executeCheckPassword(request, 
response);
-        checkAdminAuth(authInfo.userIdentity);
+        checkGlobalAuth(authInfo.userIdentity, PrivPredicate.ADMIN);
 
         initHttpExecutor();
 
diff --git a/fe/fe-core/src/main/java/org/apache/doris/master/MetaHelper.java 
b/fe/fe-core/src/main/java/org/apache/doris/master/MetaHelper.java
index 5acec62ae59..73fceec8b39 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/master/MetaHelper.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/master/MetaHelper.java
@@ -149,7 +149,7 @@ public class MetaHelper {
 
     public static <T> ResponseBody doGet(String url, int timeout, Class<T> 
clazz) throws IOException {
         Map<String, String> headers = HttpURLUtil.getNodeIdentHeaders();
-        LOG.info("meta helper, url: {}, timeout: {}, headers: {}", url, 
timeout, headers);
+        LOG.info("meta helper, url: {}, timeout: {}, header names: {}", url, 
timeout, headers.keySet());
         String response = HttpUtils.doGet(url, headers, timeout);
         try {
             return parseResponse(response, clazz);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/common/util/HttpURLUtilTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/common/util/HttpURLUtilTest.java
index 5bba968b49a..5d2099c5c61 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/common/util/HttpURLUtilTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/common/util/HttpURLUtilTest.java
@@ -17,11 +17,19 @@
 
 package org.apache.doris.common.util;
 
+import org.apache.doris.catalog.Env;
 import org.apache.doris.common.Config;
+import org.apache.doris.httpv2.meta.MetaBaseAction;
+import org.apache.doris.system.SystemInfoService.HostInfo;
 
 import org.junit.After;
 import org.junit.Assert;
 import org.junit.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.net.HttpURLConnection;
+import java.util.Map;
 
 public class HttpURLUtilTest {
 
@@ -30,6 +38,57 @@ public class HttpURLUtilTest {
         Config.enable_https = false;
         Config.http_port = 8030;
         Config.https_port = 8050;
+        Config.fe_meta_auth_token = "";
+    }
+
+    @Test
+    public void testNodeIdentHeadersIncludeClusterToken() throws Exception {
+        Config.fe_meta_auth_token = "cluster-token";
+        Env env = Mockito.mock(Env.class);
+        Mockito.when(env.getSelfNode()).thenReturn(new HostInfo("127.0.0.1", 
9010));
+
+        try (MockedStatic<Env> envStatic = Mockito.mockStatic(Env.class)) {
+            envStatic.when(Env::getServingEnv).thenReturn(env);
+
+            Map<String, String> headers = HttpURLUtil.getNodeIdentHeaders();
+
+            Assert.assertEquals("127.0.0.1", 
headers.get(Env.CLIENT_NODE_HOST_KEY));
+            Assert.assertEquals("9010", headers.get(Env.CLIENT_NODE_PORT_KEY));
+            Assert.assertEquals("cluster-token", 
headers.get(MetaBaseAction.TOKEN));
+        }
+    }
+
+    @Test
+    public void testNodeIdentHeadersOmitTokenWhenNotConfigured() throws 
Exception {
+        Config.fe_meta_auth_token = "";
+        Env env = Mockito.mock(Env.class);
+        Mockito.when(env.getSelfNode()).thenReturn(new HostInfo("127.0.0.1", 
9010));
+
+        try (MockedStatic<Env> envStatic = Mockito.mockStatic(Env.class)) {
+            envStatic.when(Env::getServingEnv).thenReturn(env);
+
+            Map<String, String> headers = HttpURLUtil.getNodeIdentHeaders();
+
+            Assert.assertEquals("127.0.0.1", 
headers.get(Env.CLIENT_NODE_HOST_KEY));
+            Assert.assertFalse(headers.containsKey(MetaBaseAction.TOKEN));
+        }
+    }
+
+    @Test
+    public void testNodeIdentConnectionIncludesClusterToken() throws Exception 
{
+        Config.fe_meta_auth_token = "cluster-token";
+        Env env = Mockito.mock(Env.class);
+        Mockito.when(env.getSelfNode()).thenReturn(new HostInfo("127.0.0.1", 
9010));
+
+        try (MockedStatic<Env> envStatic = Mockito.mockStatic(Env.class)) {
+            envStatic.when(Env::getServingEnv).thenReturn(env);
+
+            HttpURLConnection connection = 
HttpURLUtil.getConnectionWithNodeIdent("http://127.0.0.1:8030/info";);
+
+            Assert.assertEquals("127.0.0.1", 
connection.getRequestProperty(Env.CLIENT_NODE_HOST_KEY));
+            Assert.assertEquals("9010", 
connection.getRequestProperty(Env.CLIENT_NODE_PORT_KEY));
+            Assert.assertEquals("cluster-token", 
connection.getRequestProperty(MetaBaseAction.TOKEN));
+        }
     }
 
     @Test
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/httpv2/meta/MetaServiceTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/httpv2/meta/MetaServiceTest.java
new file mode 100644
index 00000000000..90bb54de6b9
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/httpv2/meta/MetaServiceTest.java
@@ -0,0 +1,295 @@
+// 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.
+
+package org.apache.doris.httpv2.meta;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.Config;
+import org.apache.doris.ha.FrontendNodeType;
+import org.apache.doris.httpv2.controller.BaseController;
+import org.apache.doris.httpv2.entity.ResponseBody;
+import org.apache.doris.httpv2.exception.UnauthorizedException;
+import org.apache.doris.httpv2.rest.RestApiStatusCode;
+import org.apache.doris.master.MetaHelper;
+import org.apache.doris.mysql.privilege.AccessControllerManager;
+import org.apache.doris.mysql.privilege.PrivPredicate;
+import org.apache.doris.persist.Storage;
+import org.apache.doris.system.Frontend;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.springframework.http.ResponseEntity;
+
+import java.io.File;
+import java.lang.reflect.Field;
+
+public class MetaServiceTest {
+    // Value configured via Config.fe_meta_auth_token -- the cluster meta auth 
token.
+    private static final String META_TOKEN = "meta-auth-token";
+    // Token persisted in the local Storage, returned by the /check endpoint. 
Intentionally
+    // different from META_TOKEN to show the two are independent.
+    private static final String STORAGE_TOKEN = "storage-token";
+    private static final String BAD_TOKEN = "bad-token";
+    private static final String FE_HOST = "127.0.0.1";
+    private static final int FE_EDIT_LOG_PORT = 9010;
+
+    @Rule
+    public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+    private String oldMetaAuthToken;
+    private boolean oldEnableAllHttpAuth;
+    private int oldHttpPort;
+    private int oldHttpsPort;
+    private boolean oldEnableHttps;
+    private Env env;
+    private MockedStatic<Env> envStatic;
+
+    @Before
+    public void setUp() {
+        oldMetaAuthToken = Config.fe_meta_auth_token;
+        oldEnableAllHttpAuth = Config.enable_all_http_auth;
+        oldHttpPort = Config.http_port;
+        oldHttpsPort = Config.https_port;
+        oldEnableHttps = Config.enable_https;
+        // Token required by default; individual tests clear it to exercise 
the legacy path.
+        Config.fe_meta_auth_token = META_TOKEN;
+        Config.enable_all_http_auth = false;
+        Config.http_port = 8030;
+        Config.https_port = 8050;
+        Config.enable_https = false;
+
+        env = Mockito.mock(Env.class);
+        envStatic = Mockito.mockStatic(Env.class);
+        envStatic.when(Env::getCurrentEnv).thenReturn(env);
+
+        Frontend frontend = new Frontend(FrontendNodeType.FOLLOWER, "fe1", 
FE_HOST, FE_EDIT_LOG_PORT);
+        Mockito.when(env.checkFeExist(FE_HOST, 
FE_EDIT_LOG_PORT)).thenReturn(frontend);
+        
Mockito.when(env.getImageDir()).thenReturn(temporaryFolder.getRoot().getAbsolutePath());
+    }
+
+    @After
+    public void tearDown() {
+        Config.fe_meta_auth_token = oldMetaAuthToken;
+        Config.enable_all_http_auth = oldEnableAllHttpAuth;
+        Config.http_port = oldHttpPort;
+        Config.https_port = oldHttpsPort;
+        Config.enable_https = oldEnableHttps;
+        if (envStatic != null) {
+            envStatic.close();
+        }
+    }
+
+    // A matching token passes even when the request originates from a proxy 
address
+    // (remoteAddr differs from the registered FE host).
+    @Test
+    public void testMatchingTokenAllowsMetaRequestFromProxyAddress() throws 
Exception {
+        MetaService service = new MetaService();
+        HttpServletRequest request = newRequest("192.0.2.10", FE_HOST, 
META_TOKEN);
+        HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
+
+        Object result = service.role(request, response);
+
+        Assert.assertEquals(RestApiStatusCode.OK.code, responseCode(result));
+        Mockito.verify(response).setHeader("role", 
FrontendNodeType.FOLLOWER.name());
+    }
+
+    // When no token is configured, the node-host check alone is sufficient. 
This keeps
+    // existing clusters and rolling upgrades working (a peer that sends no 
token is allowed).
+    @Test
+    public void testNoTokenRequiredWhenTokenNotConfigured() throws Exception {
+        Config.fe_meta_auth_token = "";
+        MetaService service = new MetaService();
+        HttpServletRequest request = newRequest("192.0.2.10", FE_HOST, null);
+        HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
+
+        Object result = service.role(request, response);
+
+        Assert.assertEquals(RestApiStatusCode.OK.code, responseCode(result));
+        Mockito.verify(response).setHeader("role", 
FrontendNodeType.FOLLOWER.name());
+    }
+
+    // /check returns the local Storage token once the request passes 
authentication.
+    @Test
+    public void testCheckReturnsStorageTokenWhenAuthPasses() throws Exception {
+        Config.fe_meta_auth_token = "";
+        MetaService service = serviceWithImageDir();
+        HttpServletRequest request = newRequest("192.0.2.10", FE_HOST, null);
+        HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
+
+        Object result = service.check(request, response);
+
+        Assert.assertEquals(RestApiStatusCode.OK.code, responseCode(result));
+        Mockito.verify(response).setHeader(MetaBaseAction.TOKEN, 
STORAGE_TOKEN);
+    }
+
+    // A wrong token is rejected even though the node-host check would pass.
+    @Test
+    public void testWrongTokenRejected() {
+        MetaService service = new MetaService();
+        HttpServletRequest request = newRequest(FE_HOST, FE_HOST, BAD_TOKEN);
+        HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
+
+        Assert.assertThrows(UnauthorizedException.class, () -> 
service.role(request, response));
+    }
+
+    // A missing token is rejected when a cluster token is configured.
+    @Test
+    public void testMissingTokenRejectedWhenConfigured() {
+        MetaService service = new MetaService();
+        HttpServletRequest request = newRequest(FE_HOST, FE_HOST, null);
+        HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
+
+        Assert.assertThrows(UnauthorizedException.class, () -> 
service.role(request, response));
+    }
+
+    // The node-host check is always enforced: an unknown FE host is rejected 
even with a
+    // matching token (the token check is additive, it does not replace the 
host check).
+    @Test
+    public void testUnknownHostRejectedEvenWithValidToken() {
+        MetaService service = new MetaService();
+        HttpServletRequest request = newRequest("192.0.2.99", "192.0.2.99", 
META_TOKEN);
+        HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
+
+        Assert.assertThrows(UnauthorizedException.class, () -> 
service.role(request, response));
+    }
+
+    @Test
+    public void testPutRejectsUnexpectedHttpPort() throws Exception {
+        MetaService service = new MetaService();
+        HttpServletRequest request = newRequest(FE_HOST, FE_HOST, META_TOKEN);
+        HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
+        Mockito.when(request.getParameter("version")).thenReturn("100");
+        
Mockito.when(request.getParameter("port")).thenReturn(Integer.toString(Config.http_port
 + 1));
+
+        try (MockedStatic<MetaHelper> metaHelper = 
Mockito.mockStatic(MetaHelper.class)) {
+            File partialFile = temporaryFolder.newFile("image.100.part");
+            metaHelper.when(() -> MetaHelper.getFile(Mockito.anyString(), 
Mockito.any(File.class)))
+                    .thenReturn(partialFile);
+
+            Object result = service.put(request, response);
+
+            Assert.assertEquals(RestApiStatusCode.BAD_REQUEST.code, 
responseCode(result));
+            metaHelper.verify(() -> 
MetaHelper.getRemoteFile(Mockito.anyString(), Mockito.anyInt(),
+                    Mockito.any(File.class)), Mockito.never());
+        }
+    }
+
+    // When HTTPS is enabled the master pushes image using https_port, so /put 
must accept
+    // https_port. The expected port follows HttpURLUtil.getHttpPort(), not a 
hard-coded http_port.
+    @Test
+    public void testPutAcceptsHttpsPortWhenHttpsEnabled() throws Exception {
+        Config.enable_https = true;
+        MetaService service = new MetaService();
+        HttpServletRequest request = newRequest(FE_HOST, FE_HOST, META_TOKEN);
+        HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
+        Mockito.when(request.getParameter("version")).thenReturn("100");
+        
Mockito.when(request.getParameter("port")).thenReturn(Integer.toString(Config.https_port));
+
+        try (MockedStatic<MetaHelper> metaHelper = 
Mockito.mockStatic(MetaHelper.class)) {
+            File partialFile = temporaryFolder.newFile("image.100.part");
+            metaHelper.when(() -> MetaHelper.getFile(Mockito.anyString(), 
Mockito.any(File.class)))
+                    .thenReturn(partialFile);
+
+            Object result = service.put(request, response);
+
+            // Passes the port check and proceeds to fetch the remote image 
(no BAD_REQUEST).
+            Assert.assertEquals(RestApiStatusCode.OK.code, 
responseCode(result));
+            metaHelper.verify(() -> 
MetaHelper.getRemoteFile(Mockito.anyString(), Mockito.anyInt(),
+                    Mockito.any(File.class)), Mockito.times(1));
+        }
+    }
+
+    // /dump authenticates and then requires ADMIN. An authenticated admin 
passes the privilege
+    // check and proceeds to dump.
+    @Test
+    public void testDumpAllowsAdmin() throws Exception {
+        MetaService service = Mockito.spy(new MetaService());
+        HttpServletRequest request = newRequest(FE_HOST, FE_HOST, META_TOKEN);
+        HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
+        
Mockito.doReturn(authInfo(UserIdentity.ADMIN)).when(service).executeCheckPassword(request,
 response);
+        AccessControllerManager accessManager = 
Mockito.mock(AccessControllerManager.class);
+        Mockito.when(env.getAccessManager()).thenReturn(accessManager);
+        Mockito.when(accessManager.checkGlobalPriv(UserIdentity.ADMIN, 
PrivPredicate.ADMIN)).thenReturn(true);
+        Mockito.when(env.dumpImage()).thenReturn(null);
+
+        service.dump(request, response);
+
+        Mockito.verify(service).executeCheckPassword(request, response);
+        Mockito.verify(env).dumpImage();
+    }
+
+    // An authenticated but non-admin user is rejected before any dump happens.
+    @Test
+    public void testDumpRejectsNonAdmin() throws Exception {
+        MetaService service = Mockito.spy(new MetaService());
+        HttpServletRequest request = newRequest(FE_HOST, FE_HOST, META_TOKEN);
+        HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
+        UserIdentity nonAdmin = 
UserIdentity.createAnalyzedUserIdentWithIp("user", "%");
+        
Mockito.doReturn(authInfo(nonAdmin)).when(service).executeCheckPassword(request,
 response);
+        AccessControllerManager accessManager = 
Mockito.mock(AccessControllerManager.class);
+        Mockito.when(env.getAccessManager()).thenReturn(accessManager);
+        Mockito.when(accessManager.checkGlobalPriv(nonAdmin, 
PrivPredicate.ADMIN)).thenReturn(false);
+
+        Assert.assertThrows(UnauthorizedException.class, () -> 
service.dump(request, response));
+        Mockito.verify(env, Mockito.never()).dumpImage();
+    }
+
+    private static BaseController.ActionAuthorizationInfo 
authInfo(UserIdentity userIdentity) {
+        BaseController.ActionAuthorizationInfo info = new 
BaseController.ActionAuthorizationInfo();
+        info.userIdentity = userIdentity;
+        return info;
+    }
+
+    private MetaService serviceWithImageDir() throws Exception {
+        File imageDir = temporaryFolder.newFolder("image");
+        Storage storage = new Storage(12345, STORAGE_TOKEN, 
imageDir.getAbsolutePath());
+        storage.writeClusterIdAndToken();
+
+        MetaService service = new MetaService();
+        Field imageDirField = MetaService.class.getDeclaredField("imageDir");
+        imageDirField.setAccessible(true);
+        imageDirField.set(service, imageDir);
+        return service;
+    }
+
+    private HttpServletRequest newRequest(String remoteAddr, String 
clientHost, String token) {
+        HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+        
Mockito.when(request.getHeader(Env.CLIENT_NODE_HOST_KEY)).thenReturn(clientHost);
+        
Mockito.when(request.getHeader(Env.CLIENT_NODE_PORT_KEY)).thenReturn(Integer.toString(FE_EDIT_LOG_PORT));
+        
Mockito.when(request.getHeader(MetaBaseAction.TOKEN)).thenReturn(token);
+        Mockito.when(request.getRemoteAddr()).thenReturn(remoteAddr);
+        Mockito.when(request.getRemoteHost()).thenReturn(remoteAddr);
+        Mockito.when(request.getParameter("host")).thenReturn(clientHost);
+        
Mockito.when(request.getParameter("port")).thenReturn(Integer.toString(FE_EDIT_LOG_PORT));
+        return request;
+    }
+
+    private int responseCode(Object result) {
+        ResponseEntity<?> responseEntity = (ResponseEntity<?>) result;
+        ResponseBody<?> body = (ResponseBody<?>) responseEntity.getBody();
+        return body.getCode();
+    }
+}
diff --git a/regression-test/suites/meta_action_p0/test_dump_image.groovy 
b/regression-test/suites/meta_action_p0/test_dump_image.groovy
index 4500503ae42..6102dfff235 100644
--- a/regression-test/suites/meta_action_p0/test_dump_image.groovy
+++ b/regression-test/suites/meta_action_p0/test_dump_image.groovy
@@ -17,6 +17,7 @@
 
 suite("test_dump_image", "nonConcurrent") {
     httpTest {
+        basicAuthorization "${context.config.jdbcUser}", 
"${context.config.jdbcPassword}"
         endpoint context.config.feHttpAddress
         uri "/dump"
         op "get"


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to