What changed, and why it matters
This commit tightens how Electrum handles error messages coming from external LNURL services. Previously, every LNURL error was wrapped with a 'do not trust this message' warning. The change splits those errors into two kinds: errors generated from Electrum's own code now display normally, while only errors returned by untrusted LNURL servers are sanitized and flagged as untrusted. It also applies a text-safety utility to the server-provided message before showing it to the user, reducing the chance that a malicious or compromised LNURL server could trick a user through a crafted error string.
Review util.error_text_str_to_safe_str to confirm it covers the intended threat model (e.g., HTML/markup injection, line-break spoofing, RTL override characters). Ensure all future call sites that raise on remote LNURL data use UntrustedLNURLError consistently. Consider adding unit tests that verify UntrustedLNURLError sanitizes server-controlled strings and that local errors are not decorated.
Security signals we found
Untrusted external input is now explicitly sanitized before UI display
Exception hierarchy distinguishes local errors from server-returned errors
Commit message frames change as a security-relevant hardening measure
New dependency on util.error_text_str_to_safe_str suggests prior concern about unsafe text rendering
Evidence from the diff
The patch refactors LNURLError into a base exception and a new UntrustedLNURLError subclass. The subclass prepends a warning label and passes the server-supplied reason through util.error_text_str_to_safe_str before display. Call sites that raise on server ‘reason’ fields or unknown remote tags now raise UntrustedLNURLError, while locally generated parsing errors use the plain LNURLError. This is a defense-in-depth change: it limits the attack surface for social-engineering or UI-injection via LNURL server responses and localizes sanitization to data that actually originates from an external party.
Changed components
electrum/lnurl.pyLNURL error handling and user-facing error messagesInspect captured patch +19 / −11
diff --git a/electrum/lnurl.py b/electrum/lnurl.py
index 3f035ba..9bfd059 100644
--- a/electrum/lnurl.py
+++ b/electrum/lnurl.py
@@ -10,22 +10,30 @@ import urllib.parse
import aiohttp.client_exceptions
-from electrum import segwit_addr
+from electrum import segwit_addr, util
from electrum.segwit_addr import bech32_decode, Encoding, convertbits, bech32_encode
from electrum.lnaddr import LnDecodeException, LnEncodeException
from electrum.network import Network
from electrum.logging import get_logger
+from electrum.i18n import _
_logger = get_logger(__name__)
-class LNURLError(Exception):
- def __init__(self, message="", *args):
- # error messages are returned by the LNURL server, some services could try to trick
- # users into doing something by sending a malicious error message
- modified_message = f"[DO NOT TRUST THIS MESSAGE]:\n{message}"
- super().__init__(modified_message, *args)
+class LNURLError(Exception): pass
+
+class UntrustedLNURLError(LNURLError):
+ def __init__(self, message=""):
+ # use if error messages are returned by the LNURL server,
+ # some services could try to trick users into doing something
+ # by sending a malicious error message
+ if message:
+ message = (
+ f"{_('[DO NOT TRUST THIS MESSAGE]:')}\n"
+ f"{util.error_text_str_to_safe_str(message)}"
+ )
+ super().__init__(message)
def decode_lnurl(lnurl: str) -> str:
@@ -124,7 +132,7 @@ async def _request_lnurl(url: str) -> dict:
status = response.get("status")
if status and status == "ERROR":
- raise LNURLError(f"LNURL request encountered an error: {response.get('reason', '<missing reason>')}")
+ raise UntrustedLNURLError(f"LNURL request encountered an error: {response.get('reason', '<missing reason>')}")
return response
@@ -168,7 +176,7 @@ def _parse_lnurl3_response(lnurl_response: dict) -> LNURL3Data:
"""Parses the server response received when requesting a LNURL-withdraw (lud3) request"""
callback_url = _parse_lnurl_response_callback_url(lnurl_response)
if not (k1 := lnurl_response.get('k1')):
- raise LNURLError(f"Missing k1 value in LNURL3 response: {lnurl_response=}")
+ raise UntrustedLNURLError(f"Missing k1 value in LNURL3 response: {lnurl_response=}")
default_description = lnurl_response.get('defaultDescription', '')
try:
min_withdrawable_sat = int(lnurl_response['minWithdrawable']) // 1000
@@ -194,7 +202,7 @@ async def request_lnurl(url: str) -> LNURLData:
return _parse_lnurl6_response(lnurl_dict)
elif tag == 'withdrawRequest':
return _parse_lnurl3_response(lnurl_dict)
- raise LNURLError(f"Unknown subtype of lnurl. tag={tag}")
+ raise UntrustedLNURLError(f"Unknown subtype of lnurl. tag={tag}")
async def try_resolve_lnurlpay(lnurl: Optional[str]) -> Optional[LNURL6Data]:
@@ -236,7 +244,7 @@ async def callback_lnurl(url: str, params: dict) -> dict:
status = response.get("status")
if status and status == "ERROR":
- raise LNURLError(f"LNURL request encountered an error: {response.get('reason', '<missing reason>')}")
+ raise UntrustedLNURLError(f"LNURL request encountered an error: {response.get('reason', '<missing reason>')}")
# TODO: handling of specific errors (validate fields, e.g. for lnurl6)
return response
Why this scored 50/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.