Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@
- [feat: support config effective value ](https://github.com/Tencent/spring-cloud-tencent/pull/1845)
- [feat: support audit log](https://github.com/Tencent/spring-cloud-tencent/pull/1844)
- [feat: support encoding and decoding TSF headers without TSF Consul](https://github.com/Tencent/spring-cloud-tencent/pull/1846)
- [feat: protect encrypted config values and improve config cache fallback](https://github.com/Tencent/spring-cloud-tencent/pull/1849)
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package com.tencent.cloud.polaris.config;

import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
Expand Down Expand Up @@ -106,9 +107,10 @@ private void initDataSource(ConfigurationImpl configuration) {
ConnectorConfigImpl connectorConfig = configuration.getConfigFile().getServerConnector();
// set connector type
connectorConfig.setConnectorType(polarisConfigProperties.getDataSource());
String localFileRootPath = polarisConfigProperties.getLocalFileRootPath();
// The same directory stores local-source files and remote-source fallback caches.
connectorConfig.setPersistDir(localFileRootPath);
if (StringUtils.equalsIgnoreCase(polarisConfigProperties.getDataSource(), LOCAL_FILE_CONNECTOR_TYPE)) {
String localFileRootPath = polarisConfigProperties.getLocalFileRootPath();
connectorConfig.setPersistDir(localFileRootPath);
LOGGER.info("[SCT] Run spring cloud tencent config with local data source. localFileRootPath = {}", localFileRootPath);
return;
}
Expand All @@ -131,7 +133,7 @@ private void initDataSource(ConfigurationImpl configuration) {

// enable close check address for unit tests
if (polarisConfigProperties.isCheckAddress()) {
checkAddressAccessible(configAddresses);
checkAddressAccessible(configAddresses, connectorConfig.getFallbackToLocalCache());
}

connectorConfig.setAddresses(configAddresses);
Expand Down Expand Up @@ -218,7 +220,7 @@ private List<String> resolvePolarisAddressFromConfigAddress(String configAddress
return polarisAddresses;
}

private void checkAddressAccessible(List<String> configAddresses) {
private void checkAddressAccessible(List<String> configAddresses, Boolean fallbackToLocalCache) {
// check address can connect
configAddresses.forEach(address -> {
String[] ipPort;
Expand All @@ -243,12 +245,47 @@ private void checkAddressAccessible(List<String> configAddresses) {
String errMsg = "Config server address (" + address + ") can not be connected. Please check your config in bootstrap.yml"
+ " with spring.cloud.polaris.address or spring.cloud.polaris.config.address.";
if (polarisConfigProperties.isShutdownIfConnectToConfigServerFailed()) {
throw new IllegalArgumentException(errMsg);
if (Boolean.TRUE.equals(fallbackToLocalCache) && hasLocalConfigCache()) {
LOGGER.warn("{} Continue startup to fall back to cached config files in {}.",
errMsg, polarisConfigProperties.getLocalFileRootPath());
}
else {
throw new IllegalArgumentException(errMsg);
}
}
else {
LOGGER.error(errMsg);
}
}
});
}

private boolean hasLocalConfigCache() {
String rootPath = polarisConfigProperties.getLocalFileRootPath();
if (StringUtils.isBlank(rootPath)) {
return false;
}
File[] files = new File(rootPath).listFiles(this::isPolarisConfigCacheFile);
return files != null && files.length > 0;
}

/**
* Polaris persist files use {@code encodedNamespace#encodedFileGroup#encodedFileName.yaml}.
* Empty files and names that only happen to contain {@code #} are ignored.
*/
private boolean isPolarisConfigCacheFile(File file) {
if (file == null || !file.isFile() || file.length() <= 0) {
return false;
}
String name = file.getName();
if (!name.endsWith(".yaml")) {
return false;
}
String stem = name.substring(0, name.length() - ".yaml".length());
String[] parts = stem.split("#", -1);
return parts.length == 3
&& StringUtils.isNotBlank(parts[0])
&& StringUtils.isNotBlank(parts[1])
&& StringUtils.isNotBlank(parts[2]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@

package com.tencent.cloud.polaris.config.adapter;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HashSet;
Expand Down Expand Up @@ -66,6 +70,24 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL

private static final Logger LOGGER = LoggerFactory.getLogger(PolarisConfigPropertyAutoRefresher.class);
private static final Set<String> registeredPolarisPropertySets = Sets.newConcurrentHashSet();
/**
* Property keys contributed by encrypted config files. Values of these keys must never be
* written to logs in plain text.
* <p>
* Grow-only on purpose: once a key is known to be sensitive, keep masking it even after the
* encrypted file drops it. Over-masking only costs troubleshooting convenience, while
* under-masking is a leak.
*/
private static final Set<String> encryptedPropertyKeys = Sets.newConcurrentHashSet();
private static final String FINGERPRINT_ALGORITHM = "SHA-256";
/**
* Number of digest bytes kept in a fingerprint. 4 bytes are enough to tell two values apart.
*/
private static final int FINGERPRINT_BYTES = 4;
/**
* Random per JVM: see {@link #fingerprint(String)} for why the digest must be salted.
*/
private static final byte[] FINGERPRINT_SALT = newFingerprintSalt();
private final PolarisConfigProperties polarisConfigProperties;
private final AtomicBoolean registered = new AtomicBoolean(false);
// this class provides customized logic for some customers to configure special business group files
Expand Down Expand Up @@ -163,6 +185,9 @@ private void registerPolarisConfigGroupChangeListener(PolarisPropertySource pola
changedKeys.addAll(p.getSource().keySet());
this.registerPolarisConfigPublishChangeListener(p, polarisPropertySource);
PolarisPropertySourceManager.addPropertySource(p);
// the loaded file carries the per-file encrypted flag, so register its keys
// right here rather than waiting for the first change event on that file
markEncryptedKeys(p.getConfigKVFile());
for (String changedKey : p.getSource().keySet()) {
polarisPropertySource.getSource().put(changedKey, p.getSource().get(changedKey));
refreshSpringValue(changedKey);
Expand Down Expand Up @@ -191,6 +216,11 @@ public void registerPolarisConfigPublishChangeListener(PolarisPropertySource lis
LOGGER.info("[SCT Config] received polaris config change event and will refresh spring context." + " namespace = {}, group = {}, fileName = {}",
listenPolarisPropertySource.getNamespace(), listenPolarisPropertySource.getGroup(), listenPolarisPropertySource.getFileName());

// the change event is the only place carrying the plugin-level ConfigFile,
// which is where the server-side per-file encrypted flag can be read
markEncryptedKeys(listenPolarisPropertySource.getConfigKVFile(),
configKVFileChangeEvent.getConfigFile(), configKVFileChangeEvent.changedKeys());

Map<String, Object> effectSource = effectPolarisPropertySource.getSource();
Map<String, Object> listenSource = listenPolarisPropertySource.getSource();
boolean isGroupRefresh = !listenPolarisPropertySource.equals(effectPolarisPropertySource);
Expand All @@ -204,13 +234,23 @@ public void registerPolarisConfigPublishChangeListener(PolarisPropertySource lis
for (String changedKey : configKVFileChangeEvent.changedKeys()) {
ConfigPropertyChangeInfo configPropertyChangeInfo = configKVFileChangeEvent.getChangeInfo(changedKey);

LOGGER.info("[SCT Config] changed property = {}", configPropertyChangeInfo);
if (isEncryptedKey(changedKey)) {
LOGGER.info("[SCT Config] changed property = [key={}, changeType={}, oldValue={}, newValue={}]",
configPropertyChangeInfo.getPropertyName(), configPropertyChangeInfo.getChangeType(),
maskValue(configPropertyChangeInfo.getOldValue()),
maskValue(configPropertyChangeInfo.getNewValue()));
}
else {
LOGGER.info("[SCT Config] changed property = {}", configPropertyChangeInfo);
}

// new ability to dynamically change log levels
try {
if (changedKey.startsWith("logging.level") && changedKey.length() >= 14) {
String loggerName = changedKey.substring(14);
String newValue = (String) configPropertyChangeInfo.getNewValue();
// not masked even in an encrypted file: encryption is per file, and
// the value here is a log level, never a secret
LOGGER.info("[SCT Config] set logging.level loggerName:{}, newValue:{}", loggerName, newValue);
PolarisConfigLoggerContext.setLevel(loggerName, newValue);
}
Expand Down Expand Up @@ -267,6 +307,122 @@ private void customRegisterPolarisConfigPublishChangeListener(PolarisPropertySou
polarisConfigCustomExtensionLayer.executeRegisterPublishChangeListener(listenPolarisPropertySource, effectPolarisPropertySource);
}

/**
* Registers the property keys of an encrypted config file, so that their values can be masked
* in logs afterwards.
* <p>
* {@code configFile} must come from {@link ConfigKVFileChangeEvent#getConfigFile()}: that
* object belongs to the response chain, where {@code encrypted} is the per-file value pushed
* by the server. The request-side object is not usable as a criterion, because the crypto
* filter unconditionally sets it to true to declare crypto support.
*
* @param kvFile the config file whose property names will be registered
* @param configFile the plugin-level config file carrying the encrypted flag, may be null
* @param changedKeys keys from the change event; an ADDED key may not be in
* {@code kvFile.getPropertyNames()} yet
*/
private void markEncryptedKeys(ConfigKVFile kvFile, ConfigFile configFile, Set<String> changedKeys) {
if (configFile == null || !configFile.isEncrypted()) {
return;
}
if (kvFile != null) {
Set<String> propertyNames = kvFile.getPropertyNames();
if (!CollectionUtils.isEmpty(propertyNames)) {
encryptedPropertyKeys.addAll(propertyNames);
}
}
if (!CollectionUtils.isEmpty(changedKeys)) {
encryptedPropertyKeys.addAll(changedKeys);
}
}

/**
* Registers the property keys of a config file that reports itself as encrypted.
* <p>
* Used where no change event is available, e.g. a file newly added to a watched group.
* {@code ConfigKVFile#isEncrypted()} resolves to the server-pushed per-file flag, so it is
* usable from the very first load.
*
* @param kvFile the loaded config file, may be null
*/
private void markEncryptedKeys(ConfigKVFile kvFile) {
if (kvFile == null || !kvFile.isEncrypted()) {
return;
}
Set<String> propertyNames = kvFile.getPropertyNames();
if (!CollectionUtils.isEmpty(propertyNames)) {
encryptedPropertyKeys.addAll(propertyNames);
}
}

/**
* @param key the property key
* @return whether the value of the given key comes from an encrypted config file
*/
protected boolean isEncryptedKey(String key) {
return encryptedPropertyKeys.contains(key);
}

/**
* Masks a property value of an encrypted config file. The length and a fingerprint are kept as
* hints for troubleshooting, the content is not exposed.
* <p>
* Takes an Object rather than a String: both {@code ConfigPropertyChangeInfo#getOldValue()}
* and the resolved {@code @Value} result are declared as Object.
*
* @param value the raw value
* @return the masked value
*/
protected static String maskValue(Object value) {
if (value == null) {
return null;
}
String text = String.valueOf(value);
if (text.isEmpty()) {
return "";
}
return "***(len=" + text.length() + ", fp=" + fingerprint(text) + ")";
}

/**
* Salted and truncated digest of a value, so that two masked values can be told apart even when
* their lengths are equal (e.g. an old and a new password of the same length).
* <p>
* The salt is random per JVM on purpose. An unsalted digest of a single config value would be
* reversible by dictionary attack, since config values carry little entropy - that would defeat
* the masking. With a per-process salt the fingerprint stays comparable within one log file,
* which is what change diagnosis needs, and carries no information outside it.
* <p>
* Truncated to 4 bytes: a collision only makes two different values look alike, it never
* exposes a value.
*
* @param text the raw value
* @return an 8-char hex fingerprint
*/
private static byte[] newFingerprintSalt() {
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt);
return salt;
}

private static String fingerprint(String text) {
try {
MessageDigest digest = MessageDigest.getInstance(FINGERPRINT_ALGORITHM);
digest.update(FINGERPRINT_SALT);
byte[] hash = digest.digest(text.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder(FINGERPRINT_BYTES * 2);
for (int i = 0; i < FINGERPRINT_BYTES; i++) {
builder.append(Character.forDigit((hash[i] >> 4) & 0xF, 16));
builder.append(Character.forDigit(hash[i] & 0xF, 16));
}
return builder.toString();
}
catch (NoSuchAlgorithmException e) {
// SHA-256 is mandated by the JDK spec, so this is unreachable in practice
return "unavailable";
}
}

private Map<String, ConfigFileMetadata> calculateUnregister(List<ConfigFileMetadata> oldConfigFileMetadataList,
List<ConfigFileMetadata> newConfigFileMetadataList) {

Expand Down Expand Up @@ -334,4 +490,12 @@ private void reportEvent(Map<String, ConfigFileMetadata> added) {
public void setRegistered(boolean registered) {
this.registered.set(registered);
}

/**
* Just for junit test. {@code encryptedPropertyKeys} is static and grow-only, so it has to be
* reset between test methods.
*/
static void clearEncryptedPropertyKeys() {
encryptedPropertyKeys.clear();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public void refreshSpringValue(String changedKey) {
}
// update the attribute with @Value annotation
for (SpringValue val : targetValues) {
updateSpringValue(val);
updateSpringValue(changedKey, val);
}
}

Expand All @@ -101,15 +101,20 @@ public void refreshConfigurationProperties(Set<String> changeKeys) {
}
}

private void updateSpringValue(SpringValue springValue) {
private void updateSpringValue(String changedKey, SpringValue springValue) {
try {
Object value = resolvePropertyValue(springValue);
springValue.update(value);

LOGGER.info("[SCT Config] Auto update polaris changed value successfully, new value: {}, {}", value,
// values of encrypted config files must not be logged in plain text
Object displayValue = isEncryptedKey(changedKey) ? maskValue(value) : value;
LOGGER.info("[SCT Config] Auto update polaris changed value successfully, new value: {}, {}", displayValue,
springValue);
}
catch (Throwable ex) {
// SpringValue.toString() carries no property value, so it is safe to log as is.
// The stack trace may still embed the raw value (e.g. unresolvable placeholder),
// which is kept on purpose: losing it would make refresh failures undiagnosable.
LOGGER.error("[SCT Config] Auto update polaris changed value failed, {}", springValue.toString(), ex);
}
}
Expand Down
Loading
Loading