feat:日志敏感信息过滤
This commit is contained in:
1 parent
2c1bf9b967
commit
26d968ec55
45 files changed
+2515
-147
No files matched your search
+15
@@ -18,6 +18,17 @@ public class FileAccessProperties implements InitializingBean {
|
||||
private List<String> allowedOrigins = new ArrayList<>();
|
||||
private Map<String, Client> clients = new LinkedHashMap<>();
|
||||
private LegacyDart legacyDart = new LegacyDart();
|
||||
private Upload upload = new Upload();
|
||||
|
||||
@Data
|
||||
public static class Upload {
|
||||
private long ttlSeconds = 300;
|
||||
private long maxFileBytes = 200L * 1024 * 1024;
|
||||
private Set<String> allowedExtensions = new HashSet<>(List.of("jpg", "jpeg", "png", "gif", "webp", "bmp", "ico", "svg",
|
||||
"pdf", "apk", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "csv", "zip",
|
||||
"mp4", "mov", "webm", "avi", "mp3", "wav", "m4a", "aac", "ogg", "amr", "flac"));
|
||||
private Set<String> internalExtensions = new HashSet<>(List.of("log", "gz"));
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Client {
|
||||
@@ -48,6 +59,10 @@ public class FileAccessProperties implements InitializingBean {
|
||||
|| futureSkewSeconds < 0 || futureSkewSeconds > 300) {
|
||||
throw new IllegalStateException("Invalid file-access policy");
|
||||
}
|
||||
if (upload == null || upload.ttlSeconds < 60 || upload.ttlSeconds > 1800 || upload.maxFileBytes <= 0
|
||||
|| upload.maxFileBytes > 1024L * 1024 * 1024 || upload.allowedExtensions == null || upload.internalExtensions == null) {
|
||||
throw new IllegalStateException("Invalid file-access upload policy");
|
||||
}
|
||||
if (enforcing() || !publicBaseUrl.isBlank()) {
|
||||
try {
|
||||
java.net.URI uri = java.net.URI.create(publicBaseUrl);
|
||||
|
||||
+5
-2
@@ -17,13 +17,16 @@ public final class FileAccessSignature {
|
||||
}
|
||||
|
||||
public static String sign(String fileName, String appId, String kid, long timestamp, String base64Key) {
|
||||
return hmac(canonical(fileName, appId, kid, timestamp), base64Key);
|
||||
}
|
||||
|
||||
public static String hmac(String canonical, String base64Key) {
|
||||
try {
|
||||
byte[] key = Base64.getDecoder().decode(base64Key);
|
||||
if (key.length < 32) throw new IllegalArgumentException("File access key must contain at least 32 bytes");
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||
return HexFormat.of().formatHex(mac.doFinal(canonical(fileName, appId, kid, timestamp)
|
||||
.getBytes(StandardCharsets.UTF_8)));
|
||||
return HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
|
||||
+16
@@ -31,6 +31,22 @@ public class FileAccessSigner {
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, String> uploadHeaders(String target) {
|
||||
FileAccessProperties.Client client = properties.getClients().get("service");
|
||||
if (client == null) throw new IllegalStateException("Missing file-access service signer configuration");
|
||||
String kid = client.getActiveKid(), key = properties.key("service", kid);
|
||||
if (key == null || key.isBlank()) throw new IllegalStateException("Missing file-access service signer key");
|
||||
long ts = clock.instant().getEpochSecond();
|
||||
String nonce = UUID.randomUUID().toString().replace("-", "");
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put(FileUploadSignature.APP, "service");
|
||||
headers.put(FileUploadSignature.KID, kid);
|
||||
headers.put(FileUploadSignature.TIMESTAMP, Long.toString(ts));
|
||||
headers.put(FileUploadSignature.NONCE, nonce);
|
||||
headers.put(FileUploadSignature.SIGNATURE, FileUploadSignature.sign(target, "service", kid, ts, nonce, key));
|
||||
return headers;
|
||||
}
|
||||
|
||||
public String signUrl(String source, String appId) {
|
||||
if (source == null || source.isBlank()) return source;
|
||||
String value = source.trim();
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.bsd.pjcloud.common.core.file;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Upload authorization is purpose-separated from read signatures and is never put in URLs. */
|
||||
public final class FileUploadSignature {
|
||||
public static final String APP = "X-File-App-Id";
|
||||
public static final String KID = "X-File-Key-Id";
|
||||
public static final String TIMESTAMP = "X-File-Timestamp";
|
||||
public static final String NONCE = "X-File-Nonce";
|
||||
public static final String SIGNATURE = "X-File-Signature";
|
||||
public static final Set<String> TARGETS = Set.of("upload", "inner-upload", "qn-upload");
|
||||
|
||||
private FileUploadSignature() { }
|
||||
|
||||
public static String canonical(String target, String appId, String kid, long timestamp, String nonce) {
|
||||
if (!TARGETS.contains(target)) throw new IllegalArgumentException("Invalid file upload target");
|
||||
return String.join("\n", "v1", "oss-file-upload", "POST", target, appId, kid, Long.toString(timestamp), nonce);
|
||||
}
|
||||
|
||||
public static String sign(String target, String appId, String kid, long timestamp, String nonce, String key) {
|
||||
return FileAccessSignature.hmac(canonical(target, appId, kid, timestamp, nonce), key);
|
||||
}
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
package com.bsd.pjcloud.common.core.util;
|
||||
|
||||
import com.bsd.pjcloud.common.core.jackson.PjcloudJavaTimeModule;
|
||||
import com.fasterxml.jackson.core.JsonFactory;
|
||||
import com.fasterxml.jackson.core.StreamReadConstraints;
|
||||
import com.fasterxml.jackson.core.StreamWriteConstraints;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import jakarta.servlet.ServletRequest;
|
||||
import jakarta.servlet.ServletResponse;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.*;
|
||||
import java.lang.reflect.Array;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** 仅用于审计日志的副本过滤,不修改业务对象、业务 ObjectMapper 或系统配置。 */
|
||||
public final class AuditLogSanitizer {
|
||||
public static final String HIDDEN = "参数已隐藏";
|
||||
private static final int MAX_DEPTH = 32;
|
||||
private static final int MAX_NODES = 10000;
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper(JsonFactory.builder()
|
||||
.streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(MAX_DEPTH).build())
|
||||
.streamWriteConstraints(StreamWriteConstraints.builder().maxNestingDepth(MAX_DEPTH).build()).build())
|
||||
.registerModule(new PjcloudJavaTimeModule()).enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
|
||||
private static final Set<String> CREDENTIALS = Set.of(
|
||||
"pass", "password", "passwd", "pwd", "credential", "credentials", "salt",
|
||||
"captcha", "captchacode", "captchaverification", "captchaid", "verifycode", "verificationcode",
|
||||
"validatecode", "smscode", "imagecode", "otp", "otpcode", "randomstr", "authcode",
|
||||
"token", "tokenid", "authtoken", "accesstoken", "refreshtoken", "idtoken", "resettoken", "authorization", "authorizationcode",
|
||||
"clientsecret", "appsecret", "accesssecret", "accesskeysecret", "secret", "secretkey",
|
||||
"privatekey", "apikey", "accesskey", "sign", "signature", "cookie", "setcookie", "sessionid", "jsessionid",
|
||||
"密码", "口令", "验证码", "短信验证码", "图形验证码", "令牌", "访问令牌", "刷新令牌", "密钥", "私钥");
|
||||
private static final Set<String> AUTH_CONTEXT_FIELDS = Set.of("randomstr", "captcha", "captchaid", "captchaverification",
|
||||
"captchacode", "verifycode", "verificationcode", "validatecode", "smscode", "otp", "otpcode", "granttype");
|
||||
private static final Pattern PASSWORD = Pattern.compile(
|
||||
"(?:old|new|current|confirm|confirmation|repeat|re|login|pay|trade|transaction|user|admin|initial|original|temporary|encoded|encrypted|hashed)*(?:password|passwd|pwd|pass)(?:s|confirm|confirmation|repeat|again|hash|digest)?\\d*");
|
||||
private static final Pattern CREDENTIAL_FAMILY = Pattern.compile(
|
||||
"[\\p{L}\\p{N}]*(?:(?:password|passwd|pwd)(?:s|confirm|confirmation|repeat|again|hash|digest)?"
|
||||
+ "|secret|secretkey|privatekey|token|tokenid|tokenkey|apikey|accesskey|(?:captcha|verify|verification|validate|sms|otp|auth)code)\\d*");
|
||||
private static final Pattern AUTH_PATH = Pattern.compile(
|
||||
"(?i)(?:^|/)(?:oauth2?|login|applogin|captcha|verifycode|smscode)(?:/|$)"
|
||||
+ "|/register/(?:password|default-user)(?:/|$)|/user/(?:personal/password|fix-phone-as-password|check)(?:/|$)"
|
||||
+ "|/code/(?:image|check|verify)(?:/|$)");
|
||||
private static final Pattern LABEL = Pattern.compile("(?<![\\p{L}\\p{N}_.\\[\\]-])[\\\"']?([\\p{L}\\p{N}_.\\[\\]-]{1,256})[\\\"']?\\s*[:=]");
|
||||
private static final Pattern AUTH_VALUE = Pattern.compile("(?i)\\b(?:Bearer|Basic)\\s+\\S+|-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----");
|
||||
private static final Pattern FORM_KEY = Pattern.compile("[\\p{L}\\p{N}_%.\\[\\]-]+");
|
||||
private final Set<String> excluded;
|
||||
|
||||
public AuditLogSanitizer(Collection<String> additionalFields) {
|
||||
Set<String> fields = new HashSet<>(CREDENTIALS);
|
||||
if (additionalFields != null) additionalFields.stream().filter(Objects::nonNull).map(AuditLogSanitizer::normalize)
|
||||
.forEach(fields::add);
|
||||
excluded = Set.copyOf(fields);
|
||||
}
|
||||
|
||||
public boolean isAuthenticationContext(String uri) {
|
||||
return uri != null && AUTH_PATH.matcher(uri.split("[?#]", 2)[0]).find();
|
||||
}
|
||||
|
||||
public boolean isSensitiveField(String name, boolean authentication) {
|
||||
if (name == null) return false;
|
||||
if (name.length() > 256) throw new IllegalArgumentException("Unsupported audit field length");
|
||||
String decoded = decode(name);
|
||||
for (String part : decoded.split("[.\\[\\]/]+")) {
|
||||
String key = normalize(part);
|
||||
if (excluded.contains(key) || PASSWORD.matcher(key).matches() || CREDENTIAL_FAMILY.matcher(key).matches()
|
||||
|| (authentication && "code".equals(key))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 历史 params 只接受可识别的 JSON 或表单,匿名值与损坏报文不回退为原文。 */
|
||||
public String sanitizeParameters(String value, String uri) {
|
||||
if (value == null || value.isBlank() || HIDDEN.equals(value)) return value;
|
||||
try {
|
||||
String input = structuredInput(value);
|
||||
if (jsonLike(input)) return MAPPER.writeValueAsString(clean(MAPPER.readTree(input), isAuthenticationContext(uri), false, 0, new Budget()));
|
||||
if (formLike(input)) return form(input, isAuthenticationContext(uri), 0, new Budget());
|
||||
} catch (Exception ignored) {
|
||||
// 异常原文可能包含请求值,不能输出异常消息或回退到原始内容。
|
||||
}
|
||||
return HIDDEN;
|
||||
}
|
||||
|
||||
/** 方法参数和 DTO 立即转成过滤后的字符串快照,异步事件不携带原始业务对象。 */
|
||||
public String sanitizePayload(Object value, String uri) {
|
||||
if (value instanceof CharSequence sequence) return sanitizeParameters(sequence.toString(), uri);
|
||||
try {
|
||||
return MAPPER.writeValueAsString(clean(value, isAuthenticationContext(uri), false, 0, new Budget()));
|
||||
} catch (Exception ignored) {
|
||||
return HIDDEN;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留 URL 表单及重复参数的语义,操作副本而非 servlet 参数映射。 */
|
||||
public String sanitizeRequestParameters(Map<String, String[]> parameters, String uri) {
|
||||
try {
|
||||
List<String> pairs = new ArrayList<>();
|
||||
for (var entry : parameters.entrySet()) {
|
||||
if (entry.getValue() == null) continue;
|
||||
for (String value : entry.getValue()) pairs.add(encode(entry.getKey()) + "=" + encode(value == null ? "" : value));
|
||||
}
|
||||
return form(String.join("&", pairs), isAuthenticationContext(uri), 0, new Budget());
|
||||
} catch (Exception ignored) {
|
||||
return HIDDEN;
|
||||
}
|
||||
}
|
||||
|
||||
/** 普通错误文本保留;含可识别凭据或损坏结构化内容时隐藏,不尝试猜测秘密值边界。 */
|
||||
public String sanitizeText(String value, String uri) {
|
||||
if (value == null || value.isBlank() || HIDDEN.equals(value)) return value;
|
||||
try {
|
||||
String input = structuredInput(value);
|
||||
if (jsonLike(input) || formLike(input)) return sanitizeParameters(input, uri);
|
||||
if (unsafeText(input, isAuthenticationContext(uri))) return HIDDEN;
|
||||
return value;
|
||||
} catch (Exception ignored) {
|
||||
return HIDDEN;
|
||||
}
|
||||
}
|
||||
|
||||
public String sanitizeUri(String uri) {
|
||||
return sanitizeUri(uri, Map.of());
|
||||
}
|
||||
|
||||
/** 新请求利用路径参数名称过滤;历史记录覆盖已知的认证路径。 */
|
||||
public String sanitizeUri(String uri, Map<String, String> pathVariables) {
|
||||
if (uri == null || uri.isBlank() || HIDDEN.equals(uri)) return uri;
|
||||
try {
|
||||
int hash = uri.indexOf('#');
|
||||
String fragment = hash < 0 ? "" : uri.substring(hash + 1);
|
||||
String main = hash < 0 ? uri : uri.substring(0, hash);
|
||||
int question = main.indexOf('?');
|
||||
String path = question < 0 ? main : main.substring(0, question);
|
||||
boolean authentication = isAuthenticationContext(path);
|
||||
String[] segments = path.split("/", -1);
|
||||
for (int index = 0; index < segments.length; index++) {
|
||||
String segment = decode(segments[index]);
|
||||
for (var variable : pathVariables.entrySet()) {
|
||||
if (isSensitiveField(variable.getKey(), authentication) && Objects.equals(segment, variable.getValue())) segments[index] = HIDDEN;
|
||||
}
|
||||
}
|
||||
path = String.join("/", segments).replaceAll("(?i)(/appLogin/(?:zzdsso|getAuthToken)/)[^/?#]+", "$1" + HIDDEN)
|
||||
.replaceAll("(?i)(https?://)[^/@\\s]+@", "$1" + HIDDEN + "@");
|
||||
if (unsafeText(path, authentication)) return HIDDEN;
|
||||
if (question >= 0) {
|
||||
String query = safeUriForm(main.substring(question + 1), authentication);
|
||||
if (!query.isBlank()) path += "?" + query;
|
||||
}
|
||||
if (!fragment.isBlank()) {
|
||||
String safeFragment = formLike(fragment) ? safeUriForm(fragment, authentication)
|
||||
: authentication || unsafeText(fragment, false) ? HIDDEN : fragment;
|
||||
path += "#" + safeFragment;
|
||||
}
|
||||
return path;
|
||||
} catch (Exception ignored) {
|
||||
return HIDDEN;
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode clean(Object value, boolean authentication, boolean named, int depth, Budget budget) throws Exception {
|
||||
budget.visit(depth);
|
||||
if (value == null || value instanceof JsonNode node && node.isNull()) return MAPPER.nullNode();
|
||||
if (isUnloggable(value)) throw new UnsafePayload();
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
ObjectNode result = MAPPER.createObjectNode();
|
||||
boolean auth = authentication || authenticationMap(map);
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String key)) throw new UnsafePayload();
|
||||
if (!isSensitiveField(key, auth) && !isUnloggable(entry.getValue())) result.set(key, clean(entry.getValue(), auth, true, depth + 1, budget));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (value instanceof JsonNode node && node.isObject()) {
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
node.fields().forEachRemaining(entry -> entries.put(entry.getKey(), entry.getValue()));
|
||||
return clean(entries, authentication, named, depth + 1, budget);
|
||||
}
|
||||
if (value instanceof JsonNode node && node.isArray()
|
||||
|| !(value instanceof JsonNode) && value instanceof Iterable<?> || value.getClass().isArray()) {
|
||||
ArrayNode result = MAPPER.createArrayNode();
|
||||
Iterable<?> items = value instanceof Iterable<?> iterable ? iterable : arrayItems(value);
|
||||
for (Object item : items) if (!isUnloggable(item)) result.add(clean(item, authentication, named, depth + 1, budget));
|
||||
return result;
|
||||
}
|
||||
if (value instanceof CharSequence || value instanceof JsonNode node && node.isTextual()) {
|
||||
String text = value instanceof JsonNode node ? node.textValue() : value.toString();
|
||||
if (!named) throw new UnsafePayload();
|
||||
String input = structuredInput(text);
|
||||
if (jsonLike(input)) return MAPPER.getNodeFactory().textNode(MAPPER.writeValueAsString(clean(MAPPER.readTree(input), authentication, false, depth + 1, budget)));
|
||||
if (formLike(input)) return MAPPER.getNodeFactory().textNode(form(input, authentication, depth + 1, budget));
|
||||
return MAPPER.getNodeFactory().textNode(unsafeText(input, authentication) ? HIDDEN : text);
|
||||
}
|
||||
if (value instanceof JsonNode node && node.isValueNode() || value instanceof Number || value instanceof Boolean || value instanceof Character || value instanceof Enum<?>) {
|
||||
if (!named) throw new UnsafePayload();
|
||||
return value instanceof JsonNode node ? node.deepCopy() : MAPPER.valueToTree(value);
|
||||
}
|
||||
return clean(MAPPER.valueToTree(value), authentication, named, depth + 1, budget);
|
||||
}
|
||||
|
||||
public static boolean isUnloggable(Object value) {
|
||||
return value instanceof ServletRequest || value instanceof ServletResponse || value instanceof BindingResult
|
||||
|| value instanceof MultipartFile || value instanceof Resource || value instanceof InputStream
|
||||
|| value instanceof OutputStream || value instanceof Reader || value instanceof Writer
|
||||
|| value instanceof File || value instanceof Path;
|
||||
}
|
||||
|
||||
private boolean authenticationMap(Map<?, ?> map) {
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String field)) continue;
|
||||
String key = normalize(decode(field));
|
||||
if (AUTH_CONTEXT_FIELDS.contains(key)) return true;
|
||||
Object value = entry.getValue();
|
||||
String text = value instanceof JsonNode node && node.isTextual() ? node.textValue()
|
||||
: value instanceof CharSequence sequence ? sequence.toString()
|
||||
: value instanceof Enum<?> enumeration ? enumeration.name() : null;
|
||||
if ("bizcode".equals(key) && "SMS_NORMAL_CODE".equalsIgnoreCase(text)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String form(String value, boolean authentication, int depth, Budget budget) throws Exception {
|
||||
budget.visit(depth);
|
||||
if (value.isBlank()) return "";
|
||||
List<String[]> pairs = new ArrayList<>();
|
||||
Map<String, String> context = new LinkedHashMap<>();
|
||||
for (String part : value.split("&", -1)) {
|
||||
if (part.isBlank()) continue;
|
||||
int equals = part.indexOf('=');
|
||||
String key = decode(equals < 0 ? part : part.substring(0, equals));
|
||||
if (key.isBlank() || !FORM_KEY.matcher(key).matches()) throw new UnsafePayload();
|
||||
String text = equals < 0 ? "" : decode(part.substring(equals + 1));
|
||||
pairs.add(new String[]{key, text});
|
||||
context.put(key, text);
|
||||
}
|
||||
boolean auth = authentication || authenticationMap(context);
|
||||
List<String> safe = new ArrayList<>();
|
||||
for (String[] pair : pairs) if (!isSensitiveField(pair[0], auth)) {
|
||||
JsonNode cleaned = clean(pair[1], auth, true, depth + 1, budget);
|
||||
safe.add(encode(pair[0]) + "=" + encode(cleaned.textValue()));
|
||||
}
|
||||
return String.join("&", safe);
|
||||
}
|
||||
|
||||
private boolean unsafeText(String value, boolean authentication) {
|
||||
if (AUTH_VALUE.matcher(value).find()) return true;
|
||||
var matcher = LABEL.matcher(value);
|
||||
while (matcher.find()) if (isSensitiveField(matcher.group(1), authentication)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private String safeUriForm(String value, boolean authentication) {
|
||||
try {
|
||||
return form(value, authentication, 0, new Budget());
|
||||
} catch (Exception ignored) {
|
||||
return HIDDEN;
|
||||
}
|
||||
}
|
||||
|
||||
private static String structuredInput(String value) {
|
||||
String input = value.trim();
|
||||
if (!jsonLike(input) && !formLike(input) && input.matches("(?s).*%[0-9a-fA-F]{2}.*")) input = decode(input);
|
||||
return input;
|
||||
}
|
||||
|
||||
private static boolean jsonLike(String value) {
|
||||
return value.startsWith("{") || value.startsWith("[") || value.startsWith("\"");
|
||||
}
|
||||
|
||||
private static boolean formLike(String value) {
|
||||
int equals = value.indexOf('=');
|
||||
return equals > 0 && FORM_KEY.matcher(value.substring(0, equals)).matches();
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]", "");
|
||||
}
|
||||
|
||||
private static String decode(String value) {
|
||||
String decoded = value;
|
||||
for (int attempt = 0; attempt < 3; attempt++) {
|
||||
String next = URLDecoder.decode(decoded, StandardCharsets.UTF_8);
|
||||
if (next.equals(decoded) || !next.matches("(?s).*%[0-9a-fA-F]{2}.*")) return next;
|
||||
decoded = next;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported parameter encoding");
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static Iterable<?> arrayItems(Object array) {
|
||||
List<Object> items = new ArrayList<>();
|
||||
for (int index = 0; index < Array.getLength(array); index++) items.add(Array.get(array, index));
|
||||
return items;
|
||||
}
|
||||
|
||||
private static final class Budget {
|
||||
private int nodes;
|
||||
private void visit(int depth) throws UnsafePayload {
|
||||
if (depth > MAX_DEPTH || ++nodes > MAX_NODES) throw new UnsafePayload();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class UnsafePayload extends Exception {}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package com.bsd.pjcloud.common.core.util;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AuditLogSanitizerTest {
|
||||
private final AuditLogSanitizer sanitizer = new AuditLogSanitizer(List.of());
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"password", "old_password", "New-Password", "newpassword1", "newpassword2",
|
||||
"ConfirmNewPassword", "password_confirm", "PWD", "passwd", "pass", "captchaVerification", "sms_code",
|
||||
"验证码", "Authorization", "client_secret", "appSecret", "private-key", "access_token", "refresh_token",
|
||||
"accessKeySecret", "COOKIE", "data.password", "users[0].password", "p%61ssword", "passwordHash", "encryptedPassword",
|
||||
"rsaPrivateKey", "selfAppSecret", "selfTokenKey", "selfTokenSecret", "smtpPassword", "X-Auth-Token"})
|
||||
void dropsCredentialKeysButPreservesBusinessValues(String field) throws Exception {
|
||||
var original = new LinkedHashMap<String, Object>();
|
||||
original.put(field, "credential-value");
|
||||
original.put("username", "operator");
|
||||
original.put("roleCode", "ADMIN");
|
||||
JsonNode result = json.readTree(sanitizer.sanitizePayload(original, "/admin/user"));
|
||||
assertEquals(json.readTree("{\"username\":\"operator\",\"roleCode\":\"ADMIN\"}"), result);
|
||||
assertEquals("credential-value", original.get(field));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recursivelyFiltersMapsBeansArraysAndEmbeddedJsonWithoutChangingInputs() throws Exception {
|
||||
Map<String, Object> child = new LinkedHashMap<>(Map.of("password", "child-secret", "id", 7));
|
||||
Map<String, Object> original = new LinkedHashMap<>();
|
||||
original.put("items", List.of(child));
|
||||
original.put("ids", new long[]{1, 2});
|
||||
original.put("payload", "{\"newpassword1\":\"embedded-secret\",\"code\":\"BUS-42\",\"rows\":[{\"token\":\"embedded-token\",\"id\":9}]}");
|
||||
original.put("bean", new LoginBean());
|
||||
original.put("created", LocalDateTime.of(2026, 10, 29, 12, 30));
|
||||
original.put("stream", new ByteArrayInputStream(new byte[]{1, 2}));
|
||||
|
||||
String output = sanitizer.sanitizePayload(original, "/orders/update");
|
||||
JsonNode result = json.readTree(output);
|
||||
assertEquals(7, result.at("/items/0/id").intValue());
|
||||
assertFalse(result.at("/items/0").has("password"));
|
||||
assertEquals(json.readTree("[1,2]"), result.get("ids"));
|
||||
assertEquals("2026-10-29 12:30:00", result.get("created").textValue());
|
||||
assertEquals("BUS-42", json.readTree(result.get("payload").textValue()).get("code").textValue());
|
||||
assertEquals("operator", result.at("/bean/username").textValue());
|
||||
assertFalse(result.has("stream"));
|
||||
for (String secret : List.of("child-secret", "embedded-secret", "embedded-token", "bean-secret")) assertFalse(output.contains(secret));
|
||||
assertEquals("child-secret", child.get("password"));
|
||||
assertTrue(original.get("payload").toString().contains("embedded-secret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void formFilteringHandlesEncodedKeysRepeatedValuesAndNestedPayloads() {
|
||||
String original = "username=operator&password=one&code=BUS-42&roleCode=ADMIN&id=1&id=2&p%61ssword=two"
|
||||
+ "&payload=%7B%22new_password%22%3A%22three%22%2C%22id%22%3A9%7D";
|
||||
assertEquals("username=operator&code=BUS-42&roleCode=ADMIN&id=1&id=2&payload=%7B%22id%22%3A9%7D",
|
||||
sanitizer.sanitizeParameters(original, "/orders/update"));
|
||||
assertTrue(original.contains("password=one"));
|
||||
String[] ids = {"1", "2"};
|
||||
Map<String, String[]> request = new LinkedHashMap<>();
|
||||
request.put("password", new String[]{"raw-secret"});
|
||||
request.put("ids", ids);
|
||||
request.put("note", new String[]{"a+b 50% 中文"});
|
||||
assertEquals("ids=1&ids=2¬e=a%2Bb+50%25+%E4%B8%AD%E6%96%87", sanitizer.sanitizeRequestParameters(request, "/orders"));
|
||||
assertSame(ids, request.get("ids"));
|
||||
assertEquals("raw-secret", request.get("password")[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void genericCodeIsRemovedOnlyInAuthenticationAndVerificationContexts() throws Exception {
|
||||
String parameters = "code=123456&bizCode=BUS-42&roleCode=ADMIN&passwordExpireFlag=0";
|
||||
assertEquals(parameters, sanitizer.sanitizeParameters(parameters, "/role/update"));
|
||||
for (String uri : List.of("/oauth2/token", "/auth/oauth2/token", "/api/admin/oauth2/token", "/login", "/register/password")) {
|
||||
assertEquals("bizCode=BUS-42&roleCode=ADMIN&passwordExpireFlag=0", sanitizer.sanitizeParameters(parameters, uri));
|
||||
}
|
||||
JsonNode sms = json.readTree(sanitizer.sanitizeParameters(
|
||||
"{\"bizCode\":\"SMS_NORMAL_CODE\",\"params\":{\"code\":\"123456\",\"orderId\":42}}", "/message/send/sms"));
|
||||
assertFalse(sms.get("params").has("code"));
|
||||
assertEquals(42, sms.at("/params/orderId").intValue());
|
||||
assertEquals("SMS_NORMAL_CODE", sms.get("bizCode").textValue());
|
||||
assertEquals("username=operator", sanitizer.sanitizeParameters("code=123456&randomStr=proof&username=operator", "/custom"));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"opaque-secret", "\"opaque-secret\"", "[\"opaque-secret\"]", "[1,2]",
|
||||
"{\"password\":\"truncated-secret", "{\"id\":1} {\"password\":\"tail-secret\"}",
|
||||
"password=%ZZ", "p%ZZassword=broken", "Bearer opaque-token"})
|
||||
void damagedOrAnonymousParametersFailClosed(String original) {
|
||||
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeParameters(original, "/orders"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyValuesAreStableAndSanitizationIsIdempotent() {
|
||||
assertNull(sanitizer.sanitizeParameters(null, "/orders"));
|
||||
assertEquals("", sanitizer.sanitizeParameters("", "/orders"));
|
||||
String result = sanitizer.sanitizeParameters("[{\"password\":\"secret\",\"id\":1},null]", "/orders");
|
||||
assertEquals("[{\"id\":1},null]", result);
|
||||
assertEquals(result, sanitizer.sanitizeParameters(result, "/orders"));
|
||||
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeParameters(AuditLogSanitizer.HIDDEN, "/orders"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void urlsAndExceptionsDoNotEchoRecognizableCredentials() {
|
||||
assertEquals("https://example.test/auth/oauth2/token?username=operator&grant_type=password",
|
||||
sanitizer.sanitizeUri("https://example.test/auth/oauth2/token?username=operator&code=123456&password=secret&grant_type=password"));
|
||||
assertEquals("/orders?code=BUS-42", sanitizer.sanitizeUri("/orders?code=BUS-42&client_secret=secret"));
|
||||
assertEquals("/orders?" + AuditLogSanitizer.HIDDEN, sanitizer.sanitizeUri("/orders?password=%ZZ"));
|
||||
assertEquals("/custom/" + AuditLogSanitizer.HIDDEN, sanitizer.sanitizeUri("/custom/path-secret", Map.of("token", "path-secret")));
|
||||
assertEquals("/appLogin/getAuthToken/" + AuditLogSanitizer.HIDDEN, sanitizer.sanitizeUri("/appLogin/getAuthToken/path-secret"));
|
||||
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeText("failed password=plain-secret", "/orders"));
|
||||
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeText("upstream returned Bearer abc123", "/orders"));
|
||||
assertEquals("java.lang.IllegalStateException: 用户不存在\n at example.User.update(User.java:42)",
|
||||
sanitizer.sanitizeText("java.lang.IllegalStateException: 用户不存在\n at example.User.update(User.java:42)", "/orders"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedObjectsCyclesAndSerializationFailuresDoNotEscapeOrFallBackToRawValues() {
|
||||
Map<String, Object> cycle = new HashMap<>();
|
||||
cycle.put("next", cycle);
|
||||
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizePayload(cycle, "/orders"));
|
||||
Map<String, Object> indirect = new HashMap<>();
|
||||
indirect.put("parent", cycle);
|
||||
cycle.put("bizCode", indirect);
|
||||
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizePayload(cycle, "/orders"));
|
||||
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizePayload(new ExplodingBean(), "/orders"));
|
||||
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizeParameters("{\"nested\":\"{broken-password=secret\"}", "/orders"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doubleEncodedNestedJsonIsDecodedAndFilteredWithoutChangingBusinessJsonStrings() throws Exception {
|
||||
String input = "{\"payload\":\"%257B%2522password%2522%253A%2522encoded-secret%2522%252C%2522id%2522%253A42%257D\",\"code\":\"BUS-42\"}";
|
||||
JsonNode result = json.readTree(sanitizer.sanitizeParameters(input, "/orders"));
|
||||
assertEquals(json.readTree("{\"id\":42}"), json.readTree(result.get("payload").textValue()));
|
||||
assertEquals("BUS-42", result.get("code").textValue());
|
||||
JsonNode form = json.readTree(sanitizer.sanitizeParameters("{\"payload\":\"password%253Dform-secret%2526id%253D42\"}", "/orders"));
|
||||
assertEquals("id=42", form.get("payload").textValue());
|
||||
assertEquals("/orders?note=a%26id%3D3", sanitizer.sanitizeUri("/orders?note=a%26id%3D3&token=secret"));
|
||||
assertEquals("", sanitizer.sanitizeText("password%3Dencoded-error-secret", "/orders"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void longBusinessValuesArePreservedWithoutUnboundedCredentialLabelMatching() {
|
||||
String description = "a".repeat(100000);
|
||||
assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
|
||||
String result = sanitizer.sanitizePayload(Map.of("description", description), "/orders");
|
||||
assertTrue(result.contains(description));
|
||||
assertEquals(AuditLogSanitizer.HIDDEN, sanitizer.sanitizePayload(Map.of("new".repeat(200) + "password", "secret"), "/orders"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void configuredAdditionalFieldsCannotDisableMandatoryCredentialFiltering() throws Exception {
|
||||
var customized = new AuditLogSanitizer(List.of("phone", "custom-field"));
|
||||
JsonNode result = json.readTree(customized.sanitizeParameters(
|
||||
"{\"phone\":\"123\",\"customField\":\"private\",\"PASSWORD\":\"secret\",\"passwordExpireFlag\":0,\"publicKey\":\"public\",\"clientId\":\"web\"}", "/orders"));
|
||||
assertEquals(json.readTree("{\"passwordExpireFlag\":0,\"publicKey\":\"public\",\"clientId\":\"web\"}"), result);
|
||||
}
|
||||
|
||||
public static class LoginBean {
|
||||
public String getUsername() { return "operator"; }
|
||||
public String getNewpassword1() { return "bean-secret"; }
|
||||
}
|
||||
|
||||
public static class ExplodingBean {
|
||||
public String getValue() { throw new IllegalStateException("getter-error-containing-private-secret"); }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user