HttpQueryRedactor.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.util;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Replaces values of known sensitive parameters in an HTTP query string with
* {@value #REDACTED} so the string is safe to write to application logs and to
* embed in the Atom/RSS self-links produced by {@code SearchService}.
* <p>
* Parameter-name comparison is case-insensitive and percent-decoded: a request
* like {@code ?%63onfirm=…} matches the sensitive name {@code confirm} even
* though the raw substring before the first {@code =} reads {@code %63onfirm}.
* The on-the-wire name is preserved in the output so operators see exactly
* what the client sent, only the value is replaced. Values are never decoded
* — base64- or percent-encoded tokens never round-trip through a decoder
* during redaction.
* <p>
* Pair separators recognised: {@code &} (RFC 3986) and {@code ;} (legacy HTML
* 4 / RFC 1866). The output normalises to {@code &} regardless of the input
* separator, which is acceptable for the log/feed-link sinks. Malformed
* pairs are passed through unchanged — best-effort, never throws.
*/
public final class HttpQueryRedactor {
private static final String REDACTED = "***";
/**
* Names whose values are always replaced. Stored lowercase for the
* case-insensitive comparison via {@link String#toLowerCase(Locale)} with
* {@link Locale#ROOT}. Covers EntryStore's actual auth surface
* (password-reset and signup confirmation tokens, form-login password,
* SAML 2.0 {@code RelayState}, CAS {@code ticket}, the OIDC callback's
* {@code code}/{@code state}) plus defence-in-depth entries for
* credential-bearing names from related frameworks ({@code token},
* {@code secret}, {@code api_key}, {@code apikey}) that are not currently
* accepted as query parameters by any EntryStore endpoint but would slip
* into logs the moment one were added.
*/
private static final Set<String> SENSITIVE_NAMES = Set.of(
"confirm", // password-reset + signup confirmation tokens
"token", // generic
"password", // never accepted as a query param today
"auth_password", // form-login parameter name
"secret",
"code", // OIDC authorization code (/login/oauth2/code/{id} callback)
"state", // OIDC state correlation token, keys the auth-state cache
"relaystate", // SAML 2.0 relay-state correlation token
"ticket", // CAS service ticket
"api_key",
"apikey"
);
private HttpQueryRedactor() {
}
public static String redact(String rawQuery) {
if (rawQuery == null || rawQuery.isEmpty()) {
return rawQuery;
}
// split on either separator with limit=-1 so trailing/empty segments are preserved
// (round-trip "a=1&" through join('&') reproduces the trailing separator).
return Arrays.stream(rawQuery.split("[&;]", -1))
.map(HttpQueryRedactor::redactPair)
.collect(Collectors.joining("&"));
}
private static String redactPair(String pair) {
int eq = pair.indexOf('=');
if (eq < 0) {
// No '=' — not a name=value pair, pass through.
return pair;
}
String name = pair.substring(0, eq);
if (SENSITIVE_NAMES.contains(decodeName(name).toLowerCase(Locale.ROOT))) {
return name + "=" + REDACTED;
}
return pair;
}
private static String decodeName(String rawName) {
try {
return URLDecoder.decode(rawName, StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
// Malformed %XX escape — fall back to raw name so the redactor never throws.
return rawName;
}
}
}