| Author | Andy Green <andy@warmcat.com> 2026-09-18 19:10 UTC | | Committer | Andy Green <andy@warmcat.com> 2026-09-21 07:06 UTC | | Tree | 461583a5032f16cb9664fcf429dedda9d5c87567 Raw Patch | | | power: authenticate builder registration with the link secret, fixes F-022 | power: authenticate builder registration with the link secret, fixes F-022
sai-power's LAN-facing registration endpoint accepted
com.warmcat.sai.builder_registration from anyone who could reach its
listen port: unknown power_controller_name values dynamically created
PCONs, and the power_on/off/monitor URLs from the message were copied
into the PCON and became ss client targets fetched from the
sai-power host. A LAN peer could thus register itself onto someone
else's PCON, retarget builder->PCON mappings, and point power events at
attacker-chosen or internal URLs (SSRF from the power host's network
position, plus a tracking beacon).
Builders now prove the fleet-wide link secret in the registration
message ("secret" member, the same link-key conf sai-server
authenticates builder connections with), compared in constant time
before any PCON mutation or persistence; sai-power fails closed when
no link-key is configured. The secret is deliberately not part of the
sticky-registration sqlite map, so it is not written at rest.
Two residuals recorded against the finding rather than fixed here:
the power-action HTTP paths (/power-on/..., /stay/... GETs) are still
unauthenticated LAN endpoints, and PCON URLs still come from the wire
of (now authenticated) builders since sai-power has no conf-defined
PCON URL path to move them to.
|
diff --git a/etc-sai-EXAMPLE/builder/conf b/etc-sai-EXAMPLE/builder/conf
index 4f9ee48..5a95e47 100644
--- a/etc-sai-EXAMPLE/builder/conf
+++ b/etc-sai-EXAMPLE/builder/conf
@@ -4,7 +4,8 @@
"host": "bionic-noi",
# the fleet-wide secret sai-server's "link-key" pvo also has;
- # the server refuses our connection without it
+ # the server refuses our connection without it, and it is
+ # presented to sai-power at registration
#
"link-key": "34c8e17b90d2a6f5c1e8430b76af2915e0d4cbf68a27e19dd5b0f3c6e48a71d2",
diff --git a/etc-sai-EXAMPLE/power/conf b/etc-sai-EXAMPLE/power/conf
index bc4947c..157508f 100644
--- a/etc-sai-EXAMPLE/power/conf
+++ b/etc-sai-EXAMPLE/power/conf
@@ -2,7 +2,8 @@
"perms": "sai:nobody",
# the fleet-wide secret sai-server's "link-key" pvo also has;
- # the server refuses our connection without it
+ # the server refuses our connection without it, and builders
+ # must present it to register with us
#
"link-key": "34c8e17b90d2a6f5c1e8430b76af2915e0d4cbf68a27e19dd5b0f3c6e48a71d2",
diff --git a/src/builder/b-power.c b/src/builder/b-power.c
index e31d774..16f8ea8 100644
--- a/src/builder/b-power.c
+++ b/src/builder/b-power.c
@@ -109,6 +109,17 @@ saib_power_client_state(void *userobj, void *sh, lws_ss_constate_t state,
if (builder.power_monitor_url)
lws_strncpy(r.power_monitor_url, builder.power_monitor_url, sizeof(r.power_monitor_url));
+ /*
+ * sai-power refuses the registration without the fleet link
+ * secret
+ */
+ if (!builder.link_key)
+ lwsl_err("%s: no link-key in conf, sai-power will "
+ "refuse our registration\n", __func__);
+ else
+ lws_strncpy(r.secret, builder.link_key,
+ sizeof(r.secret));
+
/* Add platforms */
lws_start_foreach_dll(struct lws_dll2 *, d, builder.sai_plat_owner.head) {
sai_plat_t *sp = lws_container_of(d, sai_plat_t, sai_plat_list);
diff --git a/src/common/include/private.h b/src/common/include/private.h
index 8dc7d8e..ff6ba46 100644
--- a/src/common/include/private.h
+++ b/src/common/include/private.h
@@ -313,6 +313,9 @@ struct sai_nspawn {
lws_sorted_usec_list_t sul_mirror;
lws_sorted_usec_list_t sul_task_cancel;
+ /* builder: sai_artifact_t of uploads still in flight for this ns */
+ lws_dll2_owner_t artifact_owner;
+
sai_plat_t *sp; /* the sai_plat */
struct sai_plat_server *spm; /* the sai plat / server with the ss / wsi */
@@ -345,6 +348,7 @@ struct sai_nspawn {
uint8_t user_cancel:1;
uint8_t user_killed:1;
uint8_t reap_cb_called:1;
+ uint8_t destroying:1;
};
/*
@@ -870,6 +874,7 @@ typedef struct sai_builder_registration {
char power_off_type[16];
char power_off_url[128];
char power_monitor_url[128];
+ char secret[129]; /* fleet link-key (wire only) */
} sai_builder_registration_t;
typedef struct tasmota_data {
@@ -971,7 +976,7 @@ extern const lws_struct_map_t
lsm_build_metric[14],
lsm_plat[14], /* +1 for pcon */
lsm_builder_platform[1],
- lsm_builder_registration[9],
+ lsm_builder_registration[10],
lsm_schema_sq3_map_power_controller[1],
lsm_schema_sq3_map_controlled_builder[1],
lsm_schema_builder_registration[1],
@@ -1038,7 +1043,7 @@ sai_is_safe_ref(const char *s);
/*
* The .sai.json "artifacts" field is repo-controlled and reaches the
* builder as a comma-separated list of globs, possibly with a path part
- * before the first '*', eg "build/*.rpm,*.tar.gz". The builder scans
+ * before the first '*', eg "build/ *.rpm,*.tar.gz". The builder scans
* them under the per-instance build dir and renames what it matches into
* its uploads dir, so a pattern whose path part climbs out of the
* instance dir (a ".." component, an absolute pattern, or a windows
diff --git a/src/common/struct-metadata.c b/src/common/struct-metadata.c
index 92072d3..3d6d73d 100644
--- a/src/common/struct-metadata.c
+++ b/src/common/struct-metadata.c
@@ -478,6 +478,8 @@ const lws_struct_map_t lsm_builder_registration[] = {
LSM_CARRAY(sai_builder_registration_t, power_off_type, "power_off_type"),
LSM_CARRAY(sai_builder_registration_t, power_off_url, "power_off_url"),
LSM_CARRAY(sai_builder_registration_t, power_monitor_url, "power_monitor_url"),
+ /* deliberately not persisted in the sq3 map */
+ LSM_CARRAY(sai_builder_registration_t, secret, "secret"),
};
const lws_struct_map_t lsm_schema_builder_registration[] = {
diff --git a/src/power/p-http-api.c b/src/power/p-http-api.c
index 64ca326..72b9cee 100644
--- a/src/power/p-http-api.c
+++ b/src/power/p-http-api.c
@@ -289,6 +289,25 @@ local_srv_rx(void *userobj, const uint8_t *buf, size_t len, int flags)
if (g->a.top_schema_index == 0) {
sai_builder_registration_t *r = (sai_builder_registration_t *)g->a.dest;
+ /*
+ * Registration creates and retargets PCONs, rebinds
+ * builder->PCON mappings and sets the URLs sai-power will
+ * then fetch from its own network position, so it has to
+ * prove the fleet link secret (the same "link-key" conf
+ * sai-server authenticates builders with) before anything
+ * from it is applied. Fail closed when we have no key
+ * configured, and compare in constant time.
+ */
+ if (!power.link_key ||
+ strlen(r->secret) != strlen(power.link_key) ||
+ lws_timingsafe_bcmp(r->secret, power.link_key,
+ (uint32_t)strlen(power.link_key))) {
+ lwsl_ss_warn(h, "registration without a valid link "
+ "secret, dropping");
+ lwsac_free(&g->a.ac);
+ return LWSSSSRET_DISCONNECT_ME;
+ }
+
lwsl_ss_notice(h, "Registered builder '%s' on pcon '%s'",
r->builder_name, r->power_controller_name);
|