This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new ee55502a0f [common] Redact sensitive configuration values in logs and
exceptions (#8721)
ee55502a0f is described below
commit ee55502a0fc109474cf14f677e24ca0ca33c3ee7
Author: XiaoHongbo <[email protected]>
AuthorDate: Tue Jul 21 14:47:38 2026 +0800
[common] Redact sensitive configuration values in logs and exceptions
(#8721)
---
.../java/org/apache/paimon/rest/HttpClient.java | 57 +++--
.../org/apache/paimon/rest/HttpClientUtils.java | 67 +++++-
.../main/java/org/apache/paimon/rest/RESTUtil.java | 8 +-
.../org/apache/paimon/rest/SimpleHttpClient.java | 8 +-
.../apache/paimon/rest/auth/DLFECSTokenLoader.java | 14 +-
.../paimon/rest/auth/DLFLocalFileTokenLoader.java | 20 +-
.../rest/interceptor/LoggingInterceptor.java | 7 +-
.../apache/paimon/utils/SensitiveConfigUtils.java | 236 +++++++++++++++++++++
.../apache/paimon/rest/HttpClientUtilsTest.java | 53 +++++
.../apache/paimon/rest/SimpleHttpClientTest.java | 55 +++++
.../paimon/rest/auth/DLFECSTokenLoaderTest.java | 80 +++++++
.../rest/auth/DLFLocalFileTokenLoaderTest.java | 48 +++++
.../paimon/utils/SensitiveConfigUtilsTest.java | 180 ++++++++++++++++
.../java/org/apache/paimon/utils/HadoopUtils.java | 3 +-
.../org/apache/paimon/utils/UriReaderFactory.java | 10 +-
.../paimon/utils/HadoopUtilsSecretLoggingTest.java | 83 ++++++++
.../apache/paimon/utils/UriReaderFactoryTest.java | 14 ++
.../org/apache/paimon/rest/HttpClientTest.java | 97 ++++++++-
.../java/org/apache/paimon/cosn/COSNFileIO.java | 3 +-
.../main/java/org/apache/paimon/gs/GSFileIO.java | 3 +-
.../java/org/apache/paimon/jindo/JindoFileIO.java | 3 +-
.../main/java/org/apache/paimon/obs/OBSFileIO.java | 3 +-
.../main/java/org/apache/paimon/oss/OSSFileIO.java | 3 +-
.../paimon/flink/AbstractFlinkTableFactory.java | 3 +-
.../org/apache/paimon/flink/FlinkRowWrapper.java | 9 +-
.../apache/paimon/flink/sink/ConfigRefresher.java | 5 +-
.../paimon/flink/source/FlinkTableSource.java | 6 +-
.../apache/paimon/flink/FlinkRowWrapperTest.java | 3 +-
.../paimon/format/blob/BlobFormatWriter.java | 4 +-
29 files changed, 1014 insertions(+), 71 deletions(-)
diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/HttpClient.java
b/paimon-api/src/main/java/org/apache/paimon/rest/HttpClient.java
index 2477ff9db6..311a1a2d4a 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/HttpClient.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/HttpClient.java
@@ -24,6 +24,7 @@ import org.apache.paimon.rest.auth.RESTAuthParameter;
import org.apache.paimon.rest.exceptions.RESTException;
import org.apache.paimon.rest.interceptor.LoggingInterceptor;
import org.apache.paimon.rest.responses.ErrorResponse;
+import org.apache.paimon.utils.SensitiveConfigUtils;
import org.apache.paimon.utils.StringUtils;
import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.core.JsonProcessingException;
@@ -63,7 +64,7 @@ public class HttpClient implements RESTClient {
public <T extends RESTResponse> T get(
String path, Class<T> responseType, RESTAuthFunction
restAuthFunction) {
Header[] authHeaders = getHeaders(path, "GET", "", restAuthFunction);
- HttpGet httpGet = new HttpGet(getRequestUrl(path, null));
+ HttpGet httpGet = HttpClientUtils.newHttpGet(getRequestUrl(path,
null));
httpGet.setHeaders(authHeaders);
return exec(httpGet, responseType);
}
@@ -75,7 +76,7 @@ public class HttpClient implements RESTClient {
Class<T> responseType,
RESTAuthFunction restAuthFunction) {
Header[] authHeaders = getHeaders(path, queryParams, "GET", "",
restAuthFunction);
- HttpGet httpGet = new HttpGet(getRequestUrl(path, queryParams));
+ HttpGet httpGet = HttpClientUtils.newHttpGet(getRequestUrl(path,
queryParams));
httpGet.setHeaders(authHeaders);
return exec(httpGet, responseType);
}
@@ -92,7 +93,7 @@ public class HttpClient implements RESTClient {
RESTRequest body,
Class<T> responseType,
RESTAuthFunction restAuthFunction) {
- HttpPost httpPost = new HttpPost(getRequestUrl(path, null));
+ HttpPost httpPost = HttpClientUtils.newHttpPost(getRequestUrl(path,
null));
String encodedBody = RESTUtil.encodedBody(body);
if (encodedBody != null) {
httpPost.setEntity(new StringEntity(encodedBody,
ContentType.APPLICATION_JSON));
@@ -110,7 +111,7 @@ public class HttpClient implements RESTClient {
@Override
public <T extends RESTResponse> T delete(
String path, RESTRequest body, RESTAuthFunction restAuthFunction) {
- HttpDelete httpDelete = new HttpDelete(getRequestUrl(path, null));
+ HttpDelete httpDelete =
HttpClientUtils.newHttpDelete(getRequestUrl(path, null));
String encodedBody = RESTUtil.encodedBody(body);
if (encodedBody != null) {
httpDelete.setEntity(new StringEntity(encodedBody,
ContentType.APPLICATION_JSON));
@@ -138,12 +139,21 @@ public class HttpClient implements RESTClient {
} catch (JsonProcessingException e) {
// ignore exception
}
- error = buildErrorResponse(error, responseBodyStr,
response.getCode());
+ error = buildErrorResponse(error,
response.getCode());
errorHandler.accept(error,
extractRequestId(response));
}
if (responseType != null && responseBodyStr != null) {
- return RESTApi.fromJson(responseBodyStr,
responseType);
+ try {
+ return RESTApi.fromJson(responseBodyStr,
responseType);
+ } catch (JsonProcessingException e) {
+ // Do not surface the body or Jackson's source
snippet: a
+ // successful response (e.g. a token response)
may contain
+ // credentials. Report only the target type.
+ throw new RESTException(
+ "Failed to parse REST response into
%s",
+ responseType.getName());
+ }
} else if (responseType == null) {
return null;
} else {
@@ -151,8 +161,9 @@ public class HttpClient implements RESTClient {
}
});
} catch (IOException e) {
+ // No cause: a redirect/protocol error message can echo the target
URL (a signed URL).
throw new RESTException(
- e, "Error occurred while processing %s request",
request.getMethod());
+ "Error occurred while processing %s request",
request.getMethod());
}
}
@@ -225,21 +236,23 @@ public class HttpClient implements RESTClient {
.toArray(Header[]::new);
}
- private static ErrorResponse buildErrorResponse(
- ErrorResponse error, String responseBodyStr, int errorCode) {
- if (error == null || error.getMessage() == null ||
error.getMessage().isEmpty()) {
- String resourceType =
- (error != null && error.getResourceType() != null)
- ? error.getResourceType()
- : "";
- String resourceName =
- (error != null && error.getResourceName() != null)
- ? error.getResourceName()
- : "";
- String message = responseBodyStr != null ? responseBodyStr :
"response body is null";
- int code = (error != null && error.getCode() != null) ?
error.getCode() : errorCode;
- error = new ErrorResponse(resourceType, resourceName, message,
code);
+ private static ErrorResponse buildErrorResponse(ErrorResponse error, int
errorCode) {
+ String resourceType =
+ (error != null && error.getResourceType() != null) ?
error.getResourceType() : "";
+ String resourceName =
+ (error != null && error.getResourceName() != null) ?
error.getResourceName() : "";
+ int code = (error != null && error.getCode() != null) ?
error.getCode() : errorCode;
+ String message;
+ if (error != null && error.getMessage() != null &&
!error.getMessage().isEmpty()) {
+ // A parsed message may still embed secrets (e.g. "password=...");
redact it.
+ message = SensitiveConfigUtils.redactText(error.getMessage());
+ } else if (error == null) {
+ // The body could not be parsed; it is arbitrary and is not echoed
(may hold secrets).
+ message = "Unparseable error response body (HTTP " + code + ").";
+ } else {
+ // Parsed as an ErrorResponse but with no message.
+ message = "Empty error message (HTTP " + code + ").";
}
- return error;
+ return new ErrorResponse(resourceType, resourceName, message, code);
}
}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/HttpClientUtils.java
b/paimon-api/src/main/java/org/apache/paimon/rest/HttpClientUtils.java
index f54ef7044d..e4334e60e5 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/HttpClientUtils.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/HttpClientUtils.java
@@ -20,9 +20,12 @@ package org.apache.paimon.rest;
import org.apache.paimon.rest.interceptor.LoggingInterceptor;
import org.apache.paimon.rest.interceptor.TimingInterceptor;
+import org.apache.paimon.utils.SensitiveConfigUtils;
+import org.apache.hc.client5.http.classic.methods.HttpDelete;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.classic.methods.HttpHead;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
@@ -32,6 +35,7 @@ import
org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuil
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
import org.apache.hc.client5.http.ssl.HttpsSupport;
+import org.apache.hc.core5.http.ClassicHttpRequest;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.reactor.ssl.SSLBufferMode;
import org.apache.hc.core5.ssl.SSLContexts;
@@ -39,6 +43,7 @@ import org.apache.hc.core5.util.Timeout;
import java.io.IOException;
import java.io.InputStream;
+import java.util.function.Function;
/** Utils for {@link HttpClientBuilder}. */
public class HttpClientUtils {
@@ -86,8 +91,8 @@ public class HttpClientUtils {
}
public static InputStream getAsInputStream(String uri) throws IOException {
- HttpGet httpGet = new HttpGet(uri);
- CloseableHttpResponse response = DEFAULT_HTTP_CLIENT.execute(httpGet);
+ HttpGet httpGet = newHttpGet(uri);
+ CloseableHttpResponse response = execute(httpGet, uri);
int statusCode = response.getCode();
if (statusCode != HttpStatus.SC_OK) {
try {
@@ -120,7 +125,10 @@ public class HttpClientUtils {
return false;
}
throw new IOException(
- "Unexpected HTTP status code: " + rangeStatusCode + " for uri:
" + uri);
+ "Unexpected HTTP status code: "
+ + rangeStatusCode
+ + " for uri: "
+ + SensitiveConfigUtils.sanitizeUri(uri));
}
public static boolean isNotFoundError(Throwable throwable) {
@@ -136,7 +144,9 @@ public class HttpClientUtils {
}
if (current instanceof IllegalArgumentException
&& current.getMessage() != null
- && current.getMessage().contains("Illegal character")) {
+ && (current.getMessage().contains("Illegal character")
+ || current.getMessage()
+
.startsWith(SensitiveConfigUtils.INVALID_URI_MESSAGE_PREFIX))) {
return true;
}
current = current.getCause();
@@ -182,20 +192,61 @@ public class HttpClientUtils {
}
private static int headStatusCode(String uri) throws IOException {
- HttpHead httpHead = new HttpHead(uri);
- try (CloseableHttpResponse response =
DEFAULT_HTTP_CLIENT.execute(httpHead)) {
+ HttpHead httpHead = newHttpHead(uri);
+ try (CloseableHttpResponse response = execute(httpHead, uri)) {
return response.getCode();
}
}
private static int getRangeStatusCode(String uri) throws IOException {
- HttpGet httpGet = new HttpGet(uri);
+ HttpGet httpGet = newHttpGet(uri);
httpGet.addHeader("Range", "bytes=0-0");
- try (CloseableHttpResponse response =
DEFAULT_HTTP_CLIENT.execute(httpGet)) {
+ try (CloseableHttpResponse response = execute(httpGet, uri)) {
return response.getCode();
}
}
+ /**
+ * Executes a request, converting any execute-stage failure into an
exception that carries
+ * neither the original message nor cause. Redirect and protocol errors
(e.g. "Circular redirect
+ * to <Location>") echo the target URL, which for a signed URL is a
credential; only the
+ * sanitized request URI is reported.
+ */
+ private static CloseableHttpResponse execute(ClassicHttpRequest request,
String uri)
+ throws IOException {
+ try {
+ return DEFAULT_HTTP_CLIENT.execute(request);
+ } catch (IOException | RuntimeException e) {
+ throw new IOException(
+ "HTTP request failed for uri: " +
SensitiveConfigUtils.sanitizeUri(uri));
+ }
+ }
+
+ public static HttpGet newHttpGet(String uri) {
+ return newRequest(uri, HttpGet::new);
+ }
+
+ public static HttpHead newHttpHead(String uri) {
+ return newRequest(uri, HttpHead::new);
+ }
+
+ public static HttpPost newHttpPost(String uri) {
+ return newRequest(uri, HttpPost::new);
+ }
+
+ public static HttpDelete newHttpDelete(String uri) {
+ return newRequest(uri, HttpDelete::new);
+ }
+
+ /** A malformed URL leaks the raw URL from the constructor; sanitize it. */
+ private static <T> T newRequest(String uri, Function<String, T>
constructor) {
+ try {
+ return constructor.apply(uri);
+ } catch (RuntimeException e) {
+ throw SensitiveConfigUtils.invalidUri(uri);
+ }
+ }
+
private static RuntimeException httpError(int statusCode) {
return new RuntimeException("HTTP error code: " + statusCode);
}
diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/RESTUtil.java
b/paimon-api/src/main/java/org/apache/paimon/rest/RESTUtil.java
index 8a5e760793..280a4aabb8 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/RESTUtil.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTUtil.java
@@ -167,7 +167,10 @@ public class RESTUtil {
try {
return RESTApi.toJson(body);
} catch (JsonProcessingException e) {
- throw new RESTException(e, "Failed to encode request body:
%s", body);
+ // Keep only the body type: a throwing getter can surface the
secret in both
+ // the cause chain and Jackson's message, so neither is safe
to include.
+ throw new RESTException(
+ "Failed to encode request body of type %s",
body.getClass().getName());
}
}
return null;
@@ -199,7 +202,8 @@ public class RESTUtil {
url = builder.build().toString();
}
} catch (URISyntaxException e) {
- throw new RESTException(e, "build request URL failed.");
+ // cause / getMessage() echo the raw URL (may carry credentials);
keep only the reason.
+ throw new RESTException("build request URL failed: %s",
e.getReason());
}
return url;
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/SimpleHttpClient.java
b/paimon-api/src/main/java/org/apache/paimon/rest/SimpleHttpClient.java
index 04aaf3bfea..1fc21a31eb 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/SimpleHttpClient.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/SimpleHttpClient.java
@@ -47,7 +47,7 @@ public class SimpleHttpClient implements Closeable {
}
public String post(String url, Object body, Map<String, String> headers)
throws IOException {
- HttpPost httpPost = new HttpPost(url);
+ HttpPost httpPost = HttpClientUtils.newHttpPost(url);
if (headers != null) {
httpPost.setHeaders(
headers.entrySet().stream()
@@ -68,7 +68,7 @@ public class SimpleHttpClient implements Closeable {
public String get(String url, Map<String, String> queryParams, Map<String,
String> headers)
throws IOException {
- HttpGet httpGet = new HttpGet(RESTUtil.buildRequestUrl(url,
queryParams));
+ HttpGet httpGet =
HttpClientUtils.newHttpGet(RESTUtil.buildRequestUrl(url, queryParams));
if (headers != null) {
httpGet.setHeaders(
headers.entrySet().stream()
@@ -101,8 +101,8 @@ public class SimpleHttpClient implements Closeable {
return responseBodyStr;
});
} catch (IOException e) {
- throw new RuntimeException(
- "Failed to convert HTTP response body to string, error : "
+ e.getMessage());
+ // No message from e: a redirect/protocol error can echo the
target URL (a signed URL).
+ throw new RuntimeException("Failed to convert HTTP response body
to string.");
}
}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFECSTokenLoader.java
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFECSTokenLoader.java
index 404cb49b8d..56b687ee12 100644
---
a/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFECSTokenLoader.java
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFECSTokenLoader.java
@@ -22,6 +22,8 @@ import org.apache.paimon.annotation.VisibleForTesting;
import org.apache.paimon.rest.RESTApi;
import org.apache.paimon.rest.SimpleHttpClient;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.core.JsonProcessingException;
+
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -66,13 +68,17 @@ public class DLFECSTokenLoader implements DLFTokenLoader {
}
private static DLFToken getToken(String url) {
+ String token;
try {
- String token = SimpleHttpClient.INSTANCE.get(url);
- return RESTApi.fromJson(token, DLFToken.class);
+ token = SimpleHttpClient.INSTANCE.get(url);
} catch (IOException e) {
throw new UncheckedIOException(e);
- } catch (Exception e) {
- throw new RuntimeException("get token failed, error : " +
e.getMessage(), e);
+ }
+ try {
+ return RESTApi.fromJson(token, DLFToken.class);
+ } catch (JsonProcessingException e) {
+ // The token response carries AK/SK/STS; never surface the body or
Jackson's snippet.
+ throw new RuntimeException("Failed to parse ECS token response.");
}
}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFLocalFileTokenLoader.java
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFLocalFileTokenLoader.java
index 1991524744..5514cd34c0 100644
---
a/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFLocalFileTokenLoader.java
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFLocalFileTokenLoader.java
@@ -18,9 +18,12 @@
package org.apache.paimon.rest.auth;
+import org.apache.paimon.annotation.VisibleForTesting;
import org.apache.paimon.rest.RESTApi;
import org.apache.paimon.utils.FileReadUtils;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.core.JsonProcessingException;
+
import java.io.File;
/** DLF Token Loader for local file. */
@@ -43,14 +46,23 @@ public class DLFLocalFileTokenLoader implements
DLFTokenLoader {
}
protected static DLFToken readToken(String tokenFilePath) {
+ return readToken(tokenFilePath, 5);
+ }
+
+ @VisibleForTesting
+ static DLFToken readToken(String tokenFilePath, int maxRetries) {
int retry = 1;
- Exception lastException = null;
- while (retry <= 5) {
+ RuntimeException lastException = null;
+ while (retry <= maxRetries) {
try {
String tokenStr = FileReadUtils.readFileUtf8(new
File(tokenFilePath));
return RESTApi.fromJson(tokenStr, DLFToken.class);
+ } catch (JsonProcessingException e) {
+ // The token file carries AK/SK/STS; never keep the body or
Jackson's snippet.
+ lastException = new RuntimeException("Failed to parse token
file.");
} catch (Exception e) {
- lastException = e;
+ lastException =
+ new RuntimeException("Failed to read token file: " +
tokenFilePath, e);
}
try {
Thread.sleep(retry * 1000L);
@@ -60,6 +72,6 @@ public class DLFLocalFileTokenLoader implements
DLFTokenLoader {
}
retry++;
}
- throw new RuntimeException(lastException);
+ throw lastException;
}
}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/interceptor/LoggingInterceptor.java
b/paimon-api/src/main/java/org/apache/paimon/rest/interceptor/LoggingInterceptor.java
index 8422944dca..75898455cd 100644
---
a/paimon-api/src/main/java/org/apache/paimon/rest/interceptor/LoggingInterceptor.java
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/interceptor/LoggingInterceptor.java
@@ -18,6 +18,8 @@
package org.apache.paimon.rest.interceptor;
+import org.apache.paimon.utils.SensitiveConfigUtils;
+
import org.apache.hc.core5.http.EntityDetails;
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.HttpResponse;
@@ -55,10 +57,11 @@ public class LoggingInterceptor implements
HttpResponseInterceptor {
"[rest] requestId:{} method:{} url:{} duration:{}ms",
requestId,
request.getMethod(),
- request.getUri(),
+
SensitiveConfigUtils.sanitizeUri(request.getUri().toString()),
durationMs);
} catch (URISyntaxException e) {
- LOG.warn("Failed to log rest request: {}", e.getMessage());
+ // e.getMessage() echoes the raw URI (may carry credentials); log
only the reason.
+ LOG.warn("Failed to log rest request: {}", e.getReason());
}
}
}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/utils/SensitiveConfigUtils.java
b/paimon-api/src/main/java/org/apache/paimon/utils/SensitiveConfigUtils.java
new file mode 100644
index 0000000000..08d0338ba7
--- /dev/null
+++ b/paimon-api/src/main/java/org/apache/paimon/utils/SensitiveConfigUtils.java
@@ -0,0 +1,236 @@
+/*
+ * 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.paimon.utils;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Redacts sensitive configuration values (passwords, secrets, tokens, access
keys) before they are
+ * written to logs or exception messages. Only protects Paimon-produced
output; third-party logs
+ * (Hive, Hadoop, cloud SDKs) must be handled by the runtime logging config.
+ */
+public class SensitiveConfigUtils {
+
+ /** Placeholder for a fully-masked (short) sensitive value. */
+ public static final String REDACTED = "******";
+
+ /** Values at least this long keep their last {@link #TAIL_LEN} chars to
aid troubleshooting. */
+ private static final int MIN_LEN_FOR_TAIL = 12;
+
+ private static final int TAIL_LEN = 4;
+
+ /**
+ * Substrings that mark a configuration key as sensitive. Keys are
normalized (lower-cased with
+ * separators removed) before matching, so {@code fs.oss.accessKeySecret},
{@code
+ * fs.s3a.access.key}, {@code fs.azure.account.key.*}, {@code
fs.azure.sas.*} and {@code
+ * fs.s3a.encryption.key} are all detected.
+ */
+ private static final String[] SENSITIVE_KEY_PATTERNS = {
+ "password",
+ "secret",
+ "token",
+ "credential",
+ "accesskey",
+ "accountkey",
+ "encryptionkey",
+ "authorization",
+ "privatekey",
+ "apikey",
+ "sas"
+ };
+
+ /**
+ * Keys whose value is fully masked instead of keeping a trailing hint.
Every true secret is
+ * here; only identifier-like keys ({@code accessKeyId}) keep a tail. This
mirrors AWS/Azure,
+ * where the access-key id is loggable but the secret / token / SAS is
never shown. Note {@code
+ * accessKeySecret} normalizes to contain {@code secret}, so it is fully
masked while {@code
+ * accessKeyId} (only {@code accesskey}) keeps its tail.
+ */
+ private static final String[] FULL_MASK_KEY_PATTERNS = {
+ "password",
+ "token",
+ "authorization",
+ "secret",
+ "credential",
+ "privatekey",
+ "encryptionkey",
+ "apikey",
+ "accountkey",
+ "sas"
+ };
+
+ /**
+ * Markers that flag free-form text (a server error message or a signed
URL) as possibly
+ * carrying a secret. Matched after the text is normalized (lower-cased,
separators removed), so
+ * {@code api_key}, {@code private.key}, {@code accessKey} and {@code
X-Amz-Signature} all hit.
+ */
+ private static final String[] SENSITIVE_TEXT_MARKERS = {
+ "password",
+ "secret",
+ "token",
+ "credential",
+ "accesskey",
+ "accountkey",
+ "encryptionkey",
+ "authorization",
+ "privatekey",
+ "apikey",
+ "signature",
+ "sas"
+ };
+
+ /**
+ * Literal markers (Azure SAS {@code sig}) that would be ambiguous once
separators are removed.
+ */
+ private static final String[] SENSITIVE_TEXT_LITERAL_MARKERS = {"sig=",
"\"sig\"", "'sig'"};
+
+ private SensitiveConfigUtils() {}
+
+ /** Returns whether the given configuration key is considered sensitive. */
+ public static boolean isSensitive(String key) {
+ return matchesAny(key, SENSITIVE_KEY_PATTERNS);
+ }
+
+ private static boolean matchesAny(String key, String[] patterns) {
+ if (key == null || key.isEmpty()) {
+ return false;
+ }
+ String normalized =
key.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
+ for (String pattern : patterns) {
+ if (normalized.contains(pattern)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Returns the value masked if its key is sensitive, otherwise the value
unchanged. */
+ public static String redactValue(String key, String value) {
+ return isSensitive(key) ? maskValue(value, matchesAny(key,
FULL_MASK_KEY_PATTERNS)) : value;
+ }
+
+ /**
+ * Returns a copy of the map with every sensitive value masked. Insertion
order is preserved.
+ * Safe to pass {@code null}.
+ */
+ public static Map<String, String> redactMap(Map<String, String> options) {
+ if (options == null) {
+ return null;
+ }
+ Map<String, String> redacted = new LinkedHashMap<>(options.size());
+ for (Map.Entry<String, String> entry : options.entrySet()) {
+ String key = entry.getKey();
+ redacted.put(key, redactValue(key, entry.getValue()));
+ }
+ return redacted;
+ }
+
+ /**
+ * Redacts free-form text (e.g. a server {@code ErrorResponse.message}).
Arbitrary text cannot
+ * be masked per-secret reliably, so if any sensitive marker is present
the whole text is
+ * replaced. Runs a plain linear substring scan (no regex backtracking /
ReDoS) and is safe to
+ * call on attacker-controlled input. Prefer {@link #redactMap} when the
keys are known.
+ */
+ public static String redactText(String text) {
+ if (text == null || text.isEmpty()) {
+ return text;
+ }
+ String lower = text.toLowerCase(Locale.ROOT);
+ for (String marker : SENSITIVE_TEXT_LITERAL_MARKERS) {
+ if (lower.contains(marker)) {
+ return REDACTED;
+ }
+ }
+ String normalized = lower.replaceAll("[^a-z0-9]", "");
+ for (String marker : SENSITIVE_TEXT_MARKERS) {
+ if (normalized.contains(marker)) {
+ return REDACTED;
+ }
+ }
+ return text;
+ }
+
+ /** Placeholder for a URI that cannot be parsed and thus cannot be safely
sanitized. */
+ public static final String INVALID_URI = "<invalid-uri>";
+
+ /** Message prefix of the safe exception thrown for an unparseable URI. */
+ public static final String INVALID_URI_MESSAGE_PREFIX = "Invalid URI: ";
+
+ /**
+ * Builds a safe exception for an unparseable URI. The original exception
echoes the raw URI
+ * (which may carry credentials), so it is neither kept as a cause nor
reused as the message.
+ * Callers across modules share this prefix so the exception can be
classified uniformly.
+ */
+ public static IllegalArgumentException invalidUri(String uri) {
+ return new IllegalArgumentException(INVALID_URI_MESSAGE_PREFIX +
sanitizeUri(uri));
+ }
+
+ /**
+ * Strips the query string and user-info from a URI so signed-URL
credentials (AWS/GCS
+ * signature, Azure SAS {@code sig}, an embedded {@code user:password})
never reach logs or
+ * exceptions. Returns {@code scheme://host[:port]/path}. Fail-closed: an
unparseable URI is
+ * replaced by {@link #INVALID_URI} instead of guessing at its structure.
+ */
+ public static String sanitizeUri(String uri) {
+ if (uri == null || uri.isEmpty()) {
+ return uri;
+ }
+ try {
+ URI parsed = new URI(uri);
+ // Host unresolved but '@' present: credentials may hide elsewhere
-> fail closed.
+ if (parsed.getHost() == null && uri.indexOf('@') >= 0) {
+ return INVALID_URI;
+ }
+ StringBuilder sb = new StringBuilder();
+ if (parsed.getScheme() != null) {
+ sb.append(parsed.getScheme()).append("://");
+ }
+ if (parsed.getHost() != null) {
+ sb.append(parsed.getHost());
+ if (parsed.getPort() != -1) {
+ sb.append(':').append(parsed.getPort());
+ }
+ }
+ if (parsed.getRawPath() != null) {
+ sb.append(parsed.getRawPath());
+ }
+ return sb.length() == 0 ? INVALID_URI : sb.toString();
+ } catch (URISyntaxException e) {
+ return INVALID_URI;
+ }
+ }
+
+ /**
+ * Masks a value. Password/token keys are fully masked; other secrets keep
their last {@link
+ * #TAIL_LEN} chars when long enough, to aid troubleshooting.
+ */
+ private static String maskValue(String value, boolean fullMask) {
+ if (value == null) {
+ return null;
+ }
+ if (!fullMask && value.length() >= MIN_LEN_FOR_TAIL) {
+ return "****" + value.substring(value.length() - TAIL_LEN);
+ }
+ return REDACTED;
+ }
+}
diff --git
a/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientUtilsTest.java
b/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientUtilsTest.java
index d92cb2b501..cb56cc1fae 100644
--- a/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientUtilsTest.java
+++ b/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientUtilsTest.java
@@ -18,9 +18,12 @@
package org.apache.paimon.rest;
+import org.apache.paimon.utils.SensitiveConfigUtils;
+
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
+import org.assertj.core.api.ThrowableAssert;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -178,6 +181,51 @@ public class HttpClientUtilsTest {
.hasMessage("HTTP error code: 404");
}
+ @Test
+ public void testInvalidUriExceptionDoesNotLeakCredentials() {
+ String uri = "https://alice:secret@host/bad path?sig=QUERY_SECRET";
+ for (ThrowableAssert.ThrowingCallable call :
+ new ThrowableAssert.ThrowingCallable[] {
+ () -> HttpClientUtils.exists(uri), () ->
HttpClientUtils.getAsInputStream(uri)
+ }) {
+ assertThatThrownBy(call)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasNoCause()
+ .matches(e -> HttpClientUtils.isInvalidUriException(e))
+ .satisfies(
+ e -> {
+
assertThat(String.valueOf(e)).doesNotContain("secret");
+
assertThat(String.valueOf(e)).doesNotContain("QUERY_SECRET");
+ });
+ }
+ }
+
+ @Test
+ public void testExecuteFailureDoesNotLeakRedirectLocation() {
+ String secretLocation = url("/redirect") + "?sig=REDIRECT_SECRET";
+ registerHandler(
+ "/redirect",
+ exchange -> {
+ exchange.getResponseHeaders().add("Location",
secretLocation);
+ respond(exchange, 302, new byte[0]);
+ });
+
+ for (ThrowableAssert.ThrowingCallable call :
+ new ThrowableAssert.ThrowingCallable[] {
+ () -> HttpClientUtils.getAsInputStream(url("/redirect")),
+ () -> HttpClientUtils.exists(url("/redirect"))
+ }) {
+ assertThatThrownBy(call)
+ .isInstanceOf(IOException.class)
+ .hasNoCause()
+ .satisfies(
+ e -> {
+
assertThat(String.valueOf(e)).doesNotContain("REDIRECT_SECRET");
+
assertThat(e.getMessage()).doesNotContain("sig=");
+ });
+ }
+ }
+
@Test
public void testGetAsInputStreamDoesNotLeakConnectionsOnRepeatedNotFound()
throws Exception {
registerHandler(
@@ -224,6 +272,11 @@ public class HttpClientUtilsTest {
HttpClientUtils.isInvalidUriException(
new IllegalArgumentException("Illegal
character in path")))
.isTrue();
+ // The shared invalid-URI exception must classify uniformly across
modules.
+ assertThat(
+ HttpClientUtils.isInvalidUriException(
+
SensitiveConfigUtils.invalidUri("https://host/bad path")))
+ .isTrue();
assertThat(
HttpClientUtils.isInvalidUriException(
new RuntimeException("HTTP error code: 404")))
diff --git
a/paimon-api/src/test/java/org/apache/paimon/rest/SimpleHttpClientTest.java
b/paimon-api/src/test/java/org/apache/paimon/rest/SimpleHttpClientTest.java
new file mode 100644
index 0000000000..0f426918c5
--- /dev/null
+++ b/paimon-api/src/test/java/org/apache/paimon/rest/SimpleHttpClientTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.paimon.rest;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link SimpleHttpClient}. */
+public class SimpleHttpClientTest {
+
+ private static final String MALFORMED_URL =
+ "https://alice:secret@host/bad path?sig=QUERY_SECRET";
+
+ @Test
+ public void testGetWithMalformedUrlDoesNotLeakCredentials() {
+ assertMalformedUrlSanitized(() ->
SimpleHttpClient.INSTANCE.get(MALFORMED_URL));
+ }
+
+ @Test
+ public void testPostWithMalformedUrlDoesNotLeakCredentials() {
+ assertMalformedUrlSanitized(
+ () -> SimpleHttpClient.INSTANCE.post(MALFORMED_URL, null,
null));
+ }
+
+ private static void assertMalformedUrlSanitized(
+ org.assertj.core.api.ThrowableAssert.ThrowingCallable call) {
+ assertThatThrownBy(call)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasNoCause()
+ .matches(HttpClientUtils::isInvalidUriException)
+ .satisfies(
+ e -> {
+
assertThat(String.valueOf(e)).doesNotContain("secret");
+
assertThat(String.valueOf(e)).doesNotContain("QUERY_SECRET");
+ });
+ }
+}
diff --git
a/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFECSTokenLoaderTest.java
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFECSTokenLoaderTest.java
new file mode 100644
index 0000000000..704ff8a450
--- /dev/null
+++
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFECSTokenLoaderTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.paimon.rest.auth;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link DLFECSTokenLoader}. */
+public class DLFECSTokenLoaderTest {
+
+ private HttpServer server;
+ private int port;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ server = HttpServer.create(new InetSocketAddress(0), 0);
+ port = server.getAddress().getPort();
+ server.start();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ public void testMalformedTokenResponseDoesNotLeakCredentials() {
+ String secret = "STSSECRET_AKID_9999";
+ server.createContext(
+ "/token",
+ exchange ->
+ respond(
+ exchange,
+
("{\"AccessKeyId\":\"akid\",\"AccessKeySecret\":\""
+ + secret
+ + "\" INVALID_JSON")
+ .getBytes()));
+
+ DLFECSTokenLoader loader = new DLFECSTokenLoader("http://127.0.0.1:" +
port + "/token", "");
+
+ assertThatThrownBy(loader::loadToken)
+ .hasNoCause()
+ .satisfies(e ->
assertThat(String.valueOf(e)).doesNotContain(secret));
+ }
+
+ private static void respond(HttpExchange exchange, byte[] body) throws
IOException {
+ exchange.sendResponseHeaders(200, body.length);
+ try (OutputStream out = exchange.getResponseBody()) {
+ out.write(body);
+ }
+ }
+}
diff --git
a/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFLocalFileTokenLoaderTest.java
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFLocalFileTokenLoaderTest.java
new file mode 100644
index 0000000000..b56ab4e2db
--- /dev/null
+++
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFLocalFileTokenLoaderTest.java
@@ -0,0 +1,48 @@
+/*
+ * 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.paimon.rest.auth;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link DLFLocalFileTokenLoader}. */
+public class DLFLocalFileTokenLoaderTest {
+
+ @TempDir Path tempDir;
+
+ @Test
+ public void testMalformedTokenFileDoesNotLeakCredentials() throws
Exception {
+ String secret = "STSSECRET_AKID_9999";
+ Path tokenFile = tempDir.resolve("token.json");
+ Files.write(
+ tokenFile,
+ ("{\"AccessKeyId\":\"akid\",\"AccessKeySecret\":\"" + secret +
"\" INVALID_JSON")
+ .getBytes());
+
+ assertThatThrownBy(() ->
DLFLocalFileTokenLoader.readToken(tokenFile.toString(), 1))
+ .hasNoCause()
+ .satisfies(e ->
assertThat(String.valueOf(e)).doesNotContain(secret));
+ }
+}
diff --git
a/paimon-api/src/test/java/org/apache/paimon/utils/SensitiveConfigUtilsTest.java
b/paimon-api/src/test/java/org/apache/paimon/utils/SensitiveConfigUtilsTest.java
new file mode 100644
index 0000000000..6120a75e57
--- /dev/null
+++
b/paimon-api/src/test/java/org/apache/paimon/utils/SensitiveConfigUtilsTest.java
@@ -0,0 +1,180 @@
+/*
+ * 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.paimon.utils;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.apache.paimon.utils.SensitiveConfigUtils.REDACTED;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link SensitiveConfigUtils}. */
+class SensitiveConfigUtilsTest {
+
+ @Test
+ void testIsSensitiveDetectsCredentialKeys() {
+ // Access keys / secrets in various vendor spellings.
+
assertThat(SensitiveConfigUtils.isSensitive("fs.oss.accessKeyId")).isTrue();
+
assertThat(SensitiveConfigUtils.isSensitive("fs.oss.accessKeySecret")).isTrue();
+
assertThat(SensitiveConfigUtils.isSensitive("fs.s3a.access.key")).isTrue();
+
assertThat(SensitiveConfigUtils.isSensitive("fs.s3a.secret.key")).isTrue();
+
assertThat(SensitiveConfigUtils.isSensitive("fs.s3a.session.token")).isTrue();
+
assertThat(SensitiveConfigUtils.isSensitive("security.token")).isTrue();
+ assertThat(SensitiveConfigUtils.isSensitive("my.password")).isTrue();
+
assertThat(SensitiveConfigUtils.isSensitive("some.credential")).isTrue();
+ assertThat(SensitiveConfigUtils.isSensitive("Authorization")).isTrue();
+
assertThat(SensitiveConfigUtils.isSensitive("client.private-key")).isTrue();
+ assertThat(SensitiveConfigUtils.isSensitive("dlf.api-key")).isTrue();
+ // Azure / S3 cloud credentials.
+
assertThat(SensitiveConfigUtils.isSensitive("fs.azure.account.key.acct.blob.core"))
+ .isTrue();
+
assertThat(SensitiveConfigUtils.isSensitive("fs.azure.sas.container.acct.blob.core"))
+ .isTrue();
+
assertThat(SensitiveConfigUtils.isSensitive("fs.s3a.encryption.key")).isTrue();
+ }
+
+ @Test
+ void testIsSensitiveIgnoresNonCredentialKeys() {
+ assertThat(SensitiveConfigUtils.isSensitive("bucket")).isFalse();
+ assertThat(SensitiveConfigUtils.isSensitive("warehouse")).isFalse();
+
assertThat(SensitiveConfigUtils.isSensitive("fs.oss.endpoint")).isFalse();
+ assertThat(SensitiveConfigUtils.isSensitive("metastore")).isFalse();
+ assertThat(SensitiveConfigUtils.isSensitive("")).isFalse();
+ assertThat(SensitiveConfigUtils.isSensitive(null)).isFalse();
+ }
+
+ @Test
+ void testRedactValue() {
+ // Identifier-like access-key id keeps only its last 4 chars.
+ assertThat(SensitiveConfigUtils.redactValue("fs.oss.accessKeyId",
"0123456789abcdef"))
+ .isEqualTo("****cdef");
+ // Short value is fully masked.
+ assertThat(SensitiveConfigUtils.redactValue("password",
"short")).isEqualTo(REDACTED);
+ // Non-sensitive key is untouched.
+ assertThat(SensitiveConfigUtils.redactValue("bucket",
"my-bucket")).isEqualTo("my-bucket");
+ }
+
+ @Test
+ void testTrueSecretsAreFullyMasked() {
+ // True secrets are never partially revealed (AWS/Azure: the secret is
never shown).
+ for (String key :
+ new String[] {
+ "my.password",
+ "security.token",
+ "Authorization",
+ "fs.s3a.secret.key",
+ "fs.oss.accessKeySecret",
+ "client.private-key",
+ "fs.s3a.encryption.key",
+ "fs.azure.account.key.acct",
+ "fs.azure.sas.acct",
+ "some.credential"
+ }) {
+ assertThat(SensitiveConfigUtils.redactValue(key,
"0123456789abcdef"))
+ .as(key)
+ .isEqualTo(REDACTED);
+ }
+ // Contrast: an access-key id (an identifier) keeps its tail.
+ assertThat(SensitiveConfigUtils.redactValue("fs.oss.accessKeyId",
"0123456789abcdef"))
+ .isEqualTo("****cdef");
+ }
+
+ @Test
+ void testRedactMapReplacesOnlySensitiveValues() {
+ Map<String, String> options = new LinkedHashMap<>();
+ options.put("warehouse", "mock://warehouse");
+ options.put("fs.oss.accessKeyId", "mock-access-id-0001");
+ options.put("fs.oss.accessKeySecret", "mock-secret-value");
+ options.put("fs.azure.account.key.acct", "mock-azure-key-value");
+ options.put("fs.oss.endpoint", "mock-endpoint.example.com");
+
+ Map<String, String> redacted = SensitiveConfigUtils.redactMap(options);
+
+ assertThat(redacted).containsEntry("warehouse", "mock://warehouse");
+ // Access-key id keeps a tail; true secrets are fully masked; endpoint
is untouched.
+ assertThat(redacted).containsEntry("fs.oss.accessKeyId", "****0001");
+ assertThat(redacted).containsEntry("fs.oss.accessKeySecret", REDACTED);
+ assertThat(redacted).containsEntry("fs.azure.account.key.acct",
REDACTED);
+ assertThat(redacted).containsEntry("fs.oss.endpoint",
"mock-endpoint.example.com");
+ // Original map must not be mutated.
+ assertThat(options).containsEntry("fs.oss.accessKeySecret",
"mock-secret-value");
+ // The rendered string must not contain any raw secret.
+ assertThat(redacted.toString())
+ .doesNotContain("mock-secret-value")
+ .doesNotContain("mock-access-id-0001");
+ }
+
+ @Test
+ void testRedactMapNullSafe() {
+ assertThat(SensitiveConfigUtils.redactMap(null)).isNull();
+ }
+
+ @Test
+ void testRedactTextRedactsWholeMarkedText() {
+ for (String text :
+ new String[] {
+
"{\"accessKeySecret\":\"mock-secret-abcd\",\"endpoint\":\"mock\"}",
+ "password=mock-pass&user=alice",
+ "Authorization: Bearer mock-jwt-token-abcdef",
+ "{\"password\":\"alpha,beta\"}",
+ "{\"sas\":\"sv=2024-11-04&sig=REST-LEAK\"}",
+ "invalid token SECRET-9999 in request",
+ "https://x.blob.core.windows.net/c/f?sig=SECRETSIG",
+ "api_key=mock",
+ "private.key: mock",
+ "fs.s3a.access.key=mock",
+ "{\"apiKey\":\"mock\"}"
+ }) {
+
assertThat(SensitiveConfigUtils.redactText(text)).as(text).isEqualTo(REDACTED);
+ }
+ }
+
+ @Test
+ void testRedactTextKeepsTextWithoutMarkers() {
+ assertThat(SensitiveConfigUtils.redactText(null)).isNull();
+ assertThat(SensitiveConfigUtils.redactText("")).isEmpty();
+ assertThat(SensitiveConfigUtils.redactText("Table not found:
default.t"))
+ .isEqualTo("Table not found: default.t");
+ }
+
+ @Test
+ void testSanitizeUriDropsQueryAndUserInfo() {
+
assertThat(SensitiveConfigUtils.sanitizeUri("https://host:443/p?sig=x&X-Amz-Signature=y"))
+ .isEqualTo("https://host:443/p");
+
assertThat(SensitiveConfigUtils.sanitizeUri("https://alice:secret@host/p?sig=x"))
+ .isEqualTo("https://host/p");
+ }
+
+ @Test
+ void testSanitizeUriFailsClosedOnUnparseableUri() {
+ for (String uri :
+ new String[] {
+ "https://alice:secret@host/bad path?sig=x",
+ "https://alice:se/cret@host/bad path?sig=x",
+ "https://alice:se@cret@host/bad path?sig=x",
+ "https://alice:se/cret@host/p?sig=x"
+ }) {
+ assertThat(SensitiveConfigUtils.sanitizeUri(uri))
+ .as(uri)
+ .isEqualTo(SensitiveConfigUtils.INVALID_URI);
+ }
+ }
+}
diff --git
a/paimon-common/src/main/java/org/apache/paimon/utils/HadoopUtils.java
b/paimon-common/src/main/java/org/apache/paimon/utils/HadoopUtils.java
index a37c64dbb8..eb21b4eb87 100644
--- a/paimon-common/src/main/java/org/apache/paimon/utils/HadoopUtils.java
+++ b/paimon-common/src/main/java/org/apache/paimon/utils/HadoopUtils.java
@@ -140,11 +140,12 @@ public class HadoopUtils {
String newKey = key.substring(prefix.length());
String value = options.getString(key, null);
result.set(newKey, value);
+ // Redact value: sensitive keys are masked, others printed
as-is.
LOG.debug(
"Adding Paimon config entry for {} as {}={} to
Hadoop config",
key,
newKey,
- value);
+ SensitiveConfigUtils.redactValue(newKey, value));
foundHadoopConfiguration = true;
}
}
diff --git
a/paimon-common/src/main/java/org/apache/paimon/utils/UriReaderFactory.java
b/paimon-common/src/main/java/org/apache/paimon/utils/UriReaderFactory.java
index a2fd2d4ad3..08f0691303 100644
--- a/paimon-common/src/main/java/org/apache/paimon/utils/UriReaderFactory.java
+++ b/paimon-common/src/main/java/org/apache/paimon/utils/UriReaderFactory.java
@@ -44,11 +44,19 @@ public class UriReaderFactory implements Serializable {
}
public UriReader create(String input) {
- URI uri = URI.create(input);
+ URI uri = parseUri(input);
UriKey key = new UriKey(uri.getScheme(), uri.getAuthority());
return readers.computeIfAbsent(key, k -> newReader(k, uri));
}
+ private static URI parseUri(String input) {
+ try {
+ return URI.create(input);
+ } catch (IllegalArgumentException e) {
+ throw SensitiveConfigUtils.invalidUri(input);
+ }
+ }
+
public boolean exists(String input) throws IOException {
UriReader reader = create(input);
if (reader instanceof UriReader.FileUriReader) {
diff --git
a/paimon-common/src/test/java/org/apache/paimon/utils/HadoopUtilsSecretLoggingTest.java
b/paimon-common/src/test/java/org/apache/paimon/utils/HadoopUtilsSecretLoggingTest.java
new file mode 100644
index 0000000000..7f092b8a86
--- /dev/null
+++
b/paimon-common/src/test/java/org/apache/paimon/utils/HadoopUtilsSecretLoggingTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.paimon.utils;
+
+import org.apache.paimon.options.Options;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.Logger;
+import org.apache.logging.log4j.core.appender.OutputStreamAppender;
+import org.apache.logging.log4j.core.layout.PatternLayout;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that {@link HadoopUtils#getHadoopConfiguration} forwards {@code
hadoop.*} credentials to
+ * the Hadoop configuration without leaking their values into the (DEBUG) logs.
+ */
+class HadoopUtilsSecretLoggingTest {
+
+ private static final String SECRET_MARKER =
"UNIQUE-SECRET-MARKER-9f8e7d6c";
+
+ @Test
+ void testCredentialValueIsNotLogged() {
+ Options options = new Options();
+ // hadoop.* keys are forwarded (prefix stripped) into the Hadoop
configuration.
+ options.set("hadoop.fs.oss.accessKeySecret", SECRET_MARKER);
+ options.set("hadoop.fs.oss.endpoint", "mock-endpoint.example.com");
+
+ // Warm up: first Hadoop Configuration build reconfigures log4j2 and
drops our appender.
+ HadoopUtils.getHadoopConfiguration(new Options());
+
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ OutputStreamAppender appender =
+ OutputStreamAppender.newBuilder()
+ .setName("hadoop-utils-secret-test")
+ .setTarget(output)
+
.setLayout(PatternLayout.newBuilder().withPattern("%level %msg%n").build())
+ .build();
+ Logger logger = (Logger) LogManager.getLogger(HadoopUtils.class);
+ Level previousLevel = logger.getLevel();
+
+ appender.start();
+ logger.addAppender(appender);
+ logger.setLevel(Level.DEBUG);
+ try {
+ Configuration conf = HadoopUtils.getHadoopConfiguration(options);
+
+ // The credential must still be forwarded so the configuration
keeps working.
+
assertThat(conf.get("fs.oss.accessKeySecret")).isEqualTo(SECRET_MARKER);
+
+ String logs = new String(output.toByteArray(),
StandardCharsets.UTF_8);
+ // The key name may appear, but the secret value must never be
logged.
+ assertThat(logs).contains("fs.oss.accessKeySecret");
+ assertThat(logs).doesNotContain(SECRET_MARKER);
+ } finally {
+ logger.setLevel(previousLevel);
+ logger.removeAppender(appender);
+ appender.stop();
+ }
+ }
+}
diff --git
a/paimon-common/src/test/java/org/apache/paimon/utils/UriReaderFactoryTest.java
b/paimon-common/src/test/java/org/apache/paimon/utils/UriReaderFactoryTest.java
index 3cf1cbaf3e..e0679cb74f 100644
---
a/paimon-common/src/test/java/org/apache/paimon/utils/UriReaderFactoryTest.java
+++
b/paimon-common/src/test/java/org/apache/paimon/utils/UriReaderFactoryTest.java
@@ -36,6 +36,7 @@ import java.net.InetSocketAddress;
import java.nio.file.Files;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** Test for {@link UriReaderFactory}. */
public class UriReaderFactoryTest {
@@ -68,6 +69,19 @@ public class UriReaderFactoryTest {
assertThat(reader).isInstanceOf(HttpUriReader.class);
}
+ @Test
+ public void testInvalidUriDoesNotLeakCredentials() {
+ assertThatThrownBy(
+ () -> factory.create("https://alice:secret@host/bad
path?sig=QUERY_SECRET"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasNoCause()
+ .satisfies(
+ e -> {
+
assertThat(String.valueOf(e)).doesNotContain("secret");
+
assertThat(String.valueOf(e)).doesNotContain("QUERY_SECRET");
+ });
+ }
+
@Test
public void testCreateHttpsUriReader() {
UriReader reader = factory.create("https://example.com/file.txt");
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/HttpClientTest.java
b/paimon-core/src/test/java/org/apache/paimon/rest/HttpClientTest.java
index a742419037..5bdc553fdf 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/HttpClientTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/HttpClientTest.java
@@ -23,6 +23,7 @@ import org.apache.paimon.rest.auth.BearTokenAuthProvider;
import org.apache.paimon.rest.auth.RESTAuthFunction;
import org.apache.paimon.rest.auth.RESTAuthParameter;
import org.apache.paimon.rest.exceptions.BadRequestException;
+import org.apache.paimon.rest.exceptions.RESTException;
import org.apache.paimon.rest.responses.ErrorResponse;
import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableMap;
@@ -43,6 +44,7 @@ import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** Test for {@link HttpClient}. */
@@ -130,6 +132,51 @@ public class HttpClientTest {
() -> httpClient.get(MOCK_PATH, MockRESTData.class,
restAuthFunction));
}
+ @Test
+ public void testErrorResponseMessageIsRedacted() throws Exception {
+ // A parsed ErrorResponse whose message carries a secret must not leak
it.
+ String secret = "SUPERSECRETVALUE9999";
+ String body =
+ server.createResponseBody(
+ new ErrorResponse(
+ ErrorResponse.RESOURCE_TYPE_DATABASE,
+ "test",
+ "token=" + secret,
+ 400));
+ server.enqueueResponse(body, 400);
+ BadRequestException e =
+ assertThrows(
+ BadRequestException.class,
+ () -> httpClient.get(MOCK_PATH, MockRESTData.class,
restAuthFunction));
+ assertFalse(e.getMessage().contains(secret));
+ }
+
+ @Test
+ public void testUnparseableErrorBodyIsNotEchoed() {
+ // A non-JSON error body cannot be reliably sanitized, so it must not
be echoed at all.
+ String secret = "SUPERSECRETVALUE9999";
+ server.enqueueResponse("token=" + secret + " <<not json>>", 400);
+ BadRequestException e =
+ assertThrows(
+ BadRequestException.class,
+ () -> httpClient.get(MOCK_PATH, MockRESTData.class,
restAuthFunction));
+ assertFalse(e.getMessage().contains(secret));
+ }
+
+ @Test
+ public void testSuccessResponseParseFailureDoesNotLeakBody() {
+ // A 2xx body that fails to deserialize (e.g. a token response) must
not surface the
+ // raw body or Jackson's source snippet.
+ String secret = "SUPERSECRETVALUE9999";
+ String body = "{\"data\": \"token=" + secret + "\" INVALID_JSON}";
+ server.enqueueResponse(body, 200);
+ RESTException e =
+ assertThrows(
+ RESTException.class,
+ () -> httpClient.get(MOCK_PATH, MockRESTData.class,
restAuthFunction));
+ assertFalse(e.getMessage().contains(secret));
+ }
+
@Test
public void testPostSuccess() {
server.enqueueResponse(mockResponseDataStr, 200);
@@ -228,8 +275,8 @@ public class HttpClientTest {
@Test
public void testGetWithUnparsableJsonErrorResponse() {
- // Test case for JSON response with mismatched field names that cannot
be parsed as
- // ErrorResponse
+ // A JSON body that parses to an ErrorResponse with no message must
NOT be echoed (it may
+ // carry secrets); it is reported as an empty message, not
"unparseable".
String jsonWithUppercaseFields =
"{\"Message\":\"Your request is denied as lack of ssl
protect.\","
+ "\"Code\":\"InvalidProtocol.NeedSsl\"}";
@@ -239,16 +286,19 @@ public class HttpClientTest {
httpClient.get(MOCK_PATH, MockRESTData.class, restAuthFunction);
Assertions.fail("Expected exception to be thrown");
} catch (Exception e) {
- Assertions.assertTrue(
+ Assertions.assertFalse(
e.getMessage().contains("Your request is denied as lack of
ssl protect")
||
e.getMessage().contains(jsonWithUppercaseFields),
- "Error message should contain the original response body");
+ "Raw response body must not be echoed");
+ Assertions.assertTrue(
+ e.getMessage().contains("Empty error message"),
+ "Parsed-but-empty message must not be labelled
unparseable");
}
}
@Test
public void testPostWithNonJsonErrorResponse() {
- // Test case for non-JSON response (plain text) that cannot be parsed
+ // A non-JSON (plain text) error body must NOT be echoed; only a
generic message remains.
String plainTextResponse = "Internal Server Error: Database connection
failed";
server.enqueueResponse(plainTextResponse, 500);
@@ -256,14 +306,45 @@ public class HttpClientTest {
httpClient.post(MOCK_PATH, mockResponseData, MockRESTData.class,
restAuthFunction);
Assertions.fail("Expected exception to be thrown");
} catch (Exception e) {
- // Verify that the error message contains the original plain text
response
- Assertions.assertTrue(
+ Assertions.assertFalse(
e.getMessage().contains(plainTextResponse)
|| e.getMessage().contains("Database connection
failed"),
- "Error message should contain the original non-JSON
response");
+ "Raw response body must not be echoed");
+ Assertions.assertTrue(
+ e.getMessage().contains("500"), "Message should carry the
HTTP status");
}
}
+ @Test
+ public void testGetWithMalformedUrlDoesNotLeakCredentials() {
+ // Malformed URL throws in the constructor before exec(); the raw URL
must not leak.
+ String secret = "QUERY_SECRET";
+ IllegalArgumentException e =
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ httpClient.get(
+ "/x?sig=" + secret + " bad",
+ MockRESTData.class,
+ restAuthFunction));
+ assertFalse(e.getMessage().contains(secret));
+ }
+
+ @Test
+ public void testPostWithMalformedUrlDoesNotLeakCredentials() {
+ String secret = "QUERY_SECRET";
+ IllegalArgumentException e =
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ httpClient.post(
+ "/x?sig=" + secret + " bad",
+ mockResponseData,
+ MockRESTData.class,
+ restAuthFunction));
+ assertFalse(e.getMessage().contains(secret));
+ }
+
@Test
public void testPostSetsJsonContentType() throws Exception {
server.enqueueResponse(mockResponseDataStr, 200);
diff --git
a/paimon-filesystems/paimon-cosn-impl/src/main/java/org/apache/paimon/cosn/COSNFileIO.java
b/paimon-filesystems/paimon-cosn-impl/src/main/java/org/apache/paimon/cosn/COSNFileIO.java
index 00b0bc6012..0f0096751d 100644
---
a/paimon-filesystems/paimon-cosn-impl/src/main/java/org/apache/paimon/cosn/COSNFileIO.java
+++
b/paimon-filesystems/paimon-cosn-impl/src/main/java/org/apache/paimon/cosn/COSNFileIO.java
@@ -21,6 +21,7 @@ package org.apache.paimon.cosn;
import org.apache.paimon.catalog.CatalogContext;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.options.Options;
+import org.apache.paimon.utils.SensitiveConfigUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
@@ -83,7 +84,7 @@ public class COSNFileIO extends HadoopCompliantFileIO {
LOG.debug(
"Adding config entry for {} as {} to Hadoop
config",
key,
- hadoopOptions.get(key));
+ SensitiveConfigUtils.redactValue(key,
hadoopOptions.get(key)));
}
}
}
diff --git
a/paimon-filesystems/paimon-gs-impl/src/main/java/org/apache/paimon/gs/GSFileIO.java
b/paimon-filesystems/paimon-gs-impl/src/main/java/org/apache/paimon/gs/GSFileIO.java
index 2226d8be32..d2efcad70c 100644
---
a/paimon-filesystems/paimon-gs-impl/src/main/java/org/apache/paimon/gs/GSFileIO.java
+++
b/paimon-filesystems/paimon-gs-impl/src/main/java/org/apache/paimon/gs/GSFileIO.java
@@ -21,6 +21,7 @@ package org.apache.paimon.gs;
import org.apache.paimon.catalog.CatalogContext;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.options.Options;
+import org.apache.paimon.utils.SensitiveConfigUtils;
import com.google.cloud.hadoop.fs.gcs.GoogleHadoopFileSystem;
import org.apache.hadoop.conf.Configuration;
@@ -70,7 +71,7 @@ public class GSFileIO extends HadoopCompliantFileIO {
LOG.warn(
"Adding config entry for {} as {} to Hadoop
config",
key,
- hadoopOptions.get(key));
+ SensitiveConfigUtils.redactValue(key,
hadoopOptions.get(key)));
}
}
}
diff --git
a/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoFileIO.java
b/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoFileIO.java
index c5e2d13a77..7cab897e76 100644
---
a/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoFileIO.java
+++
b/paimon-filesystems/paimon-jindo/src/main/java/org/apache/paimon/jindo/JindoFileIO.java
@@ -26,6 +26,7 @@ import org.apache.paimon.fs.TwoPhaseOutputStream;
import org.apache.paimon.options.Options;
import org.apache.paimon.utils.IOUtils;
import org.apache.paimon.utils.Pair;
+import org.apache.paimon.utils.SensitiveConfigUtils;
import org.apache.paimon.utils.StringUtils;
import com.aliyun.jindodata.common.JindoHadoopSystem;
@@ -113,7 +114,7 @@ public class JindoFileIO extends HadoopCompliantFileIO
implements HadoopOptionsP
LOG.debug(
"Adding config entry for {} as {} to Hadoop
config",
key,
- hadoopOptions.get(key));
+ SensitiveConfigUtils.redactValue(key,
hadoopOptions.get(key)));
}
}
}
diff --git
a/paimon-filesystems/paimon-obs-impl/src/main/java/org/apache/paimon/obs/OBSFileIO.java
b/paimon-filesystems/paimon-obs-impl/src/main/java/org/apache/paimon/obs/OBSFileIO.java
index ae11dc2f9e..4f425ca590 100644
---
a/paimon-filesystems/paimon-obs-impl/src/main/java/org/apache/paimon/obs/OBSFileIO.java
+++
b/paimon-filesystems/paimon-obs-impl/src/main/java/org/apache/paimon/obs/OBSFileIO.java
@@ -21,6 +21,7 @@ package org.apache.paimon.obs;
import org.apache.paimon.catalog.CatalogContext;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.options.Options;
+import org.apache.paimon.utils.SensitiveConfigUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
@@ -99,7 +100,7 @@ public class OBSFileIO extends HadoopCompliantFileIO {
LOG.debug(
"Adding config entry for {} as {} to Hadoop
config",
key,
- hadoopOptions.get(key));
+ SensitiveConfigUtils.redactValue(key,
hadoopOptions.get(key)));
}
}
}
diff --git
a/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSFileIO.java
b/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSFileIO.java
index 410c460bb4..c397eca2f3 100644
---
a/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSFileIO.java
+++
b/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSFileIO.java
@@ -26,6 +26,7 @@ import org.apache.paimon.fs.TwoPhaseOutputStream;
import org.apache.paimon.options.Options;
import org.apache.paimon.utils.IOUtils;
import org.apache.paimon.utils.ReflectionUtils;
+import org.apache.paimon.utils.SensitiveConfigUtils;
import org.apache.paimon.utils.StringUtils;
import com.aliyun.oss.OSSClient;
@@ -145,7 +146,7 @@ public class OSSFileIO extends HadoopCompliantFileIO
implements HadoopOptionsPro
LOG.debug(
"Adding config entry for {} as {} to Hadoop
config",
key,
- hadoopOptions.get(key));
+ SensitiveConfigUtils.redactValue(key,
hadoopOptions.get(key)));
}
}
}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/AbstractFlinkTableFactory.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/AbstractFlinkTableFactory.java
index c13a1bb158..c3e55247c3 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/AbstractFlinkTableFactory.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/AbstractFlinkTableFactory.java
@@ -37,6 +37,7 @@ import org.apache.paimon.table.FileStoreTableFactory;
import org.apache.paimon.table.FormatTable;
import org.apache.paimon.table.Table;
import org.apache.paimon.utils.Preconditions;
+import org.apache.paimon.utils.SensitiveConfigUtils;
import org.apache.flink.api.common.RuntimeExecutionMode;
import org.apache.flink.configuration.ConfigOption;
@@ -289,7 +290,7 @@ public abstract class AbstractFlinkTableFactory
LOG.info(
"Loading dynamic table options for {} in table config: {}",
tableName,
- optionsFromTableConfig);
+ SensitiveConfigUtils.redactMap(optionsFromTableConfig));
}
return optionsFromTableConfig;
}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/FlinkRowWrapper.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/FlinkRowWrapper.java
index a6d900262b..5e587f9780 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/FlinkRowWrapper.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/FlinkRowWrapper.java
@@ -33,6 +33,7 @@ import org.apache.paimon.data.variant.Variant;
import org.apache.paimon.rest.HttpClientUtils;
import org.apache.paimon.types.DataTypeRoot;
import org.apache.paimon.types.RowKind;
+import org.apache.paimon.utils.SensitiveConfigUtils;
import org.apache.paimon.utils.UriReaderFactory;
import org.apache.flink.table.data.DecimalData;
@@ -240,7 +241,7 @@ public class FlinkRowWrapper implements InternalRow {
}
LOG.warn(
"Failed to check blob descriptor file {} for BLOB field at
position {}.",
- descriptor.uri(),
+ SensitiveConfigUtils.sanitizeUri(descriptor.uri()),
pos,
e);
throw new RuntimeException(e);
@@ -250,7 +251,7 @@ public class FlinkRowWrapper implements InternalRow {
}
LOG.warn(
"Failed to check blob descriptor file {} for BLOB field at
position {}.",
- descriptor.uri(),
+ SensitiveConfigUtils.sanitizeUri(descriptor.uri()),
pos,
e);
throw e;
@@ -261,12 +262,12 @@ public class FlinkRowWrapper implements InternalRow {
if (isHttpUri(descriptor.uri())) {
LOG.warn(
"Blob descriptor file {} returned HTTP 404, returning NULL
for BLOB field at position {}.",
- descriptor.uri(),
+ SensitiveConfigUtils.sanitizeUri(descriptor.uri()),
pos);
} else {
LOG.warn(
"Blob descriptor file {} does not exist, returning NULL
for BLOB field at position {}.",
- descriptor.uri(),
+ SensitiveConfigUtils.sanitizeUri(descriptor.uri()),
pos);
}
}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/ConfigRefresher.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/ConfigRefresher.java
index 1d4b8deb08..917b851ca6 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/ConfigRefresher.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/ConfigRefresher.java
@@ -23,6 +23,7 @@ import org.apache.paimon.flink.FlinkConnectorOptions;
import org.apache.paimon.options.Options;
import org.apache.paimon.schema.TableSchema;
import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.utils.SensitiveConfigUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -101,8 +102,8 @@ public class ConfigRefresher {
refresher.refresh(table);
LOG.info(
"write has been refreshed due to configs changed.
old options:{}, new options:{}.",
- currentOptions,
- newOptions);
+ SensitiveConfigUtils.redactMap(currentOptions),
+ SensitiveConfigUtils.redactMap(newOptions));
}
} catch (Exception e) {
throw new RuntimeException("update write failed.", e);
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkTableSource.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkTableSource.java
index 66cb49798a..534783fb02 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkTableSource.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkTableSource.java
@@ -37,6 +37,7 @@ import org.apache.paimon.table.DataTable;
import org.apache.paimon.table.Table;
import org.apache.paimon.table.source.Split;
import org.apache.paimon.utils.RowDataToObjectArrayConverter;
+import org.apache.paimon.utils.SensitiveConfigUtils;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
@@ -180,7 +181,10 @@ public abstract class FlinkTableSource
// In older versions of Flink, however, lookup sources will first
be treated as normal
// sources. So this method will also be visited by lookup tables,
and the options might
// cause IllegalArgumentException. In this case we ignore the
filters.
- LOG.info("Failed to get filter with table options {} ",
table.options(), e);
+ LOG.info(
+ "Failed to get filter with table options {} ",
+ SensitiveConfigUtils.redactMap(table.options()),
+ e);
return null;
}
}
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/FlinkRowWrapperTest.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/FlinkRowWrapperTest.java
index 517917e4c0..3141ef7062 100644
---
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/FlinkRowWrapperTest.java
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/FlinkRowWrapperTest.java
@@ -167,7 +167,8 @@ public class FlinkRowWrapperTest {
assertThatThrownBy(() -> wrapper.isNullAt(0))
.isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("Illegal character");
+ .hasMessageContaining("Invalid URI")
+ .hasMessageNotContaining("1304008055350781673");
}
@Test
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFormatWriter.java
b/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFormatWriter.java
index 5e2bc9f9bb..f33133b5a2 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFormatWriter.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFormatWriter.java
@@ -37,6 +37,7 @@ import org.apache.paimon.types.DataTypeRoot;
import org.apache.paimon.types.RowType;
import org.apache.paimon.utils.DeltaVarintCompressor;
import org.apache.paimon.utils.LongArrayList;
+import org.apache.paimon.utils.SensitiveConfigUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -360,7 +361,8 @@ public class BlobFormatWriter implements
FileAwareFormatWriter {
private static String blobUri(@Nullable Blob blob) {
if (blob instanceof BlobRef) {
- return ((BlobRef) blob).toDescriptor().uri();
+ // Sanitize: a signed URL carries token/signature in its query.
+ return SensitiveConfigUtils.sanitizeUri(((BlobRef)
blob).toDescriptor().uri());
}
return "unknown";
}