From d7a5229479d550117c6cd415f3ad542edcd08781 Mon Sep 17 00:00:00 2001 From: Amperstrand Date: Sat, 5 Sep 2026 14:26:03 +0200 Subject: [PATCH] lightningd: fix crash and busy-loop on invoice with huge expiry invoice's `expiry` parameter is an unclamped param_u64, and far-future values break the daemon in two different ways: - expiry >= 2^60 needs more bits than push_varlen_field() can encode in the bolt11 `x` field, so bolt11_encode() aborts the whole daemon (FATAL SIGNAL 6). - far below that (anywhere past ~584k years), the invoice expiration timer's nanosecond-grain u64 counter overflows: install_expiration_timer() arms a timer that reads as already due, trigger_expiration() finds nothing expired, re-arms, and the daemon busy-loops at 100% CPU with the RPC reply left racing the storm. Refuse at the parameter stage instead: expiry >= 2^32 seconds (~136 years) returns JSONRPC2_INVALID_PARAMS, keeping a wide margin under both limits. 2^32 - 1 still works. Changelog-Fixed: lightningd: fix crash (`FATAL SIGNAL 6`) and a 100% CPU busy-loop when calling `invoice` with an `expiry` too far in the future (now refused above 2^32 seconds). --- lightningd/invoice.c | 11 +++++++++++ tests/test_invoices.py | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/lightningd/invoice.c b/lightningd/invoice.c index bb9a1aa54071..e10c8df96e9e 100644 --- a/lightningd/invoice.c +++ b/lightningd/invoice.c @@ -1152,6 +1152,17 @@ static struct command_result *json_invoice(struct command *cmd, return command_fail(cmd, JSONRPC2_INVALID_PARAMS, "dev-routes requires --developer"); + /* Two hard limits on how far in the future an invoice can expire: + * push_varlen_field() can only encode up to 60 bits (larger values + * abort the daemon in bolt11_encode()), and the invoice expiration + * timer overflows its u64 nanosecond-based grain count far below + * that, leaving the expiry check looping forever. 2^32 seconds + * (~136 years) keeps a wide margin under both. */ + if (*expiry >= (u64)1 << 32) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "expiry must be below 2^32 seconds" + " (~136 years)"); + if (strlen(info->label->s) > inv_max_label_len) { return command_fail(cmd, JSONRPC2_INVALID_PARAMS, "Label '%s' over %zu bytes", info->label->s, inv_max_label_len); diff --git a/tests/test_invoices.py b/tests/test_invoices.py index 8ef2d26d7f30..78d8620e17d6 100644 --- a/tests/test_invoices.py +++ b/tests/test_invoices.py @@ -442,6 +442,30 @@ def test_invoice_expiry(node_factory, executor): assert expiry >= start + 1 and expiry <= end + 1 +def test_invoice_expiry_too_large(node_factory): + """An expiry too large to be safe must be refused, not crash or wedge. + + The `x` field is encoded by push_varlen_field(), which can only + express values of up to 60 bits and aborts the whole daemon for + anything larger. Long before that, the invoice expiration timer's + nanosecond-based counter overflows and the expiry check busy-loops + forever, so anything beyond 2^32 seconds (~136 years) is refused. + """ + l1 = node_factory.get_node() + + # The exact boundary still works: 2^32 - 1 is ~136 years of headroom. + ok = l1.rpc.invoice(amount_msat=1000, label='expiry-boundary-ok', + description='boundary', expiry=2**32 - 1) + assert ok['bolt11'] + + # One above the boundary: typed refusal, daemon stays alive. + with pytest.raises(RpcError, match='expiry must be below') as err: + l1.rpc.invoice(amount_msat=1000, label='expiry-too-large', + description='too large', expiry=2**32) + assert err.value.error['code'] == -32602 + assert l1.rpc.getinfo()['id'] + + def test_waitinvoice(node_factory, executor): """Test waiting for one invoice will not return if another invoice is paid. """