fix(CLI-40): scrub the credential from the redacted error's structured reply, route MXACCESS_FAILURE to MxAccess (Rust), fix Go Subscribe terminal-error drop
Code-review follow-up on the CLI-40/41/44 branch. ISSUE 1 (all five, critical): the message-only scrub still leaked the server-echoed credential through the redacted error's structured reply accessor (.NET Reply/Statuses, Java reply()/protocolStatus(), Go MxAccessError.Reply via errors.As, Rust reply()/into_reply(), Python raw_reply). The redacted error now carries a scrubbed clone of the reply (protocol_status.message, diagnostic_message, statuses[].diagnostic_text), with per-language tests asserting the reply accessor no longer contains the credential. ISSUE 2 (Rust, critical): ensure_command_success routed MXACCESS_FAILURE to Error::Command (unlike the other four clients), bypassing attach_secrets and leaking via derived Debug/Display. MXACCESS_FAILURE now routes to Error::MxAccess, fixing the cross-client inconsistency. ISSUE 3 (Go, important): the CLI-44 terminal send was unconditionally non-blocking, dropping a genuine terminal error under a full buffer on the never-drop SubscribeEvents path. It is now reserved-slot-non-blocking only for the cancel-on-overflow path and blocking for the never-drop path. New shared fixture authenticate-user.echoed-credential-mxaccess-failure.reply.json wired into all five suites. Minors: whitespace-secret guard on .NET/Java redact helpers; Java preserves exception subtype on redaction; redaction-helper unit tests (Go/Java/.NET). Docs (ClientBehaviorFixtures.md, ClientLibrariesDesign.md) updated to make the structured-field claim true.
This commit is contained in:
+7
-5
@@ -63,9 +63,9 @@ public final class MxGatewaySecrets {
|
||||
* into a free-form failure message.
|
||||
*
|
||||
* @param message the message to scrub, may be {@code null}
|
||||
* @param secrets the exact secret substrings to remove; {@code null}/empty
|
||||
* entries and a {@code null} array are ignored
|
||||
* @return {@code message} unchanged when it is {@code null} or no non-empty
|
||||
* @param secrets the exact secret substrings to remove; {@code null}, empty,
|
||||
* and blank (whitespace-only) entries and a {@code null} array are ignored
|
||||
* @return {@code message} unchanged when it is {@code null} or no non-blank
|
||||
* secret is supplied, otherwise the message with every secret occurrence
|
||||
* replaced by {@code "<redacted>"}
|
||||
*/
|
||||
@@ -76,9 +76,11 @@ public final class MxGatewaySecrets {
|
||||
|
||||
String result = message;
|
||||
for (String secret : secrets) {
|
||||
if (secret != null && !secret.isEmpty()) {
|
||||
result = result.replace(secret, "<redacted>");
|
||||
if (secret == null || secret.isBlank()) {
|
||||
// A blank "secret" would over-redact real whitespace; skip it.
|
||||
continue;
|
||||
}
|
||||
result = result.replace(secret, "<redacted>");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+125
-11
@@ -31,6 +31,7 @@ import mxaccess_gateway.v1.MxaccessGateway.MxSparseElement;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ReadBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemBulkCommand;
|
||||
@@ -1055,29 +1056,142 @@ public final class MxGatewaySession implements AutoCloseable {
|
||||
* it into a diagnostic; this guarantees it never survives in the exception
|
||||
* text a caller might log.
|
||||
*
|
||||
* <p>On failure the original exception's message is scrubbed with
|
||||
* {@link MxGatewaySecrets#redactExact}. If nothing changed (the common case
|
||||
* where the message never carried the secret), the original exception is
|
||||
* <p>On failure both the exception's message <em>and</em> its structured
|
||||
* context (the {@link ProtocolStatus} and {@link MxCommandReply} a caller can
|
||||
* inspect and log) are scrubbed with {@link MxGatewaySecrets#redactExact}: the
|
||||
* gateway echoes the credential into {@code protocolStatus.message},
|
||||
* {@code reply.diagnosticMessage}, and each {@code statuses[i].diagnosticText}.
|
||||
* If nothing carried the secret (the common case) the original exception is
|
||||
* rethrown untouched. Otherwise it is re-thrown as the same concrete type
|
||||
* carrying the redacted message; the secret-bearing original is not chained
|
||||
* as a cause, so it cannot leak through a printed stack trace.
|
||||
* carrying the redacted message and scrubbed context; the secret-bearing
|
||||
* original is not chained as a cause, so it cannot leak through a printed
|
||||
* stack trace.
|
||||
*/
|
||||
private MxCommandReply invokeCommandRedacted(MxCommand command, String... secrets) {
|
||||
try {
|
||||
return invokeCommand(command);
|
||||
} catch (MxGatewayException ex) {
|
||||
String original = ex.getMessage();
|
||||
String redacted = MxGatewaySecrets.redactExact(original, secrets);
|
||||
if (redacted == null || redacted.equals(original)) {
|
||||
String redactedMessage = MxGatewaySecrets.redactExact(original, secrets);
|
||||
boolean messageChanged = redactedMessage != null && !redactedMessage.equals(original);
|
||||
|
||||
ProtocolStatus status = protocolStatusOf(ex);
|
||||
ProtocolStatus scrubbedStatus = scrubProtocolStatus(status, secrets);
|
||||
boolean statusChanged = status != null && !status.equals(scrubbedStatus);
|
||||
|
||||
MxCommandReply reply = replyOf(ex);
|
||||
MxCommandReply scrubbedReply = scrubReply(reply, secrets);
|
||||
boolean replyChanged = reply != null && !reply.equals(scrubbedReply);
|
||||
|
||||
if (!messageChanged && !statusChanged && !replyChanged) {
|
||||
throw ex;
|
||||
}
|
||||
if (ex instanceof MxAccessException mx) {
|
||||
throw new MxAccessException(redacted, mx.protocolStatus(), mx.reply(), null);
|
||||
}
|
||||
throw new MxGatewayException(redacted);
|
||||
|
||||
String message = messageChanged ? redactedMessage : original;
|
||||
throw rebuildRedacted(ex, message, scrubbedStatus, scrubbedReply);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the {@link ProtocolStatus} an exception carries, if any, so it can
|
||||
* be scrubbed and re-attached to the rebuilt exception.
|
||||
*/
|
||||
private static ProtocolStatus protocolStatusOf(MxGatewayException ex) {
|
||||
if (ex instanceof MxGatewayCommandException command) {
|
||||
return command.protocolStatus();
|
||||
}
|
||||
if (ex instanceof MxGatewaySessionException session) {
|
||||
return session.protocolStatus();
|
||||
}
|
||||
if (ex instanceof MxGatewayWorkerException worker) {
|
||||
return worker.protocolStatus();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the raw {@link MxCommandReply} an exception carries, if any.
|
||||
*/
|
||||
private static MxCommandReply replyOf(MxGatewayException ex) {
|
||||
if (ex instanceof MxGatewayCommandException command) {
|
||||
return command.reply();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds a gateway exception of the same concrete type with a redacted
|
||||
* message and already-scrubbed context, mirroring the .NET client's
|
||||
* type-switch. Only a truly-unknown subtype collapses to the base
|
||||
* {@link MxGatewayException}. The original (secret-bearing) exception is
|
||||
* deliberately not chained as a cause.
|
||||
*/
|
||||
private static MxGatewayException rebuildRedacted(
|
||||
MxGatewayException ex, String message, ProtocolStatus status, MxCommandReply reply) {
|
||||
if (ex instanceof MxAccessException) {
|
||||
return new MxAccessException(message, status, reply, null);
|
||||
}
|
||||
if (ex instanceof MxGatewayCommandException) {
|
||||
return new MxGatewayCommandException(message, status, reply, null);
|
||||
}
|
||||
if (ex instanceof MxGatewaySessionException) {
|
||||
return new MxGatewaySessionException(message, status, null);
|
||||
}
|
||||
if (ex instanceof MxGatewayWorkerException) {
|
||||
return new MxGatewayWorkerException(message, status, null);
|
||||
}
|
||||
if (ex instanceof MxGatewayMalformedReplyException) {
|
||||
return new MxGatewayMalformedReplyException(message);
|
||||
}
|
||||
if (ex instanceof MxGatewayAuthenticationException) {
|
||||
return new MxGatewayAuthenticationException(message, null);
|
||||
}
|
||||
if (ex instanceof MxGatewayAuthorizationException) {
|
||||
return new MxGatewayAuthorizationException(message, null);
|
||||
}
|
||||
return new MxGatewayException(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a scrubbed clone of a command reply, removing any exact secret the
|
||||
* gateway echoed into {@code protocolStatus.message},
|
||||
* {@code diagnosticMessage}, or a status's {@code diagnosticText}.
|
||||
*
|
||||
* @param reply the reply to scrub, or {@code null}
|
||||
* @param secrets the exact secrets to strip
|
||||
* @return {@code null} when {@code reply} is {@code null}, otherwise a clone
|
||||
* with every echoed secret replaced by the redaction marker
|
||||
*/
|
||||
private static MxCommandReply scrubReply(MxCommandReply reply, String... secrets) {
|
||||
if (reply == null) {
|
||||
return null;
|
||||
}
|
||||
MxCommandReply.Builder builder = reply.toBuilder();
|
||||
if (builder.hasProtocolStatus()) {
|
||||
builder.setProtocolStatus(scrubProtocolStatus(builder.getProtocolStatus(), secrets));
|
||||
}
|
||||
builder.setDiagnosticMessage(MxGatewaySecrets.redactExact(builder.getDiagnosticMessage(), secrets));
|
||||
for (int index = 0; index < builder.getStatusesCount(); index++) {
|
||||
MxStatusProxy.Builder status = builder.getStatuses(index).toBuilder();
|
||||
status.setDiagnosticText(MxGatewaySecrets.redactExact(status.getDiagnosticText(), secrets));
|
||||
builder.setStatuses(index, status);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a scrubbed clone of a protocol status, removing any exact secret
|
||||
* the gateway echoed into its free-form {@code message}.
|
||||
*/
|
||||
private static ProtocolStatus scrubProtocolStatus(ProtocolStatus status, String... secrets) {
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
return status.toBuilder()
|
||||
.setMessage(MxGatewaySecrets.redactExact(status.getMessage(), secrets))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the string payload of a secured-write value so it can be scrubbed
|
||||
* from an echoed failure message. Only string-kind values carry a
|
||||
|
||||
+14
@@ -20,6 +20,20 @@ public final class MxGatewaySessionException extends MxGatewayException {
|
||||
this.protocolStatus = protocolStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new session exception with an already-built, verbatim message.
|
||||
* Used to re-surface a failure with a redacted message while preserving the
|
||||
* (already scrubbed) protocol status.
|
||||
*
|
||||
* @param message the exact message to surface (already formatted/redacted)
|
||||
* @param protocolStatus protocol status returned by the gateway
|
||||
* @param cause underlying error, or {@code null}
|
||||
*/
|
||||
protected MxGatewaySessionException(String message, ProtocolStatus protocolStatus, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.protocolStatus = protocolStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the gateway protocol status that triggered this exception.
|
||||
*
|
||||
|
||||
+14
@@ -20,6 +20,20 @@ public final class MxGatewayWorkerException extends MxGatewayException {
|
||||
this.protocolStatus = protocolStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new worker exception with an already-built, verbatim message.
|
||||
* Used to re-surface a failure with a redacted message while preserving the
|
||||
* (already scrubbed) protocol status.
|
||||
*
|
||||
* @param message the exact message to surface (already formatted/redacted)
|
||||
* @param protocolStatus protocol status returned by the gateway
|
||||
* @param cause underlying error, or {@code null}
|
||||
*/
|
||||
protected MxGatewayWorkerException(String message, ProtocolStatus protocolStatus, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.protocolStatus = protocolStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the gateway protocol status that triggered this exception.
|
||||
*
|
||||
|
||||
+31
-2
@@ -2,6 +2,7 @@ package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@@ -28,11 +29,23 @@ final class MxGatewayCredentialReplyTests {
|
||||
|
||||
@Test
|
||||
void authenticateUserRedactsEchoedCredentialFromReplyDrivenError() throws Exception {
|
||||
MxCommandReply reply = loadReply("authenticate-user.echoed-credential.reply.json");
|
||||
assertCredentialFullyRedacted(
|
||||
"authenticate-user.echoed-credential.reply.json", "auth-echo-session");
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticateUserRedactsEchoedCredentialFromMxAccessFailureReply() throws Exception {
|
||||
assertCredentialFullyRedacted(
|
||||
"authenticate-user.echoed-credential-mxaccess-failure.reply.json",
|
||||
"auth-echo-failure-session");
|
||||
}
|
||||
|
||||
private static void assertCredentialFullyRedacted(String fixture, String sessionId) throws Exception {
|
||||
MxCommandReply reply = loadReply(fixture);
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.startReturning(reply);
|
||||
MxGatewayClient client = gateway.client()) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "auth-echo-session");
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, sessionId);
|
||||
|
||||
MxAccessException error = assertThrows(
|
||||
MxAccessException.class,
|
||||
@@ -42,6 +55,22 @@ final class MxGatewayCredentialReplyTests {
|
||||
"credential echoed by the gateway must not survive in the surfaced message");
|
||||
assertTrue(error.getMessage().contains("<redacted>"),
|
||||
"the echoed credential must be replaced with the redaction marker");
|
||||
|
||||
// The rebuilt exception must not re-expose the credential through the
|
||||
// structured reply/protocolStatus a caller can inspect and log.
|
||||
MxCommandReply surfaced = error.reply();
|
||||
assertNotNull(surfaced, "the redacted exception must preserve a reply for inspection");
|
||||
assertFalse(surfaced.getProtocolStatus().getMessage().contains(CREDENTIAL),
|
||||
"reply protocol status message must not leak the echoed credential");
|
||||
assertFalse(surfaced.getDiagnosticMessage().contains(CREDENTIAL),
|
||||
"reply diagnostic message must not leak the echoed credential");
|
||||
for (int index = 0; index < surfaced.getStatusesCount(); index++) {
|
||||
assertFalse(surfaced.getStatusesList().get(index).getDiagnosticText().contains(CREDENTIAL),
|
||||
"reply status diagnostic text must not leak the echoed credential");
|
||||
}
|
||||
assertNotNull(error.protocolStatus(), "the redacted exception must preserve a protocol status");
|
||||
assertFalse(error.protocolStatus().getMessage().contains(CREDENTIAL),
|
||||
"exception protocol status must not leak the echoed credential");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class MxGatewaySecretsTests {
|
||||
@Test
|
||||
void redactExactReplacesEveryOccurrenceOfASecret() {
|
||||
String message = "verify s3cr3t, retry s3cr3t, done s3cr3t";
|
||||
|
||||
String result = MxGatewaySecrets.redactExact(message, "s3cr3t");
|
||||
|
||||
assertFalse(result.contains("s3cr3t"), "no occurrence of the secret may survive");
|
||||
assertEquals("verify <redacted>, retry <redacted>, done <redacted>", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactExactFullyRedactsOverlappingSecretsWhenOneIsASubstringOfTheOther() {
|
||||
String message = "password=hunter2 token=hunter2extra";
|
||||
|
||||
String result = MxGatewaySecrets.redactExact(message, "hunter2extra", "hunter2");
|
||||
|
||||
assertFalse(result.contains("hunter2"), "both the secret and its superstring must be fully redacted");
|
||||
assertEquals("password=<redacted> token=<redacted>", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactExactWithNoSecretsReturnsMessageUnchanged() {
|
||||
String message = "nothing to scrub here";
|
||||
|
||||
assertEquals(message, MxGatewaySecrets.redactExact(message));
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactExactToleratesNullMessage() {
|
||||
assertNull(MxGatewaySecrets.redactExact(null, "secret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactExactIgnoresBlankSecretSoRealSpacesAreNotOverRedacted() {
|
||||
String message = "keep these spaces intact";
|
||||
|
||||
String result = MxGatewaySecrets.redactExact(message, " ", "");
|
||||
|
||||
assertEquals(message, result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user