ActuatorConfiguration.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed 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.entrystore.rest.springboot.configuration;
import org.springframework.boot.actuate.endpoint.SanitizingFunction;
import org.springframework.boot.actuate.web.exchanges.HttpExchangeRepository;
import org.springframework.boot.actuate.web.exchanges.InMemoryHttpExchangeRepository;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* Actuator wiring that Spring Boot intentionally leaves to the application.
*
* <p>The {@code httpexchanges} endpoint records nothing — and is not even created — unless an
* {@link HttpExchangeRepository} bean is present. Spring Boot stopped auto-registering one because
* captured exchanges can hold sensitive request/response data. Registering an in-memory ring buffer
* (default capacity 100) makes the admin-only endpoint return the most recent exchanges. Recording
* runs on every request, so it can be switched off with
* {@code management.httpexchanges.recording.enabled=false} (on by default). Which fields are captured
* is controlled by {@code management.httpexchanges.recording.include} in {@code application.yaml};
* Cookie/Authorization headers and the session id are deliberately excluded there so the endpoint
* cannot surface live {@code auth_token} sessions.
*
* <p>The {@code env} endpoint runs with {@code show-values=WHEN_AUTHORIZED}, so it reveals property
* values to authorized admins. Modern Spring Boot no longer masks credentials automatically and the
* old {@code keys-to-sanitize} property is gone, so all masking here is supplied by two
* {@link SanitizingFunction} beans: {@link #environmentValueSanitizer()} masks a value when its key
* is credential-shaped, and {@link #urlCredentialSanitizer()} strips {@code user:pass@} userinfo from
* any string value containing a URL. The URL pass runs on every property; when the key is already
* credential-shaped the value has been fully masked by the first sanitizer, so it is only observable
* for non-credential-shaped keys such as a store or Solr URL with embedded credentials.
*/
@Configuration
public class ActuatorConfiguration {
// Matches "user:pass@", bare "user@", or ":pass@" userinfo between a scheme's "://" and the host.
private static final Pattern URL_USERINFO = Pattern.compile("://[^/@\\s:]*(?::[^/@\\s]*)?@");
// Recording runs on every request; allow operators to switch it off (and with it the endpoint)
// via management.httpexchanges.recording.enabled=false. On by default so the endpoint works out
// of the box; the same flag also gates Spring Boot's own HttpExchangesFilter.
@Bean
@ConditionalOnProperty(prefix = "management.httpexchanges.recording", name = "enabled", matchIfMissing = true)
public HttpExchangeRepository httpExchangeRepository() {
return new InMemoryHttpExchangeRepository();
}
@Bean
public SanitizingFunction environmentValueSanitizer() {
return SanitizingFunction.sanitizeValue().ifKeyMatches(ActuatorConfiguration::isSensitiveEnvironmentKey);
}
@Bean
public SanitizingFunction urlCredentialSanitizer() {
return data -> {
if (data.getValue() instanceof String value) {
String redacted = redactUrlUserInfo(value);
if (!redacted.equals(value)) {
return data.withValue(redacted);
}
}
return data;
};
}
/**
* Keys whose {@code env} values must stay masked even when values are shown to admins. Covers the
* conventional credential suffixes, any key containing {@code credentials}, and the one case the
* conventional shapes miss: {@code entrystore.auth.adminpw} (the admin-password override, whose key
* ends in {@code adminpw}, not {@code password}).
*
* <p>The JVM-argument carriers ({@code sun.java.command}, {@code JAVA_TOOL_OPTIONS}) are deliberately
* NOT masked: on an admin-only endpoint they are shareable operational/tuning data, and the only
* secret they could expose is one an operator chose to pass on the command line — masking them would
* hide the very tuning info {@code /env} exists to surface.
*/
static boolean isSensitiveEnvironmentKey(String key) {
String lowerKey = key.toLowerCase(Locale.ROOT);
return lowerKey.endsWith("password")
|| lowerKey.endsWith("secret")
|| lowerKey.endsWith("token")
|| lowerKey.endsWith("key")
|| lowerKey.endsWith("adminpw")
|| lowerKey.contains("credentials");
}
/**
* Replaces {@code user:pass@} or bare {@code user@} userinfo in any URL within {@code value} with
* {@code ******@}, leaving the rest of the URL visible. Connection URLs (an HTTP/SPARQL store or Solr URL) can
* embed backend credentials in the value even when the property key is not credential-shaped, so
* key-based masking alone would expose them under {@code /env}.
*/
static String redactUrlUserInfo(String value) {
return URL_USERINFO.matcher(value).replaceAll("://******@");
}
}