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 00526509177152..2fe4a8218f1b9d 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 @@ -484,7 +484,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, @@ -818,6 +818,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 192fdea7c8b109..e58fea913d7b48 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 @@ private void warnUnknownConfigKeys(String confFile, Properties props) { } } + // 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 dump() { HashMap 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 static synchronized List> getConfigInfo(PatternMatcher match 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 0d305298270ceb..d48d7ae8ed330a 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 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 static void setUp() throws Exception { 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 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 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 feb88abbb1cec5..f1fa2f87d4012a 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 @@ 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 static HttpURLConnection getConnectionWithNodeIdent(String request) throw 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 static Map getNodeIdentHeaders() throws IOException { 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 79a34ad4d382cd..4a8acdce33d8e7 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 @@ 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 "" 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 ""; + } + if (token.length() < MIN_TOKEN_LEN_FOR_PREFIX) { + // Too short to reveal any prefix without leaking a large fraction of the secret. + return ""; + } + 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 Object put(HttpServletRequest request, HttpServletResponse response) thro 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 Object check(HttpServletRequest request, HttpServletResponse response) th @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 3a8dc73bbe2085..fcb3a58a5d5e9b 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 @@ private static List getBeList() { */ @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> configs = ConfigBase.getConfigInfo(null); // Sort all configs by config key. @@ -320,8 +325,10 @@ public Object config(HttpServletRequest request, HttpServletResponse response) { 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 5acec62ae59a77..73fceec8b39481 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 @@ private static void checkFile(File file) throws IOException { public static ResponseBody doGet(String url, int timeout, Class clazz) throws IOException { Map 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 5bba968b49af41..5d2099c5c61d0e 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 void tearDown() { 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 envStatic = Mockito.mockStatic(Env.class)) { + envStatic.when(Env::getServingEnv).thenReturn(env); + + Map 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 envStatic = Mockito.mockStatic(Env.class)) { + envStatic.when(Env::getServingEnv).thenReturn(env); + + Map 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 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 00000000000000..90bb54de6b9e43 --- /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 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 = 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 = 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 4500503ae4267b..6102dfff2352be 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"