libplugin: allow sync interface to be used at all times.
What changed, and why it matters
This change lets Core Lightning plugins make synchronous (blocking) JSON-RPC calls at any time, not just during startup. To do that safely, it opens a second Unix socket connection to the lightningd RPC so the sync call does not collide with ongoing async traffic. The patch also fixes a memory-handling quirk so the returned response is a proper allocated object. There is no direct evidence this fixes an active security bug, but it removes a risky restriction and improves robustness.
Review downstream plugins that previously worked around the init-only sync restriction; verify they do not now perform blocking sync RPC calls from time-sensitive handlers. Ensure the new rpc_open path handles long rpc_location paths and connection failures safely. Consider adding fuzz or concurrency tests that exercise sync+async RPC overlap.
Security signals we found
Removed assert(streq(init_cmd->methodname, "init")) guards, which previously prevented use outside init
Added a dedicated sync RPC socket to avoid interleaving sync and async traffic on the same fd
Fixed surprising memory semantics where sync RPC returned a pointer into an array rather than a valid tal object
Changed read_rpc_reply and handle_rpc_reply signatures to pass the correct buffer offset explicitly
Evidence from the diff
The commit refactors libplugin’s synchronous RPC helpers (jsonrpc_request_sync, rpc_scan, rpc_scan_datastore_*) so they no longer assert they are running inside the init callback. It introduces rpc_open() to create a fresh AF_UNIX socket to lightningd’s RPC socket, and after init the async fd is closed (set to -1) so a new sync fd is opened on demand. read_rpc_reply and sync_req are updated to return a tal-resized jsmntok_t array instead of a pointer into the connection membuf. A test plugin now calls jsonrpc_request_sync from a normal command handler. The change is defensive: it prevents plugins from corrupting or misinterpreting async RPC stream state by interleaving synchronous requests.
Changed components
plugins/libplugin.cplugins/libplugin.htests/plugins/test_libplugin.ctests/test_plugin.pyInspect captured patch +91 / −55
diff --git a/plugins/libplugin.c b/plugins/libplugin.c
index dbe58d2c..616eb70c 100644
--- a/plugins/libplugin.c
+++ b/plugins/libplugin.c
@@ -187,6 +187,27 @@ static struct command *new_command(const tal_t *ctx,
return cmd;
}
+static int rpc_open(struct plugin *plugin)
+{
+ struct sockaddr_un addr;
+ int fd = socket(AF_UNIX, SOCK_STREAM, 0);
+
+ if (strlen(plugin->rpc_location) + 1 > sizeof(addr.sun_path))
+ plugin_err(plugin, "rpc filename '%s' too long",
+ plugin->rpc_location);
+ strcpy(addr.sun_path, plugin->rpc_location);
+ addr.sun_family = AF_UNIX;
+
+ if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
+ plugin_log(plugin, LOG_UNUSUAL,
+ "Could not connect to '%s': %s",
+ plugin->rpc_location, strerror(errno));
+ close(fd);
+ fd = -1;
+ }
+ return fd;
+}
+
static void complain_deprecated(const char *feature,
bool allowing,
struct command *cmd)
@@ -734,14 +755,14 @@ void command_set_usage(struct command *cmd, const char *usage TAKES)
}
/* Reads rpc reply and returns tokens, setting contents to 'error' or
- * 'result' (depending on *error). */
-static const jsmntok_t *read_rpc_reply(const tal_t *ctx,
- struct plugin *plugin,
- const jsmntok_t **contents,
- bool *error,
- int *reqlen)
+- * 'result' (depending on *error). */
+static jsmntok_t *read_rpc_reply(const tal_t *ctx,
+ struct plugin *plugin,
+ const jsmntok_t **contents,
+ bool *error,
+ int *reqlen)
{
- const jsmntok_t *toks;
+ jsmntok_t *toks;
do {
*reqlen = read_json_from_rpc(plugin);
@@ -778,38 +799,56 @@ static const jsmntok_t *sync_req(const tal_t *ctx,
const char **resp)
{
bool error;
+ jsmntok_t *toks;
const jsmntok_t *contents;
int reqlen;
struct json_out *jout = json_out_new(tmpctx);
const char *id = json_id(tmpctx, plugin, "init/", method);
+ size_t num_toks;
json_out_start(jout, NULL, '{');
json_out_addstr(jout, "jsonrpc", "2.0");
/* Copy in id *literally* */
memcpy(json_out_member_direct(jout, "id", strlen(id)), id, strlen(id));
json_out_addstr(jout, "method", method);
- json_out_add_splice(jout, "params", params);
+ if (params)
+ json_out_add_splice(jout, "params", params);
+ else {
+ json_out_start(jout, "params", '{');
+ json_out_end(jout, '}');
+ }
if (taken(params))
tal_free(params);
+
+ /* If we're past init, we may need a new fd (the old one
+ * is being used for async comms). */
+ if (plugin->rpc_conn->fd == -1)
+ plugin->rpc_conn->fd = rpc_open(plugin);
+
finish_and_send_json(plugin->rpc_conn->fd, jout);
- read_rpc_reply(ctx, plugin, &contents, &error, &reqlen);
+ toks = read_rpc_reply(ctx, plugin, &contents, &error, &reqlen);
if (error)
plugin_err(plugin, "Got error reply to %s: '%.*s'",
method, reqlen, membuf_elems(&plugin->rpc_conn->mb));
*resp = membuf_consume(&plugin->rpc_conn->mb, reqlen);
- return contents;
+
+ /* Make the returned pointer the valid tal object of minimal length */
+ num_toks = json_next(contents) - contents;
+ memmove(toks, contents, num_toks * sizeof(*toks));
+ tal_resize(&toks, num_toks);
+ return toks;
}
const jsmntok_t *jsonrpc_request_sync(const tal_t *ctx,
- struct command *init_cmd,
+ struct command *cmd,
const char *method,
const struct json_out *params TAKES,
const char **resp)
{
- assert(streq(init_cmd->methodname, "init"));
- return sync_req(ctx, init_cmd->plugin, method, params, resp);
+
+ return sync_req(ctx, cmd->plugin, method, params, resp);
}
/* Returns contents of scanning guide on 'result' */
@@ -828,7 +867,7 @@ static const char *rpc_scan_core(const tal_t *ctx,
}
/* Synchronous routine to send command and extract fields from response */
-void rpc_scan(struct command *init_cmd,
+void rpc_scan(struct command *cmd,
const char *method,
const struct json_out *params TAKES,
const char *guide,
@@ -837,13 +876,12 @@ void rpc_scan(struct command *init_cmd,
const char *err;
va_list ap;
- assert(streq(init_cmd->methodname, "init"));
va_start(ap, guide);
- err = rpc_scan_core(tmpctx, init_cmd->plugin, method, params, guide, ap);
+ err = rpc_scan_core(tmpctx, cmd->plugin, method, params, guide, ap);
va_end(ap);
if (err)
- plugin_err(init_cmd->plugin, "Could not parse %s in reply to %s: %s",
+ plugin_err(cmd->plugin, "Could not parse %s in reply to %s: %s",
guide, method, err);
}
@@ -858,7 +896,7 @@ static void json_add_keypath(struct json_out *jout, const char *fieldname, const
}
static const char *rpc_scan_datastore(const tal_t *ctx,
- struct command *init_cmd,
+ struct command *cmd,
const char *path,
const char *hex_or_string,
va_list ap)
@@ -866,7 +904,6 @@ static const char *rpc_scan_datastore(const tal_t *ctx,
const char *guide;
struct json_out *params;
- assert(streq(init_cmd->methodname, "init"));
params = json_out_new(NULL);
json_out_start(params, NULL, '{');
json_add_keypath(params, "key", path);
@@ -874,12 +911,12 @@ static const char *rpc_scan_datastore(const tal_t *ctx,
json_out_finished(params);
guide = tal_fmt(tmpctx, "{datastore:[0:{%s:%%}]}", hex_or_string);
- return rpc_scan_core(ctx, init_cmd->plugin, "listdatastore", take(params),
+ return rpc_scan_core(ctx, cmd->plugin, "listdatastore", take(params),
guide, ap);
}
const char *rpc_scan_datastore_str(const tal_t *ctx,
- struct command *init_cmd,
+ struct command *cmd,
const char *path,
...)
{
@@ -887,14 +924,14 @@ const char *rpc_scan_datastore_str(const tal_t *ctx,
va_list ap;
va_start(ap, path);
- ret = rpc_scan_datastore(ctx, init_cmd, path, "string", ap);
+ ret = rpc_scan_datastore(ctx, cmd, path, "string", ap);
va_end(ap);
return ret;
}
/* This variant scans the hex encoding, not the string */
const char *rpc_scan_datastore_hex(const tal_t *ctx,
- struct command *init_cmd,
+ struct command *cmd,
const char *path,
...)
{
@@ -902,7 +939,7 @@ const char *rpc_scan_datastore_hex(const tal_t *ctx,
va_list ap;
va_start(ap, path);
- ret = rpc_scan_datastore(ctx, init_cmd, path, "hex", ap);
+ ret = rpc_scan_datastore(ctx, cmd, path, "hex", ap);
va_end(ap);
return ret;
}
@@ -1038,13 +1075,12 @@ static void destroy_cmd_mark_freed(struct command *cmd, bool *cmd_freed)
*cmd_freed = true;
}
-static void handle_rpc_reply(struct plugin *plugin, const jsmntok_t *toks)
+static void handle_rpc_reply(struct plugin *plugin, const char *buf, const jsmntok_t *toks)
{
const jsmntok_t *idtok, *contenttok;
struct out_req *out;
struct command_result *res;
bool cmd_freed;
- const char *buf = plugin->rpc_buffer + plugin->rpc_read_offset;
idtok = json_get_member(buf, toks, "id");
if (!idtok)
@@ -1397,7 +1433,7 @@ static bool rpc_read_response_one(struct plugin *plugin)
plugin->rpc_buffer + plugin->rpc_read_offset);
}
- handle_rpc_reply(plugin, plugin->rpc_toks);
+ handle_rpc_reply(plugin, plugin->rpc_buffer + plugin->rpc_read_offset, plugin->rpc_toks);
/* Move this object out of the buffer */
plugin->rpc_read_offset += plugin->rpc_toks[0].end;
@@ -1547,11 +1583,10 @@ static struct command_result *handle_init(struct command *cmd,
const jsmntok_t *params)
{
const jsmntok_t *configtok, *opttok, *t;
- struct sockaddr_un addr;
size_t i;
char *dir, *network;
struct plugin *p = cmd->plugin;
- bool with_rpc = p->rpc_conn != NULL;
+ bool with_rpc;
const char *err;
configtok = json_get_member(buf, params, "configuration");
@@ -1578,28 +1613,17 @@ static struct command_result *handle_init(struct command *cmd,
/* Only attempt to connect if the plugin has configured the rpc_conn
* already, if that's not the case we were told to run without an RPC
* connection, so don't even log an error. */
- /* FIXME: Move this to its own function so we can initialize at a
- * later point in time. */
if (p->rpc_conn != NULL) {
- p->rpc_conn->fd = socket(AF_UNIX, SOCK_STREAM, 0);
- if (strlen(p->rpc_location) + 1 > sizeof(addr.sun_path))
- plugin_err(p, "rpc filename '%s' too long",
- p->rpc_location);
- strcpy(addr.sun_path, p->rpc_location);
- addr.sun_family = AF_UNIX;
-
- if (connect(p->rpc_conn->fd, (struct sockaddr *)&addr,
- sizeof(addr)) != 0) {
+ p->rpc_conn->fd = rpc_open(p);
+ if (p->rpc_conn->fd == -1)
with_rpc = false;
- plugin_log(p, LOG_UNUSUAL,
- "Could not connect to '%s': %s",
- p->rpc_location, strerror(errno));
- }
+ else
+ with_rpc = true;
membuf_init(&p->rpc_conn->mb, tal_arr(p, char, READ_CHUNKSIZE),
READ_CHUNKSIZE, membuf_tal_realloc);
-
- }
+ } else
+ with_rpc = false;
opttok = json_get_member(buf, params, "options");
json_for_each_obj(i, t, opttok) {
@@ -1633,6 +1657,9 @@ static struct command_result *handle_init(struct command *cmd,
get_beglist, plugin_broken_cb, NULL);
json_add_string(req->js, "config", "i-promise-to-fix-broken-api-user");
send_outreq(req);
+
+ /* We will open a new one if we want to be sync. */
+ p->rpc_conn->fd = -1;
}
return command_success(cmd, json_out_obj(cmd, NULL, NULL));
diff --git a/plugins/libplugin.h b/plugins/libplugin.h
index 80261ab2..4e3716a1 100644
--- a/plugins/libplugin.h
+++ b/plugins/libplugin.h
@@ -445,24 +445,24 @@ bool command_deprecated_ok_flag(const struct command *cmd)
#define notification_handler_pending(cmd) command_still_pending(cmd)
/* Synchronous helper to send command and extract fields from
- * response; can only be used in init callback. */
-void rpc_scan(struct command *init_cmd,
+ * response. */
+void rpc_scan(struct command *cmd,
const char *method,
const struct json_out *params TAKES,
const char *guide,
...);
-/* Helper to scan datastore: can only be used in init callback. Returns error
- * msg (usually meaning field does not exist), or NULL on success. path is
- * /-separated. Final arg is JSON_SCAN or JSON_SCAN_TAL.
+/* Helper to scan datastore. Returns error msg (usually meaning field
+ * does not exist), or NULL on success. path is /-separated. Final
+ * arg is JSON_SCAN or JSON_SCAN_TAL.
*/
const char *rpc_scan_datastore_str(const tal_t *ctx,
- struct command *init_cmd,
+ struct command *cmd,
const char *path,
...);
/* This variant scans the hex encoding, not the string */
const char *rpc_scan_datastore_hex(const tal_t *ctx,
- struct command *init_cmd,
+ struct command *cmd,
const char *path,
...);
@@ -706,9 +706,9 @@ void plugin_set_memleak_handler(struct plugin *plugin,
struct htable *memtable));
/* Synchronously call a JSON-RPC method and return its contents and
- * the parser token. */
+ * the parser token. params may be NULL for an empty object. */
const jsmntok_t *jsonrpc_request_sync(const tal_t *ctx,
- struct command *init_cmd,
+ struct command *cmd,
const char *method,
const struct json_out *params TAKES,
const char **resp);
diff --git a/tests/plugins/test_libplugin.c b/tests/plugins/test_libplugin.c
index d6f26961..f73378e6 100644
--- a/tests/plugins/test_libplugin.c
+++ b/tests/plugins/test_libplugin.c
@@ -45,6 +45,8 @@ static struct command_result *json_helloworld(struct command *cmd,
const jsmntok_t *params)
{
const char *name;
+ const char *response_buf;
+ const jsmntok_t *response;
if (!param(cmd, buf, params,
p_opt("name", param_string, &name),
@@ -53,6 +55,12 @@ static struct command_result *json_helloworld(struct command *cmd,
plugin_notify_message(cmd, LOG_INFORM, "Notification from %s", "json_helloworld");
+ response = jsonrpc_request_sync(cmd, cmd, "listpeers", NULL, &response_buf);
+ plugin_log(cmd->plugin, LOG_INFORM, "listpeers gave %zu tokens: %.*s",
+ tal_count(response),
+ json_tok_full_len(response),
+ json_tok_full(response_buf, response));
+
if (!name)
return jsonrpc_get_datastore_binary(cmd,
"test_libplugin/name",
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index 4913d350..b0100547 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -1635,6 +1635,7 @@ def test_libplugin(node_factory):
# Test commands
assert l1.rpc.call("helloworld") == {"hello": "NOT FOUND"}
+ l1.daemon.wait_for_log(r'listpeers gave 3 tokens: {"peers":\[\]}')
l1.daemon.wait_for_log("get_ds_bin_done: 00010203")
l1.daemon.wait_for_log("BROKEN.* Datastore gave nonstring result.*00010203")
assert l1.rpc.call("helloworld", {"name": "test"}) == {"hello": "test"}
Why this scored 26/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.