fetchinvoice: handle weird labels in recurrence_label parameter.
What changed, and why it matters
This commit fixes how the fetchinvoice plugin handles user-supplied labels that contain special characters such as backslashes, quotes, tabs, and newlines. Previously these labels were passed as raw strings, which could cause JSON encoding problems when the plugin forwarded the label to other parts of the lightning node. The change now parses the label through a JSON-aware escaping helper and writes it back out as a safely escaped JSON string. The included test demonstrates that weird labels now work correctly for recurring invoices and cancellations.
Review other RPC parameters in fetchinvoice and related plugins that accept free-form strings and are re-emitted as JSON to ensure consistent use of json_escape/escaped-string helpers. Consider whether any externally reachable path could previously have caused malformed JSON or parser confusion.
Security signals we found
JSON injection / improper escaping of user-controlled input passed between RPC components
Potential mismatch between label parsing and label serialization leading to functional failures or unexpected behavior
Fix is narrowly scoped to a single plugin and parameter
Evidence from the diff
The patch changes plugins/fetchinvoice.c to use the json_escape type and param_label parser for the recurrence_label parameter in both json_fetchinvoice and json_cancelrecurringinvoice. It replaces json_add_string with json_add_escaped_string when emitting the label into an outgoing JSON request, and uses the escaped string’s ->s member when computing the BOLT12 alias tweak. A regression test in tests/test_pay.py exercises a label containing backslash, double quote, tab, and newline characters through fetchinvoice and cancelrecurringinvoice flows.
Changed components
plugins/fetchinvoice.cjson_fetchinvoice RPCjson_cancelrecurringinvoice RPCBOLT12 recurrence label handlingInspect captured patch +37 / −10
diff --git a/plugins/fetchinvoice.c b/plugins/fetchinvoice.c
index 8d83328c..48a52a05 100644
--- a/plugins/fetchinvoice.c
+++ b/plugins/fetchinvoice.c
@@ -1,4 +1,5 @@
#include "config.h"
+#include <ccan/json_escape/json_escape.h>
#include <ccan/json_out/json_out.h>
#include <ccan/mem/mem.h>
#include <ccan/str/hex/hex.h>
@@ -791,7 +792,7 @@ static bool payer_key(const struct offers_data *od,
static u8 *recurrence_invreq_metadata(const tal_t *ctx,
const struct tlv_invoice_request *invreq,
const struct secret *nodealias_base,
- const char *rec_label)
+ const struct json_escape *rec_label)
{
struct sha256 offer_id, tweak;
u8 *tweak_input;
@@ -799,11 +800,11 @@ static u8 *recurrence_invreq_metadata(const tal_t *ctx,
/* Use "offer_id || label" as tweak input */
invreq_offer_id(invreq, &offer_id);
tweak_input = tal_arr(tmpctx, u8,
- sizeof(offer_id) + strlen(rec_label));
+ sizeof(offer_id) + strlen(rec_label->s));
memcpy(tweak_input, &offer_id, sizeof(offer_id));
memcpy(tweak_input + sizeof(offer_id),
- rec_label,
- strlen(rec_label));
+ rec_label->s,
+ strlen(rec_label->s));
bolt12_alias_tweak(nodealias_base,
tweak_input,
@@ -854,7 +855,8 @@ struct command_result *json_fetchinvoice(struct command *cmd,
{
const struct offers_data *od = get_offers_data(cmd->plugin);
struct amount_msat *msat;
- const char *rec_label, *payer_note;
+ const char *payer_note;
+ struct json_escape *rec_label;
u8 *payer_metadata;
struct out_req *req;
struct tlv_invoice_request *invreq;
@@ -870,7 +872,7 @@ struct command_result *json_fetchinvoice(struct command *cmd,
p_opt("quantity", param_u64, &quantity),
p_opt("recurrence_counter", param_number, &recurrence_counter),
p_opt("recurrence_start", param_number, &recurrence_start),
- p_opt("recurrence_label", param_string, &rec_label),
+ p_opt("recurrence_label", param_label, &rec_label),
p_opt_def("timeout", param_number, &timeout, 60),
p_opt("payer_note", param_string, &payer_note),
p_opt("payer_metadata", param_bin_from_hex, &payer_metadata),
@@ -1096,7 +1098,7 @@ struct command_result *json_fetchinvoice(struct command *cmd,
json_add_string(req->js, "bolt12", invrequest_encode(tmpctx, invreq));
json_add_bool(req->js, "savetodb", false);
if (rec_label)
- json_add_string(req->js, "label", rec_label);
+ json_add_escaped_string(req->js, "label", rec_label);
return send_outreq(req);
}
@@ -1105,7 +1107,8 @@ struct command_result *json_cancelrecurringinvoice(struct command *cmd,
const jsmntok_t *params)
{
const struct offers_data *od = get_offers_data(cmd->plugin);
- const char *rec_label, *payer_note;
+ const char *payer_note;
+ struct json_escape *rec_label;
struct out_req *req;
struct tlv_invoice_request *invreq;
struct sent *sent = tal(cmd, struct sent);
@@ -1115,7 +1118,7 @@ struct command_result *json_cancelrecurringinvoice(struct command *cmd,
if (!param_check(cmd, buffer, params,
p_req("offer", param_offer, &sent->offer),
p_req("recurrence_counter", param_number, &recurrence_counter),
- p_req("recurrence_label", param_string, &rec_label),
+ p_req("recurrence_label", param_label, &rec_label),
p_opt("recurrence_start", param_number, &recurrence_start),
p_opt("payer_note", param_string, &payer_note),
p_opt("bip353", param_bip353, &bip353),
@@ -1250,7 +1253,7 @@ struct command_result *json_cancelrecurringinvoice(struct command *cmd,
/* We don't want this is the database: that's only for ones we publish */
json_add_string(req->js, "bolt12", invrequest_encode(tmpctx, invreq));
json_add_bool(req->js, "savetodb", false);
- json_add_string(req->js, "label", rec_label);
+ json_add_escaped_string(req->js, "label", rec_label);
return send_outreq(req);
}
diff --git a/tests/test_pay.py b/tests/test_pay.py
index df768bda..fdf09cda 100644
--- a/tests/test_pay.py
+++ b/tests/test_pay.py
@@ -4576,6 +4576,30 @@ def test_offer(node_factory, bitcoind):
assert enable_ret['description'] == offer_desc
+def test_recurrence_escaped_label(node_factory, bitcoind):
+ l1, l2 = node_factory.line_graph(2)
+
+ # Recurring offer.
+ offer = l2.rpc.offer(amount='1msat',
+ description='test_recurrence_escaped_label',
+ recurrence='1minutes')['bolt12']
+ # Works the first time
+ weird_label = 'label \\ " \t \n'
+ ret = l1.rpc.fetchinvoice(offer=offer,
+ recurrence_counter=0,
+ recurrence_label=weird_label)
+ l1.rpc.xpay(invstring=ret['invoice'])
+ # Works the second time to match
+ l1.rpc.fetchinvoice(offer=offer,
+ recurrence_counter=1,
+ recurrence_label=weird_label)
+
+ # Works to cancel.
+ l1.rpc.cancelrecurringinvoice(offer=offer,
+ recurrence_counter=2,
+ recurrence_label=weird_label)
+
+
def test_offer_deprecated_api(node_factory, bitcoind):
l1, l2 = node_factory.line_graph(2, opts={'allow-deprecated-apis': True})
Why this scored 35/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.