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:
@@ -0,0 +1,115 @@
|
||||
package mxgateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
)
|
||||
|
||||
// TestScrubReplyStringsRedactsEveryOccurrence covers the multi-occurrence case:
|
||||
// one secret appearing across ProtocolStatus.Message, DiagnosticMessage, and every
|
||||
// Statuses[].DiagnosticText must be fully redacted with no residue.
|
||||
func TestScrubReplyStringsRedactsEveryOccurrence(t *testing.T) {
|
||||
const secret = "hunter2"
|
||||
reply := &pb.MxCommandReply{
|
||||
ProtocolStatus: &pb.ProtocolStatus{Message: "rejected hunter2 then hunter2 again"},
|
||||
DiagnosticMessage: "echoed hunter2 back",
|
||||
Statuses: []*pb.MxStatusProxy{
|
||||
{DiagnosticText: "first hunter2"},
|
||||
{DiagnosticText: "second hunter2 and hunter2"},
|
||||
},
|
||||
}
|
||||
|
||||
scrubbed := scrubReplyStrings(reply, []string{secret})
|
||||
|
||||
if strings.Contains(scrubbed.GetProtocolStatus().GetMessage(), secret) {
|
||||
t.Fatalf("ProtocolStatus.Message still contains the secret: %q", scrubbed.GetProtocolStatus().GetMessage())
|
||||
}
|
||||
if strings.Contains(scrubbed.GetDiagnosticMessage(), secret) {
|
||||
t.Fatalf("DiagnosticMessage still contains the secret: %q", scrubbed.GetDiagnosticMessage())
|
||||
}
|
||||
for i, status := range scrubbed.GetStatuses() {
|
||||
if strings.Contains(status.GetDiagnosticText(), secret) {
|
||||
t.Fatalf("Statuses[%d].DiagnosticText still contains the secret: %q", i, status.GetDiagnosticText())
|
||||
}
|
||||
}
|
||||
if !strings.Contains(scrubbed.GetProtocolStatus().GetMessage(), redactedSecretMarker) {
|
||||
t.Fatalf("ProtocolStatus.Message missing redaction marker: %q", scrubbed.GetProtocolStatus().GetMessage())
|
||||
}
|
||||
|
||||
// The original reply must be untouched (scrubReplyStrings clones).
|
||||
if !strings.Contains(reply.GetDiagnosticMessage(), secret) {
|
||||
t.Fatal("scrubReplyStrings mutated the original reply instead of cloning it")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScrubReplyStringsRedactsOverlappingSecrets covers two secrets where one is a
|
||||
// substring of the other: both must be fully redacted, with no partial leak of the
|
||||
// longer secret's non-shared remainder.
|
||||
func TestScrubReplyStringsRedactsOverlappingSecrets(t *testing.T) {
|
||||
const shortSecret = "pass"
|
||||
const longSecret = "password123"
|
||||
reply := &pb.MxCommandReply{
|
||||
DiagnosticMessage: "value was password123 and also pass",
|
||||
}
|
||||
|
||||
scrubbed := scrubReplyStrings(reply, []string{longSecret, shortSecret})
|
||||
|
||||
got := scrubbed.GetDiagnosticMessage()
|
||||
if strings.Contains(got, shortSecret) {
|
||||
t.Fatalf("scrubbed message still contains a secret substring %q: %q", shortSecret, got)
|
||||
}
|
||||
if strings.Contains(got, longSecret) {
|
||||
t.Fatalf("scrubbed message still contains %q: %q", longSecret, got)
|
||||
}
|
||||
// "123" is the longer secret's remainder past the shared "pass" prefix; it must
|
||||
// not survive as a partial leak.
|
||||
if strings.Contains(got, "123") {
|
||||
t.Fatalf("scrubbed message leaked the longer secret's remainder: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactSecretsEmptyOrNilLeavesErrorUnchanged confirms the no-secret paths keep
|
||||
// the original typed error verbatim (no wrapping, no scrubbed clone).
|
||||
func TestRedactSecretsEmptyOrNilLeavesErrorUnchanged(t *testing.T) {
|
||||
base := &MxAccessError{Reply: &pb.MxCommandReply{DiagnosticMessage: "boom"}}
|
||||
|
||||
if got := redactSecrets(base); got != error(base) {
|
||||
t.Fatalf("redactSecrets with no secrets = %v, want the original error unchanged", got)
|
||||
}
|
||||
if got := redactSecrets(base, ""); got != error(base) {
|
||||
t.Fatalf("redactSecrets with only an empty secret = %v, want the original error unchanged", got)
|
||||
}
|
||||
if got := redactSecrets(nil, "secret"); got != nil {
|
||||
t.Fatalf("redactSecrets(nil, ...) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactSecretsRebuildsTypedCommandError confirms a *CommandError (non-MXAccess
|
||||
// path) is rebuilt with a scrubbed Status and Reply, and errors.As still reaches it.
|
||||
func TestRedactSecretsRebuildsTypedCommandError(t *testing.T) {
|
||||
const secret = "topSecretValue"
|
||||
base := &CommandError{
|
||||
Op: "write secured",
|
||||
Status: &pb.ProtocolStatus{Message: "rejected topSecretValue"},
|
||||
Reply: &pb.MxCommandReply{DiagnosticMessage: "echoed topSecretValue"},
|
||||
}
|
||||
|
||||
redacted := redactSecrets(base, secret)
|
||||
|
||||
var cmdErr *CommandError
|
||||
if !errors.As(redacted, &cmdErr) {
|
||||
t.Fatalf("redactSecrets result %T does not unwrap to *CommandError", redacted)
|
||||
}
|
||||
if strings.Contains(cmdErr.Status.GetMessage(), secret) {
|
||||
t.Fatalf("CommandError.Status.Message leaked the secret: %q", cmdErr.Status.GetMessage())
|
||||
}
|
||||
if strings.Contains(cmdErr.Reply.GetDiagnosticMessage(), secret) {
|
||||
t.Fatalf("CommandError.Reply.DiagnosticMessage leaked the secret: %q", cmdErr.Reply.GetDiagnosticMessage())
|
||||
}
|
||||
if strings.Contains(redacted.Error(), secret) {
|
||||
t.Fatalf("rendered error leaked the secret: %q", redacted.Error())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user