fix: tighten redemption error mapping and stop X-Cashu raw-text leak

- Anchor the broad invalid/decode buckets to co-occur with "token" so
  internal faults (e.g. "Invalid isoformat string") fall through to 500
  instead of masquerading as a 401 token error.
- Map the "estimate fees"/"exceed token amount" wallet errors to the
  specific "too small to cover swap fees" message instead of the generic
  bucket.
- Stop the X-Cashu redemption path from interpolating raw mint/exception
  text into mint_error/cashu_error responses (both duplicated blocks).
- Add tests for the fee-gap mapping and the anchored gate.
This commit is contained in:
9qeklajc
2026-07-05 16:53:54 +02:00
parent 7f49ba1771
commit 9dc4c979b8
3 changed files with 56 additions and 6 deletions
+11 -2
View File
@@ -91,11 +91,20 @@ def redemption_error_to_http_exception(error: Exception) -> HTTPException:
status_code = 400
if "already spent" in lowered:
message = "Cashu token already spent"
elif "insufficient" in lowered or "melt fee" in lowered:
elif (
"insufficient" in lowered
or "melt fee" in lowered
or "exceed token amount" in lowered
or "estimate fees" in lowered
):
message = "Token value is too small to cover swap fees"
elif "failed to melt" in lowered:
message = "Failed to swap token from foreign mint"
elif "invalid" in lowered or "decode" in lowered:
elif ("invalid" in lowered or "decode" in lowered) and "token" in lowered:
# Anchor the broad buckets to "token" so internal faults whose text
# merely contains "invalid"/"decode" (e.g. "invalid literal",
# SQLAlchemy "Invalid …") fall through to the generic 500 below
# instead of masquerading as a 401 token error.
message = "Invalid Cashu token"
status_code = 401
elif isinstance(error, ValueError):
+4 -4
View File
@@ -4049,7 +4049,7 @@ class BaseUpstreamProvider:
if "mint error" in error_message.lower():
return create_error_response(
"mint_error",
f"CASHU mint error: {error_message}",
"CASHU mint error while processing token",
422,
request=request,
token=x_cashu_token,
@@ -4057,7 +4057,7 @@ class BaseUpstreamProvider:
return create_error_response(
"cashu_error",
f"CASHU token processing failed: {error_message}",
"CASHU token processing failed",
400,
request=request,
token=x_cashu_token,
@@ -4717,7 +4717,7 @@ class BaseUpstreamProvider:
if "mint error" in error_message.lower():
return create_error_response(
"mint_error",
f"CASHU mint error: {error_message}",
"CASHU mint error while processing token",
422,
request=request,
token=x_cashu_token,
@@ -4725,7 +4725,7 @@ class BaseUpstreamProvider:
return create_error_response(
"cashu_error",
f"CASHU token processing failed: {error_message}",
"CASHU token processing failed",
400,
request=request,
token=x_cashu_token,
+41
View File
@@ -75,6 +75,13 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
400,
"Token value is too small to cover swap fees",
),
(
ValueError(
"Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)"
),
400,
"Token value is too small to cover swap fees",
),
(
ValueError(
"Failed to melt token from foreign mint http://foreign:3338: boom"
@@ -158,3 +165,37 @@ async def test_unexpected_redemption_error_returns_internal_error(
assert error_detail["code"] == "internal_error"
assert "/var/lib/secret" not in error_detail["message"]
assert await session.get(ApiKey, hashed_key) is None
@pytest.mark.asyncio
async def test_internal_error_with_invalid_keyword_does_not_masquerade(
session: AsyncSession,
) -> None:
"""A non-wallet fault whose text merely contains "invalid" (but not
"token") must fall through to a generic 500, not a 401 token error.
Guards the anchored `"invalid"/"decode"` + `"token"` gate against stdlib/
driver strings like "Invalid isoformat string" leaking as token errors."""
token = "cashuAinternal_fault_mentions_invalid"
hashed_key = hashlib.sha256(token.encode()).hexdigest()
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
from routstr.core.settings import settings
with (
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
patch("routstr.auth.deserialize_token_from_string", return_value=token_obj),
patch(
"routstr.auth.credit_balance",
new=AsyncMock(
side_effect=RuntimeError("Invalid isoformat string: '2020-13-99'")
),
),
):
with pytest.raises(HTTPException) as exc_info:
await validate_bearer_key(token, session)
assert exc_info.value.status_code == 500
detail = cast(dict[str, dict[str, str]], exc_info.value.detail)
assert detail["error"]["code"] == "internal_error"
assert await session.get(ApiKey, hashed_key) is None