| Author | Andy Green <andy@warmcat.com> 2026-09-06 07:46 UTC | | Committer | Andy Green <andy@warmcat.com> 2026-09-06 07:46 UTC | | Tree | 27b7cdcfcb6bf72d85356d82d7707872e9c53833 Raw Patch | | | web: fail closed around spoofable x-lws-login-admin, fixes F-009 | web: fail closed around spoofable x-lws-login-admin, fixes F-009
The admin grant at ws establish was derived solely from the
x-lws-login-admin custom header. The deployment contract (front-end
lwsws lws-login interceptor stamps it, sai-web sits on a unix socket)
makes that the interceptor's verdict, but nothing in sai-web enforced
the contract: a peer reaching a listen directly could self-grant admin
by sending the header, and duplicate header names win first-match --
the lws-side anti-spoof zap only removes the first client-supplied
copy, so a client sending two copies gets its value through ahead of
the interceptor's.
saiw_admin_header_trusted() now gates reading the header:
- reject when more than one x-lws-login-admin header is present
(ambiguous at best, spoofing at worst);
- honour the header only when the connection arrived on the unix
socket: SO_PEERCRED succeeds only for connected AF_UNIX peers, so a
TCP-exposed sai-web silently loses admin ops (by design, and noted in
the example conf) instead of handing them to whoever sends the
header. Platforms without SO_PEERCRED keep the duplicate check.
Absent-header behaviour is unchanged: no interceptor, no admin.
|
diff --git a/etc-sai-EXAMPLE/web/conf.d/unixskt b/etc-sai-EXAMPLE/web/conf.d/unixskt
index 348638c..4615307 100644
--- a/etc-sai-EXAMPLE/web/conf.d/unixskt
+++ b/etc-sai-EXAMPLE/web/conf.d/unixskt
@@ -85,6 +85,15 @@
# sai-web then reads x-lws-login-admin:0/1 at WS
# establish. Without the interceptor, sai-web sees no
# header and treats the user as not having admin rights.
+ #
+ # sai-web also fails closed around the header itself:
+ # it is only honoured when exactly one copy is present
+ # (duplicates could be a client-supplied spoof winning
+ # first-match) and the connection arrived on this unix
+ # socket, ie it came through the front-end proxy and
+ # not from something reaching a listen directly. If
+ # you expose sai-web on a TCP vhost, admin ops will
+ # silently stop working by design.
# template HTML to use for this vhost. You'd normally
# copy this to gitohashi-vhostname.html and modify it
diff --git a/src/web/w-comms.c b/src/web/w-comms.c
index c70e8a4..e9739f4 100644
--- a/src/web/w-comms.c
+++ b/src/web/w-comms.c
@@ -33,6 +33,7 @@
#include <time.h>
#include <stdio.h>
#include <fcntl.h>
+#include <sys/socket.h>
#include "w-private.h"
@@ -146,6 +147,71 @@ saiw_close_artifact(struct pss *pss)
}
}
+/*
+ * The admin grant comes from the x-lws-login-admin header the lws-login
+ * interceptor stamps in the front-end proxy. Before honouring it, fail
+ * closed on the ways that header can be anything but the interceptor's
+ * verdict:
+ *
+ * - more than one header of that name is ambiguous: the lws accessors
+ * return the first match, and the interceptor's anti-spoof zap only
+ * removes the first client-supplied copy, so a duplicate can survive
+ * to us with the client's value first. Treat it as a spoof.
+ *
+ * - the connection must have reached us on the unix socket the front-end
+ * proxy connects to (the documented deployment); SO_PEERCRED only
+ * succeeds for AF_UNIX peers. A client that reached a TCP listen
+ * directly could otherwise simply send the header and self-grant.
+ * Where SO_PEERCRED doesn't exist, this check isn't available and we
+ * fall back to the duplicate check alone.
+ */
+static void
+saiw_count_admin_hdr_cb(const char *name, int nlen, void *opaque)
+{
+ int *count = (int *)opaque;
+
+ if (nlen == 18 && !strncmp(name, "x-lws-login-admin:", 18))
+ (*count)++;
+}
+
+static int
+saiw_admin_header_trusted(struct lws *wsi)
+{
+ int count = 0;
+
+ if (lws_hdr_custom_name_foreach(wsi, saiw_count_admin_hdr_cb,
+ &count) || count > 1) {
+ lwsl_wsi_notice(wsi, "%d x-lws-login-admin headers, "
+ "ignoring them", count);
+
+ return 0;
+ }
+
+#if defined(SO_PEERCRED)
+ {
+ /*
+ * We don't need the creds themselves, only whether the
+ * peer is reachable this way: SO_PEERCRED only succeeds
+ * for connected AF_UNIX sockets. (struct ucred itself is
+ * not portable to every libc's feature macro set.)
+ */
+ unsigned char cred[64];
+ socklen_t cl = sizeof(cred);
+
+ if (lws_get_socket_fd(wsi) < 0 ||
+ getsockopt(lws_get_socket_fd(wsi), SOL_SOCKET,
+ SO_PEERCRED, cred, &cl)) {
+ lwsl_wsi_notice(wsi, "x-lws-login-admin ignored: peer "
+ "is not on the unix socket");
+
+ return 0;
+ }
+ }
+#endif
+
+ return 1;
+}
+
static int
w_callback_ws(struct lws *wsi, enum lws_callback_reasons reason, void *user,
void *in, size_t len)
@@ -626,16 +692,25 @@ http_resp:
* headers (x-lws-login-admin: 0/1) which the proxy forwarded
* to us. Read the admin flag; everything else (the action gate
* at w-ws-browser.c) keys off auth_state. If the header is
- * absent (no interceptor configured) we fail closed: not admin.
+ * absent (no interceptor configured) we fail closed: not admin,
+ * and if it could be a client-supplied copy rather than the
+ * interceptor's verdict, we ignore it the same way.
*/
{
char admin[8];
- int a = lws_hdr_custom_copy(wsi, admin, sizeof(admin),
- "x-lws-login-admin:", 18);
+ int a = -1;
+
+ pss->auth_state = SAI_AUTH_STATE_LOGGED_IN_NO_GRANT;
+
+ if (saiw_admin_header_trusted(wsi))
+ a = lws_hdr_custom_copy(wsi, admin,
+ sizeof(admin),
+ "x-lws-login-admin:", 18);
+
+ if (a == 1 && admin[0] == '1')
+ pss->auth_state =
+ SAI_AUTH_STATE_LOGGED_IN_GRANT_ADMIN;
- pss->auth_state = (a == 1 && admin[0] == '1') ?
- SAI_AUTH_STATE_LOGGED_IN_GRANT_ADMIN :
- SAI_AUTH_STATE_LOGGED_IN_NO_GRANT;
lwsl_wsi_notice(wsi, "ESTABLISHED WS: x-lws-login-admin=%c (auth_state=%d)",
a == 1 ? admin[0] : '-', (int)pss->auth_state);
}
|