lightningd: support "filters" in plugins manifest to restrict when hooks are called.
What changed, and why it matters
This commit adds a new plugin feature called 'filters' that lets plugin authors tell Core Lightning to only call their plugin's hook under certain conditions. For example, a plugin can now say 'only call me for pay commands' on the rpc_command hook. The change is a feature addition, not a bug fix, and there is no direct evidence in the commit that it addresses a security vulnerability.
Treat as a normal feature commit. Review the follow-up usage of filters on rpc_command to ensure the filter field is correctly populated and cannot be bypassed by a caller. No immediate security action is required based solely on this commit.
Security signals we found
New hook filtering mechanism reduces hook invocation surface, which can limit unintended plugin behavior
Validation added for filter type and array contents (strings or unsigned integers)
No memory safety fixes, bounds checks beyond parsing, or privilege changes visible
No vendor disclosure or CVE references present
Evidence from the diff
The commit extends the plugin manifest parsing to accept an optional ‘filters’ array per hook, validates that filters match the hook’s declared filter type (none, string array, or unsigned integer array), stores the filters in the hook instance, and skips calling a plugin hook when the runtime filter field does not match any configured filter. It introduces new macros REGISTER_PLUGIN_HOOK_STRFILTER and REGISTER_PLUGIN_HOOK_INTFILTER, and updates the plugin_hook_call_ signature to accept a string and integer filter field. Existing hooks without filters continue to work unchanged. The commit message frames this as groundwork for xpay to filter rpc_command on ‘pay’ commands.
Changed components
lightningd/plugin.clightningd/plugin_hook.clightningd/plugin_hook.hlightningd/test/run-find_my_abspath.clightningd/test/run-invoice-select-inchan.clightningd/test/run-jsonrpc.cwallet/test/run-wallet.cInspect captured patch +213 / −34
diff --git a/lightningd/plugin.c b/lightningd/plugin.c
index e18fbb63..54d4b060 100644
--- a/lightningd/plugin.c
+++ b/lightningd/plugin.c
@@ -1472,7 +1472,7 @@ static const char *plugin_subscriptions_add(struct plugin *plugin,
static const char *plugin_hooks_add(struct plugin *plugin, const char *buffer,
const jsmntok_t *resulttok)
{
- const jsmntok_t *t, *hookstok, *beforetok, *aftertok;
+ const jsmntok_t *t, *hookstok, *beforetok, *aftertok, *filterstok;
size_t i;
hookstok = json_get_member(buffer, resulttok, "hooks");
@@ -1482,6 +1482,7 @@ static const char *plugin_hooks_add(struct plugin *plugin, const char *buffer,
json_for_each_arr(i, t, hookstok) {
char *name;
struct plugin_hook *hook;
+ const char *err;
if (t->type == JSMN_OBJECT) {
const jsmntok_t *nametok;
@@ -1494,21 +1495,16 @@ static const char *plugin_hooks_add(struct plugin *plugin, const char *buffer,
name = json_strdup(tmpctx, buffer, nametok);
beforetok = json_get_member(buffer, t, "before");
aftertok = json_get_member(buffer, t, "after");
+ filterstok = json_get_member(buffer, t, "filters");
} else {
/* FIXME: deprecate in 3 releases after v0.9.2! */
name = json_strdup(tmpctx, buffer, t);
- beforetok = aftertok = NULL;
- }
-
- hook = plugin_hook_register(plugin, name);
- if (!hook) {
- return tal_fmt(plugin,
- "could not register hook '%s', either the "
- "name doesn't exist or another plugin "
- "already registered it.",
- name);
+ beforetok = aftertok = filterstok = NULL;
}
+ err = plugin_hook_register(plugin, name, buffer, filterstok, &hook);
+ if (err)
+ return err;
plugin_hook_add_deps(hook, plugin, buffer, beforetok, aftertok);
tal_free(name);
}
diff --git a/lightningd/plugin_hook.c b/lightningd/plugin_hook.c
index b80040ea..5c561e1f 100644
--- a/lightningd/plugin_hook.c
+++ b/lightningd/plugin_hook.c
@@ -1,5 +1,6 @@
#include "config.h"
#include <ccan/io/io.h>
+#include <ccan/tal/str/str.h>
#include <common/memleak.h>
#include <db/exec.h>
#include <db/utils.h>
@@ -15,6 +16,10 @@ struct plugin_hook_request {
struct db *db;
struct lightningd *ld;
+ /* Only one of these can be non-NULL */
+ const char *strfilterfield;
+ u64 intfilterfield;
+
/* Where are we up to in the hook->hooks[] array */
size_t hook_index;
};
@@ -25,6 +30,10 @@ struct hook_instance {
/* Dependencies it asked for. */
const char **before, **after;
+
+ /* Optional filter fields. */
+ const char **strfilters;
+ const u64 *intfilters;
};
static struct plugin_hook **get_hooks(size_t *num)
@@ -72,15 +81,101 @@ static void destroy_hook_instance(struct hook_instance *h,
remove_hook_instance(h, hook->new_hooks);
}
-struct plugin_hook *plugin_hook_register(struct plugin *plugin, const char *method)
+/* Filters in an array of strings */
+static const char *parse_str_filters(const tal_t *ctx,
+ const char *buffer,
+ const jsmntok_t *filterstok,
+ const char ***filters)
{
- struct hook_instance *h;
- struct plugin_hook *hook = plugin_hook_by_name(method);
- if (!hook) {
- /* No such hook name registered */
+ size_t i;
+ const jsmntok_t *t;
+
+ if (!filterstok) {
+ *filters = NULL;
+ return NULL;
+ }
+
+ if (filterstok->type != JSMN_ARRAY)
+ return tal_fmt(ctx, "filters token must be an array");
+
+ *filters = tal_arr(ctx, const char *, filterstok->size);
+ json_for_each_arr(i, t, filterstok) {
+ if (t->type != JSMN_STRING)
+ return tal_fmt(ctx, "filters must be array of strings, not '%.*s'",
+ json_tok_full_len(t),
+ json_tok_full(buffer, t));
+ (*filters)[i] = json_strdup(*filters, buffer, t);
+ }
+ return NULL;
+}
+
+/* Filters in an array of ints */
+static const char *parse_int_filters(const tal_t *ctx,
+ const char *buffer,
+ const jsmntok_t *filterstok,
+ u64 **filters)
+{
+ size_t i;
+ const jsmntok_t *t;
+
+ if (!filterstok) {
+ *filters = NULL;
return NULL;
}
+ if (filterstok->type != JSMN_ARRAY)
+ return tal_fmt(ctx, "filters token must be an array");
+
+ *filters = tal_arr(ctx, u64, filterstok->size);
+ json_for_each_arr(i, t, filterstok) {
+ if (!json_to_u64(buffer, t, &(*filters)[i]))
+ return tal_fmt(ctx, "filters must be array of unsigned integers, not '%.*s'",
+ json_tok_full_len(t),
+ json_tok_full(buffer, t));
+ }
+ return NULL;
+}
+
+const char *plugin_hook_register(struct plugin *plugin,
+ const char *method,
+ const char *buf, const jsmntok_t *filterstok,
+ struct plugin_hook **plugin_hook)
+{
+ struct hook_instance *h;
+ struct plugin_hook *hook;
+ const char *err;
+ const char **strfilters;
+ u64 *intfilters;
+
+ hook = plugin_hook_by_name(method);
+ if (!hook)
+ return tal_fmt(plugin, "Unknown hook name %s", method);
+
+ switch (hook->filter_type) {
+ case JSMN_UNDEFINED:
+ if (filterstok)
+ return tal_fmt(plugin, "Hook %s does not allow filters", method);
+ intfilters = NULL;
+ strfilters = NULL;
+ break;
+ case JSMN_PRIMITIVE:
+ strfilters = NULL;
+ err = parse_int_filters(plugin, buf, filterstok, &intfilters);
+ if (err)
+ return err;
+ break;
+ case JSMN_STRING:
+ intfilters = NULL;
+ err = parse_str_filters(plugin, buf, filterstok, &strfilters);
+ if (err)
+ return err;
+ break;
+
+ /* Nothing else is valid (yet?) */
+ default:
+ abort();
+ }
+
/* Make sure the hook_elements array is initialized. */
if (hook->hooks == NULL) {
hook->hooks = notleak(tal_arr(NULL, struct hook_instance *, 0));
@@ -93,7 +188,8 @@ struct plugin_hook *plugin_hook_register(struct plugin *plugin, const char *meth
if (!hook->hooks[i])
continue;
if (hook->hooks[i]->plugin == plugin)
- return NULL;
+ return tal_fmt(plugin, "Registered for hook %s multiple times",
+ method);
}
/* Ok, we're sure they can register and they aren't yet registered, so
@@ -102,14 +198,17 @@ struct plugin_hook *plugin_hook_register(struct plugin *plugin, const char *meth
h->plugin = plugin;
h->before = tal_arr(h, const char *, 0);
h->after = tal_arr(h, const char *, 0);
+ h->strfilters = tal_steal(h, strfilters);
+ h->intfilters = tal_steal(h, intfilters);
tal_add_destructor2(h, destroy_hook_instance, hook);
tal_arr_expand(&hook->hooks, h);
- return hook;
+ *plugin_hook = hook;
+ return NULL;
}
/* Mutual recursion */
-static void plugin_hook_call_next(struct plugin_hook_request *ph_req);
+static bool plugin_hook_call_next(struct plugin_hook_request *ph_req);
static void plugin_hook_callback(const char *buffer, const jsmntok_t *toks,
const jsmntok_t *idtok,
struct plugin_hook_request *r);
@@ -200,7 +299,38 @@ static void plugin_hook_callback(const char *buffer, const jsmntok_t *toks,
plugin_hook_call_next(ph_req);
}
-static void plugin_hook_call_next(struct plugin_hook_request *ph_req)
+static bool hook_callable(const struct hook_instance *hook,
+ const char *strfilterfield,
+ u64 intfilterfield)
+{
+ /* NULL? Skip */
+ if (!hook)
+ return false;
+
+ /* String filters? If there are some we must match one. */
+ if (hook->strfilters) {
+ for (size_t i = 0; i < tal_count(hook->strfilters); i++) {
+ if (streq(strfilterfield, hook->strfilters[i]))
+ return true;
+ }
+ return false;
+ }
+
+ /* Integer filters? */
+ if (hook->intfilters) {
+ for (size_t i = 0; i < tal_count(hook->intfilters); i++) {
+ if (intfilterfield == hook->intfilters[i])
+ return true;
+ }
+ return false;
+ }
+
+ /* No filters: always call. */
+ return true;
+}
+
+/* Returns true if it finished all the hooks (and thus didn't call anything) */
+static bool plugin_hook_call_next(struct plugin_hook_request *ph_req)
{
struct jsonrpc_request *req;
const struct plugin_hook *hook = ph_req->hook;
@@ -212,9 +342,11 @@ static void plugin_hook_call_next(struct plugin_hook_request *ph_req)
if (ph_req->hook_index >= tal_count(hook->hooks)) {
hook_done(ph_req->ld, ph_req->hook, ph_req->cb_arg);
tal_free(ph_req);
- return;
+ return true;
}
- } while (hook->hooks[ph_req->hook_index] == NULL);
+ } while (!hook_callable(hook->hooks[ph_req->hook_index],
+ ph_req->strfilterfield,
+ ph_req->intfilterfield));
plugin = hook->hooks[ph_req->hook_index]->plugin;
log_trace(ph_req->ld->log, "Calling %s hook of plugin %s",
@@ -232,9 +364,13 @@ static void plugin_hook_call_next(struct plugin_hook_request *ph_req)
req->stream);
plugin_request_send(plugin, req);
+ return false;
}
-bool plugin_hook_call_(struct lightningd *ld, struct plugin_hook *hook,
+bool plugin_hook_call_(struct lightningd *ld,
+ struct plugin_hook *hook,
+ const char *strfilterfield TAKES,
+ u64 intfilterfield,
const char *cmd_id TAKES,
tal_t *cb_arg STEALS)
{
@@ -254,8 +390,9 @@ bool plugin_hook_call_(struct lightningd *ld, struct plugin_hook *hook,
ph_req->ld = ld;
ph_req->cmd_id = tal_strdup_or_null(ph_req, cmd_id);
ph_req->hook_index = -1;
- plugin_hook_call_next(ph_req);
- return false;
+ ph_req->strfilterfield = tal_strdup_or_null(ph_req, strfilterfield);
+ ph_req->intfilterfield = intfilterfield;
+ return plugin_hook_call_next(ph_req);
} else {
/* If no plugin has registered for this hook, just
* call the callback with a NULL result. Saves us the
diff --git a/lightningd/plugin_hook.h b/lightningd/plugin_hook.h
index b1f108a3..096768d2 100644
--- a/lightningd/plugin_hook.h
+++ b/lightningd/plugin_hook.h
@@ -2,6 +2,7 @@
#define LIGHTNING_LIGHTNINGD_PLUGIN_HOOK_H
#include "config.h"
+#include <common/json_parse_simple.h>
#include <lightningd/plugin.h>
/**
@@ -47,6 +48,9 @@ struct plugin_hook {
void (*serialize_payload)(void *src, struct json_stream *dest,
struct plugin *plugin);
+ /* Type of filters we allow (JSMN_UNDEFINED means none) */
+ jsmntype_t filter_type;
+
/* Which plugins have registered this hook? This is a `tal_arr`
* initialized at creation. */
struct hook_instance **hooks;
@@ -68,6 +72,8 @@ AUTODATA_TYPE(hooks, struct plugin_hook);
*/
bool plugin_hook_call_(struct lightningd *ld,
struct plugin_hook *hook,
+ const char *strfilterfield TAKES,
+ u64 intfilterfield,
const char *cmd_id TAKES,
tal_t *cb_arg STEALS);
@@ -80,11 +86,25 @@ bool plugin_hook_continue(void *arg, const char *buffer, const jsmntok_t *toks);
* the method-name is correct for the call.
*/
/* FIXME: Find a way to avoid back-to-back declaration and definition */
-#define PLUGIN_HOOK_CALL_DEF(name, cb_arg_type) \
+#define PLUGIN_HOOK_CALL_DEF_NOFILTER(name, cb_arg_type) \
UNNEEDED static inline bool plugin_hook_call_##name( \
struct lightningd *ld, const char *cmd_id TAKES, cb_arg_type cb_arg STEALS) \
{ \
- return plugin_hook_call_(ld, &name##_hook_gen, cmd_id, cb_arg); \
+ return plugin_hook_call_(ld, &name##_hook_gen, NULL, 0, cmd_id, cb_arg); \
+ }
+
+#define PLUGIN_HOOK_CALL_DEF_STRFILTER(name, cb_arg_type) \
+ UNNEEDED static inline bool plugin_hook_call_##name( \
+ struct lightningd *ld, const char *strfilterfield, const char *cmd_id TAKES, cb_arg_type cb_arg STEALS) \
+ { \
+ return plugin_hook_call_(ld, &name##_hook_gen, strfilterfield, 0, cmd_id, cb_arg); \
+ }
+
+#define PLUGIN_HOOK_CALL_DEF_INTFILTER(name, cb_arg_type) \
+ UNNEEDED static inline bool plugin_hook_call_##name( \
+ struct lightningd *ld, u64 intfilterfield, const char *cmd_id TAKES, cb_arg_type cb_arg STEALS) \
+ { \
+ return plugin_hook_call_(ld, &name##_hook_gen, NULL, intfilterfield, cmd_id, cb_arg); \
}
/* Typechecked registration of a plugin hook. We check that the
@@ -95,7 +115,7 @@ bool plugin_hook_continue(void *arg, const char *buffer, const jsmntok_t *toks);
* response_cb function accepts the deserialized response format and
* an arbitrary extra argument used to maintain context.
*/
-#define REGISTER_PLUGIN_HOOK(name, deserialize_cb, final_cb, \
+#define REGISTER_PLUGIN_HOOK2(name, filter_type, deserialize_cb, final_cb, \
serialize_payload, cb_arg_type) \
struct plugin_hook name##_hook_gen = { \
stringify(name), \
@@ -109,13 +129,31 @@ bool plugin_hook_continue(void *arg, const char *buffer, const jsmntok_t *toks);
void (*)(void *, struct json_stream *, struct plugin *), \
void (*)(cb_arg_type, struct json_stream *, struct plugin *), \
serialize_payload), \
- NULL, /* .plugins */ \
+ (filter_type), \
+ NULL, /* .plugins */ \
}; \
- AUTODATA(hooks, &name##_hook_gen); \
- PLUGIN_HOOK_CALL_DEF(name, cb_arg_type)
-
-struct plugin_hook *plugin_hook_register(struct plugin *plugin,
- const char *method);
+ AUTODATA(hooks, &name##_hook_gen)
+
+#define REGISTER_PLUGIN_HOOK(name, deserialize_cb, final_cb, \
+ serialize_payload, cb_arg_type) \
+ REGISTER_PLUGIN_HOOK2(name, JSMN_UNDEFINED, deserialize_cb, final_cb, serialize_payload, cb_arg_type); \
+ PLUGIN_HOOK_CALL_DEF_NOFILTER(name, cb_arg_type)
+
+#define REGISTER_PLUGIN_HOOK_STRFILTER(name, deserialize_cb, final_cb, \
+ serialize_payload, cb_arg_type) \
+ REGISTER_PLUGIN_HOOK2(name, JSMN_STRING, deserialize_cb, final_cb, serialize_payload, cb_arg_type) \
+ PLUGIN_HOOK_CALL_DEF_STRFILTER(name, cb_arg_type)
+
+#define REGISTER_PLUGIN_HOOK_INTFILTER(name, deserialize_cb, final_cb, \
+ serialize_payload, cb_arg_type) \
+ REGISTER_PLUGIN_HOOK2(name, JSMN_PRIMITIVE, deserialize_cb, final_cb, serialize_payload, cb_arg_type) \
+ PLUGIN_HOOK_CALL_DEF_INTFILTER(name, cb_arg_type)
+
+/* Returns the error, or NULL and populates *plugin_hook */
+const char *plugin_hook_register(struct plugin *plugin,
+ const char *method,
+ const char *buf, const jsmntok_t *filterstok,
+ struct plugin_hook **plugin_hook);
/* Special sync plugin hook for db. */
void plugin_hook_db_sync(struct db *db);
diff --git a/lightningd/test/run-find_my_abspath.c b/lightningd/test/run-find_my_abspath.c
index b3c86203..80c2a42c 100644
--- a/lightningd/test/run-find_my_abspath.c
+++ b/lightningd/test/run-find_my_abspath.c
@@ -164,6 +164,8 @@ void onchaind_replay_channels(struct lightningd *ld UNNEEDED)
/* Generated stub for plugin_hook_call_ */
bool plugin_hook_call_(struct lightningd *ld UNNEEDED,
struct plugin_hook *hook UNNEEDED,
+ const char *strfilterfield TAKES UNNEEDED,
+ u64 intfilterfield UNNEEDED,
const char *cmd_id TAKES UNNEEDED,
tal_t *cb_arg STEALS UNNEEDED)
{ fprintf(stderr, "plugin_hook_call_ called!\n"); abort(); }
diff --git a/lightningd/test/run-invoice-select-inchan.c b/lightningd/test/run-invoice-select-inchan.c
index 5b65c29d..97bee8f7 100644
--- a/lightningd/test/run-invoice-select-inchan.c
+++ b/lightningd/test/run-invoice-select-inchan.c
@@ -584,6 +584,8 @@ bool peer_start_openingd(struct peer *peer UNNEEDED,
/* Generated stub for plugin_hook_call_ */
bool plugin_hook_call_(struct lightningd *ld UNNEEDED,
struct plugin_hook *hook UNNEEDED,
+ const char *strfilterfield TAKES UNNEEDED,
+ u64 intfilterfield UNNEEDED,
const char *cmd_id TAKES UNNEEDED,
tal_t *cb_arg STEALS UNNEEDED)
{ fprintf(stderr, "plugin_hook_call_ called!\n"); abort(); }
diff --git a/lightningd/test/run-jsonrpc.c b/lightningd/test/run-jsonrpc.c
index 214b2003..c28ca212 100644
--- a/lightningd/test/run-jsonrpc.c
+++ b/lightningd/test/run-jsonrpc.c
@@ -82,6 +82,8 @@ u32 penalty_feerate(struct chain_topology *topo UNNEEDED)
/* Generated stub for plugin_hook_call_ */
bool plugin_hook_call_(struct lightningd *ld UNNEEDED,
struct plugin_hook *hook UNNEEDED,
+ const char *strfilterfield TAKES UNNEEDED,
+ u64 intfilterfield UNNEEDED,
const char *cmd_id TAKES UNNEEDED,
tal_t *cb_arg STEALS UNNEEDED)
{ fprintf(stderr, "plugin_hook_call_ called!\n"); abort(); }
diff --git a/wallet/test/run-wallet.c b/wallet/test/run-wallet.c
index b434064b..74e02307 100644
--- a/wallet/test/run-wallet.c
+++ b/wallet/test/run-wallet.c
@@ -605,6 +605,8 @@ bool peer_start_openingd(struct peer *peer UNNEEDED,
/* Generated stub for plugin_hook_call_ */
bool plugin_hook_call_(struct lightningd *ld UNNEEDED,
struct plugin_hook *hook UNNEEDED,
+ const char *strfilterfield TAKES UNNEEDED,
+ u64 intfilterfield UNNEEDED,
const char *cmd_id TAKES UNNEEDED,
tal_t *cb_arg STEALS UNNEEDED)
{ fprintf(stderr, "plugin_hook_call_ called!\n"); abort(); }
Why this scored 30/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.