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:
@@ -919,19 +919,47 @@ def _value_secrets(value: MxValueInput) -> list[str]:
|
||||
|
||||
|
||||
def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None:
|
||||
"""Scrub secret substrings from a raised error's message in place.
|
||||
"""Scrub secret substrings from a raised error's message and reply in place.
|
||||
|
||||
Rewrites ``error.args[0]`` (the message returned by ``str(error)``) through
|
||||
the shared :func:`~zb_mom_ww_mxgateway.auth.redact_secret` seam so credential
|
||||
text can never reach logs or be re-raised to a caller. The
|
||||
``protocol_status`` / ``raw_reply`` context is left untouched — those hold the
|
||||
gateway's own fields, which never echo the client-supplied secret.
|
||||
text can never reach logs or be re-raised to a caller.
|
||||
|
||||
A misbehaving MXAccess provider can echo the client-supplied credential back
|
||||
verbatim in its failure diagnostics, so ``error.raw_reply`` (the protobuf
|
||||
reply) can carry the secret in ``protocol_status.message``,
|
||||
``diagnostic_message``, and each ``statuses[].diagnostic_text``. A logger
|
||||
dumping those structured fields would reintroduce the leak the message scrub
|
||||
closes. When there is a secret to scrub and a reply is attached, this rebinds
|
||||
``error.raw_reply`` to a scrubbed deep copy so the raised exception carries no
|
||||
credential text on any surface. The clone leaves the original reply untouched.
|
||||
"""
|
||||
scrubbed = [secret for secret in secrets if secret]
|
||||
if not scrubbed:
|
||||
return
|
||||
if error.args and isinstance(error.args[0], str):
|
||||
error.args = (redact_secret(error.args[0], scrubbed), *error.args[1:])
|
||||
if error.raw_reply is not None:
|
||||
error.raw_reply = _redact_reply(error.raw_reply, scrubbed)
|
||||
|
||||
|
||||
def _redact_reply(reply: pb.MxCommandReply, secrets: Sequence[str]) -> pb.MxCommandReply:
|
||||
"""Return a deep copy of *reply* with credential text scrubbed from diagnostics.
|
||||
|
||||
Operates on a clone so the caller's original reply object is never mutated.
|
||||
Only the free-text diagnostic fields that can echo a client-supplied secret
|
||||
are scrubbed; the structured/enum fields the gateway itself sets are left as-is.
|
||||
"""
|
||||
clone = type(reply)()
|
||||
clone.CopyFrom(reply)
|
||||
if clone.protocol_status.message:
|
||||
clone.protocol_status.message = redact_secret(clone.protocol_status.message, secrets)
|
||||
if clone.diagnostic_message:
|
||||
clone.diagnostic_message = redact_secret(clone.diagnostic_message, secrets)
|
||||
for status in clone.statuses:
|
||||
if status.diagnostic_text:
|
||||
status.diagnostic_text = redact_secret(status.diagnostic_text, secrets)
|
||||
return clone
|
||||
|
||||
|
||||
from .client import GatewayClient # noqa: E402
|
||||
|
||||
@@ -82,15 +82,31 @@ async def test_add_buffered_item_missing_payload_raises_malformed_reply() -> Non
|
||||
assert captured.value.raw_reply is reply
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fixture",
|
||||
[
|
||||
"command-replies/authenticate-user.echoed-credential.reply.json",
|
||||
"command-replies/authenticate-user.echoed-credential-mxaccess-failure.reply.json",
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_echoed_credential_is_scrubbed() -> None:
|
||||
async def test_authenticate_user_echoed_credential_is_scrubbed(fixture: str) -> None:
|
||||
credential = "sup3rSecretVerify9f3a2b"
|
||||
reply = _load_reply("command-replies/authenticate-user.echoed-credential.reply.json")
|
||||
reply = _load_reply(fixture)
|
||||
session, _ = await _session_with([reply])
|
||||
|
||||
with pytest.raises(MxAccessError) as captured:
|
||||
await session.authenticate_user(12, "operator", credential)
|
||||
|
||||
message = str(captured.value)
|
||||
exc = captured.value
|
||||
message = str(exc)
|
||||
assert credential not in message
|
||||
assert "[redacted]" in message
|
||||
|
||||
# The credential must not survive in the structured protobuf context either:
|
||||
# a logger dumping raw_reply's fields would otherwise reintroduce the leak.
|
||||
assert exc.raw_reply is not None
|
||||
assert credential not in exc.raw_reply.protocol_status.message
|
||||
assert credential not in exc.raw_reply.diagnostic_message
|
||||
for status in exc.raw_reply.statuses:
|
||||
assert credential not in status.diagnostic_text
|
||||
|
||||
@@ -140,10 +140,19 @@ async def test_write_secured_surfaces_native_failure_without_prior_authenticate(
|
||||
with pytest.raises(MxAccessError) as captured:
|
||||
await session.write_secured(12, 34, secret_value, current_user_id=5, verifier_user_id=6)
|
||||
|
||||
# Native failure is surfaced (not "fixed") and the raw reply is preserved...
|
||||
assert captured.value.raw_reply is failure
|
||||
# ...but the credential-sensitive value is scrubbed from the surfaced message.
|
||||
# Native failure is surfaced (not "fixed"): the raw reply's structure is
|
||||
# preserved so callers still see the native verdict...
|
||||
raw = captured.value.raw_reply
|
||||
assert raw is not None
|
||||
assert raw.kind == pb.MX_COMMAND_KIND_WRITE_SECURED
|
||||
assert raw.hresult == -2147217407
|
||||
assert raw.protocol_status.code == pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE
|
||||
# ...but the credential-sensitive value is scrubbed from the surfaced message
|
||||
# AND from the reply's echoed diagnostics, so a logger dumping raw_reply's
|
||||
# structured fields cannot reintroduce the leak.
|
||||
assert secret_value not in str(captured.value)
|
||||
assert secret_value not in raw.protocol_status.message
|
||||
assert "[redacted]" in raw.protocol_status.message
|
||||
command = stub.invoke.requests[0].command
|
||||
assert command.kind == pb.MX_COMMAND_KIND_WRITE_SECURED
|
||||
assert command.write_secured.current_user_id == 5
|
||||
|
||||
Reference in New Issue
Block a user