Project homepage Mailing List  Warmcat.com  API Docs  Github Mirror 
    npro  
 Modern all-safe Rust Network Protocol library supporting h1, h2, h3, ws, wt sans-IO and with socket IO + tls
git clone https://npro.rs/repo/npro
 
root / scripts / sai-virt.service
Author[]Andy Green <andy@warmcat.com> 2026-09-06 07:46 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-09-06 07:46 UTC
Tree27b7cdcfcb6bf72d85356d82d7707872e9c53833   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); }
Page fetched 0s ago, creation time: 2ms (vhost etag hits: 0%, cache hits: 0%)