pyln-testing: check plugin notifications against any extant notification schemas.
What changed, and why it matters
This commit adds a new test-only check that makes Core Lightning's Python test harness validate plugin hook and notification JSON messages against published schemas. It is a testing/quality improvement, not a fix for an exploitable vulnerability in production code. The only runtime behavior change is writing plugin I/O to a directory during tests and then validating it during teardown.
No urgent action required. Treat as a test-framework hardening commit. Reviewers may want to confirm that the deprecated `channel_state_changed.null_scid` behavior is documented and scheduled for removal, and that `dev-save-plugin-io` is guarded so it cannot be enabled in production builds.
Security signals we found
Adds schema validation for plugin notifications/hooks in test harness
References a known non-schema-compliant deprecated notification: channel_state_changed.null_scid
Introduces dev-only option dev-save-plugin-io to persist plugin I/O for validation
No changes to cryptographic, network, or consensus code
Evidence from the diff
The patch introduces checkPluginJSON() in pyln-testing fixtures, which iterates over plugin-io JSON files saved by a new dev-save-plugin-io option and validates them against notification schemas under doc/schemas/notification. It also adds a bad_notifications node flag and a workaround for a known deprecated-API case where channel_state_changed can emit null for short_channel_id. The changes are confined to the test framework and test cases; no production lightningd code is modified.
Changed components
contrib/pyln-testing/pyln/testing/fixtures.pycontrib/pyln-testing/pyln/testing/utils.pytests/test_misc.pyInspect captured patch +70 / −2
diff --git a/contrib/pyln-testing/pyln/testing/fixtures.py b/contrib/pyln-testing/pyln/testing/fixtures.py
index ebc34aa9..215c5402 100644
--- a/contrib/pyln-testing/pyln/testing/fixtures.py
+++ b/contrib/pyln-testing/pyln/testing/fixtures.py
@@ -3,6 +3,7 @@ from pyln.testing.db import SqliteDbProvider, PostgresDbProvider
from pyln.testing.utils import NodeFactory, BitcoinD, ElementsD, env, LightningNode, TEST_DEBUG, TEST_NETWORK
from pyln.client import Millisatoshi
from typing import Dict
+from pathlib import Path
import json
import jsonschema # type: ignore
@@ -496,6 +497,7 @@ def node_factory(request, directory, test_name, bitcoind, executor, db_provider,
map_node_error(nf.nodes, checkBroken, "had BROKEN messages")
map_node_error(nf.nodes, lambda n: not n.allow_warning and n.daemon.is_in_log(r' WARNING:'), "had warning messages")
map_node_error(nf.nodes, checkReconnect, "had unexpected reconnections")
+ map_node_error(nf.nodes, checkPluginJSON, "had malformed hooks/notifications")
# On any bad gossip complaints, print out every nodes' gossip_store
if map_node_error(nf.nodes, checkBadGossip, "had bad gossip messages"):
@@ -617,6 +619,57 @@ def checkBroken(node):
return 0
+def checkPluginJSON(node):
+ if node.bad_notifications:
+ return 0
+
+ try:
+ notificationfiles = os.listdir('doc/schemas/notification')
+ except FileNotFoundError:
+ notificationfiles = []
+
+ notifications = {}
+ for fname in notificationfiles:
+ if fname.endswith('.json'):
+ base = fname.replace('.json', '')
+ # Request is 0 and Response is 1
+ notifications[base] = _load_schema(os.path.join('doc/schemas/notification', fname))
+
+ # FIXME: add doc/schemas/hook/
+ hooks = {}
+
+ for f in (Path(node.daemon.lightning_dir) / "plugin-io").iterdir():
+ # e.g. hook_in-peer_connected-124567-358
+ io = json.loads(f.read_text())
+ parts = f.name.split('-')
+ if parts[0] == 'hook_in':
+ schema = hooks.get(parts[1])
+ req = io['result']
+ direction = 1
+ elif parts[0] == 'hook_out':
+ schema = hooks.get(parts[1])
+ req = io['params']
+ direction = 0
+ else:
+ assert parts[0] == 'notification_out'
+ schema = notifications.get(parts[1])
+ # The notification is wrapped in an object of its own name.
+ req = io['params'][parts[1]]
+ direction = 1
+
+ # Until v26.09, with channel_state_changed.null_scid, that notification will be non-schema compliant.
+ if f.name.startswith('notification_out-channel_state_changed-') and node.daemon.opts.get('allow-deprecated-apis', True) is True:
+ continue
+
+ if schema is not None:
+ try:
+ schema[direction].validate(req)
+ except jsonschema.exceptions.ValidationError as e:
+ print(f"Failed validating {f}: {e}")
+ return 1
+ return 0
+
+
def checkBadReestablish(node):
if node.daemon.is_in_log('Bad reestablish'):
return 1
diff --git a/contrib/pyln-testing/pyln/testing/utils.py b/contrib/pyln-testing/pyln/testing/utils.py
index caa03513..2317c96c 100644
--- a/contrib/pyln-testing/pyln/testing/utils.py
+++ b/contrib/pyln-testing/pyln/testing/utils.py
@@ -789,11 +789,13 @@ class LightningNode(object):
jsonschemas={},
valgrind_plugins=True,
executable=None,
+ bad_notifications=False,
**kwargs):
self.bitcoin = bitcoind
self.executor = executor
self.may_fail = may_fail
self.may_reconnect = may_reconnect
+ self.bad_notifications = bad_notifications
self.broken_log = broken_log
self.allow_bad_gossip = allow_bad_gossip
self.allow_warning = allow_warning
@@ -853,6 +855,10 @@ class LightningNode(object):
if self.cln_version >= "v24.11":
self.daemon.opts.update({"autoconnect-seeker-peers": 0})
+ jsondir = Path(lightning_dir) / "plugin-io"
+ jsondir.mkdir()
+ self.daemon.opts['dev-save-plugin-io'] = jsondir
+
if options is not None:
self.daemon.opts.update(options)
dsn = db.get_dsn()
@@ -1601,6 +1607,7 @@ class NodeFactory(object):
'broken_log',
'allow_warning',
'may_reconnect',
+ 'bad_notifications',
'random_hsm',
'feerates',
'wait_for_bitcoind_sync',
diff --git a/tests/test_misc.py b/tests/test_misc.py
index 81e308fd..8a4509d2 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -2723,6 +2723,9 @@ def test_new_node_is_mainnet(node_factory):
assert not os.path.isfile(os.path.join(netdir, "lightningd-bitcoin.pid"))
assert os.path.isfile(os.path.join(basedir, "lightningd-bitcoin.pid"))
+ # Teardown expects this to exist...
+ os.mkdir(basedir + "/plugin-io")
+
def test_unicode_rpc(node_factory, executor, bitcoind):
node = node_factory.get_node()
@@ -3659,7 +3662,8 @@ def test_version_reexec(node_factory, bitcoind):
# We use a file to tell our openingd wrapper where the real one is
with open(os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "openingd-real"), 'w') as f:
f.write(os.path.abspath('lightningd/lightning_openingd'))
-
+ # Internal restart doesn't work well with --dev-save-plugin-io
+ del l1.daemon.opts['dev-save-plugin-io']
l1.start()
# This is a "version" message
verfile = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "openingd-version")
@@ -4534,7 +4538,11 @@ def test_setconfig_changed(node_factory, bitcoind):
@unittest.skipIf(os.getenv('TEST_DB_PROVIDER', 'sqlite3') != 'sqlite3', "deletes database, which is assumed sqlite3")
def test_recover_command(node_factory, bitcoind):
- l1, l2 = node_factory.get_nodes(2)
+ l1 = node_factory.get_node(start=False)
+ # Internal restart doesn't work well with --dev-save-plugin-io
+ del l1.daemon.opts['dev-save-plugin-io']
+ l1.start()
+ l2 = node_factory.get_node()
l1oldid = l1.info['id']
Why this scored 19/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.