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 / assets / arch-x86_64-amd.svg
Author[]Andy Green <andy@warmcat.com> 2026-09-18 18:52 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-09-21 07:06 UTC
Tree5c18dab4841f14e0dcbb1acb09a6730588d26ee9   Raw Patch
 
server: anchor SAI_WATCH_URL watcher matching to the service host, fixes F-018
server: anchor SAI_WATCH_URL watcher matching to the service host, fixes F-018

A repo build script (or a fake builder) could print

  SAI_WATCH_URL: http://169.254.169.254/latest/meta-data/<match-string>/

and sai-server would store a watcher for it and poll it with an ss
fetch from the server's network position, because the only gate was
strstr(url, s->match) over the whole URL: the operator-configured
match string just had to appear somewhere in it.  The scraped response
lands in metrics_json and is shown in the public results page, so this
is SSRF with a read-back channel.

sais_watcher_url_matches() replaces the substring test at both the
registration and the poll-time service lookup.  The URL must parse as
http(s), its host must be exactly the configured match host or a
subdomain of it (so <match>.attacker.tld, userinfo tricks and path
embedding all fail), and any path in the match must prefix the URL's
path.  Literal private / loopback / link-local / CGNAT / link-local-v6
hosts and localhost-style names are rejected unless the service opts
in with the new allow_private conf member, for genuinely internal
services.  Hostnames that resolve into private ranges cannot be seen
at match time; that residual is noted in the finding.

The registration block was also unreachable as delivered: it sat
behind the " Step " control-line prefix gate added with it, so
watchers never registered at all.  Move it in front of that gate
(keeping the channel-3 requirement), so the documented feature works
through the hardened gate.
diff --git a/READMEs/README-watchers.md b/READMEs/README-watchers.md index fc3c993..c651e4e 100644 --- a/READMEs/README-watchers.md +++ b/READMEs/README-watchers.md @@ -19,7 +19,8 @@ Watchers are defined in JSON files located in `/etc/sai/server/conf.d/`. Each fi | Field | Type | Description | | :--- | :--- | :--- | | `name` | string | Unique name for the service (e.g., "coverity"). Used to find icons in `assets/watchers/<name>/icon.svg`. | -| `match` | string | Substring to match against the reported URL to identify this service. | +| `match` | string | `host[/path]` the reported URL must be on: the URL's host must be this host or a subdomain of it, and the path (if given) must prefix the URL's path. A substring appearing anywhere else in the URL does not match. | +| `allow_private` | number | Optional, default 0. Set to 1 to let the service match URLs on private / loopback / link-local literal hosts (for internal services); by default these are rejected so repo-controlled `SAI_WATCH_URL` lines cannot make the server scrape itself or its LAN. | | `rules` | array | List of scraping rules to extract data from HTML. | | `ui` | array | List of rendering rules for the Web UI. | diff --git a/src/common/include/private.h b/src/common/include/private.h index 9836180..1f6fd38 100644 --- a/src/common/include/private.h +++ b/src/common/include/private.h @@ -121,6 +121,7 @@ typedef struct sai_watcher_service { const char *match; const char *icon; const char *auth_token_file; /* optional file with secret token */ + uint8_t allow_private; /* accept private/loopback hosts */ lws_dll2_owner_t rules_owner; /* sai_watcher_rule_t */ lws_dll2_owner_t ui_owner; /* sai_watcher_ui_rule_t */ @@ -974,7 +975,7 @@ extern const lws_struct_map_t lsm_schema_taskclone[1], lsm_watcher_rule[6], lsm_watcher_ui_rule[4], - lsm_watcher_service[6], + lsm_watcher_service[7], lsm_watcher[8], lsm_schema_sq3_map_watcher[1], lsm_schema_json_map_watcher[1], diff --git a/src/common/struct-metadata.c b/src/common/struct-metadata.c index 1ff2d77..92072d3 100644 --- a/src/common/struct-metadata.c +++ b/src/common/struct-metadata.c @@ -556,6 +556,7 @@ const lws_struct_map_t lsm_watcher_service[] = { LSM_STRING_PTR (sai_watcher_service_t, match, "match"), LSM_STRING_PTR (sai_watcher_service_t, icon, "icon"), LSM_STRING_PTR (sai_watcher_service_t, auth_token_file, "auth_token_file"), + LSM_UNSIGNED (sai_watcher_service_t, allow_private, "allow_private"), LSM_LIST (sai_watcher_service_t, rules_owner, sai_watcher_rule_t, list, NULL, lsm_watcher_rule, "rules"), LSM_LIST (sai_watcher_service_t, ui_owner, sai_watcher_ui_rule_t, list, diff --git a/src/server/s-private.h b/src/server/s-private.h index edf6736..48428a8 100644 --- a/src/server/s-private.h +++ b/src/server/s-private.h @@ -300,6 +300,9 @@ sais_buflist_append_bounded(struct lws_buflist **head, const uint8_t *buf, size_t len, size_t cap); int +sais_watcher_url_matches(const sai_watcher_service_t *s, const char *url); + +int saiw_ws_json_tx_browser(struct vhd *vhd, struct pss *pss, uint8_t *buf, size_t bl); int diff --git a/src/server/s-watcher.c b/src/server/s-watcher.c index 7a17b7f..f9f0b0d 100644 --- a/src/server/s-watcher.c +++ b/src/server/s-watcher.c @@ -1,10 +1,171 @@ #include <libwebsockets.h> #include <string.h> +#include <strings.h> #include <time.h> #include <fcntl.h> #include <unistd.h> #include "s-private.h" +/* + * Is this a host that never belongs on the open internet: a literal IP in + * a private / loopback / link-local range, or a local-only name? Used to + * stop repo-influenced watcher urls pointing the server's ss fetch at + * itself or its LAN. Literal addresses are classified exactly by range; + * hostnames can only be checked textually (a name that resolves into a + * private range is not visible until connect time). + */ +static int +host_is_local(const char *host) +{ + unsigned int b[4]; + const char *p = host; + size_t len = strlen(host); + int n = 0; + + /* take it as a dotted-quad IPv4 literal if it has the shape */ + + while (n < 4) { + unsigned int v = 0; + int digits = 0; + + while (*p >= '0' && *p <= '9' && digits < 3) { + v = (v * 10) + (unsigned int)(*p - '0'); + p++; + digits++; + } + if (!digits || v > 255) + break; + b[n++] = v; + if (n < 4) { + if (*p != '.') + break; + p++; + } + } + + if (n == 4 && !*p) { + /* 0/8, 10/8, 127/8, 100.64/10, 169.254/16, 172.16/12, 192.168/16 */ + if (b[0] == 0 || b[0] == 10 || b[0] == 127 || + (b[0] == 172 && (b[1] & 0xf0) == 16) || + (b[0] == 192 && b[1] == 168) || + (b[0] == 169 && b[1] == 254) || + (b[0] == 100 && (b[1] & 0xc0) == 64)) + return 1; + + return 0; + } + + if (strchr(host, ':')) { + /* + * IPv6 literal: ULA fc00::/7, link-local fe80::/10, and + * anything starting with :: (loopback, v4-mapped, ...) + * are not global. Port has already been split off. + */ + if (!strncasecmp(host, "fc", 2) || !strncasecmp(host, "fd", 2) || + !strncasecmp(host, "fe8", 3) || !strncasecmp(host, "fe9", 3) || + !strncasecmp(host, "fea", 3) || !strncasecmp(host, "feb", 3) || + host[0] == ':') + return 1; + + return 0; + } + + if (!strcasecmp(host, "localhost") || + (len > 10 && !strcasecmp(host + len - 10, ".localhost")) || + (len > 6 && !strcasecmp(host + len - 6, ".local"))) + return 1; + + return 0; +} + +/* + * Decide if a builder-reported SAI_WATCH_URL url really belongs to the + * configured service s. The url comes from build output of whatever repo + * was CI'd, so this is the gate that stops the watcher poller becoming an + * SSRF with a read-back channel into the public results page. + * + * The url must be http(s) on a non-local host (unless the service opts in + * with allow_private), its host must be exactly the configured match host + * or a subdomain of it, and any path in the match must prefix the url's + * path. It is not enough for the match string to appear somewhere in the + * url: that let eg http://169.254.169.254/latest/meta-data/<match>/ pass. + */ +int +sais_watcher_url_matches(const sai_watcher_service_t *s, const char *url) +{ + char mhost[160]; + const char *m = s->match, *slash; + lws_parse_uri_t *u; + size_t ml, hl; + int ret = 0; + + if (!m || !m[0]) + return 0; + + u = lws_parse_uri_create(url); + if (!u) + return 0; + + do { + if (strcmp(u->scheme, "http") && strcmp(u->scheme, "https")) + break; + + if (u->unix_skt) + break; + + if (!s->allow_private && host_is_local(u->host)) + break; + + /* the match is host[/path], tolerate a scheme on it */ + + if (!strncmp(m, "https://", 8)) + m += 8; + else if (!strncmp(m, "http://", 7)) + m += 7; + + slash = strchr(m, '/'); + ml = slash ? (size_t)(slash - m) : strlen(m); + hl = strlen(u->host); + + if (!ml || ml >= sizeof(mhost) || hl < ml) + break; + + memcpy(mhost, m, ml); + mhost[ml] = '\0'; + + /* + * Host must BE the configured host or a subdomain of it: + * <match>.attacker.tld and <user>@ style tricks fail this + */ + if (strcasecmp(u->host, mhost) && + (hl == ml || u->host[hl - ml - 1] != '.' || + strcasecmp(u->host + hl - ml, mhost))) + break; + + if (slash && u->path) { + const char *up = u->path, *mp = slash; + size_t pl; + + /* parsers differ on keeping the leading '/' */ + + if (*up == '/') + up++; + else + mp++; + + pl = strlen(mp); + if (strncmp(up, mp, pl)) + break; + } + + ret = 1; + } while (0); + + lws_parse_uri_destroy(&u); + + return ret; +} + typedef struct watcher_fetch { struct lws_ss_handle *ss; struct vhd *vhd; @@ -314,7 +475,7 @@ sais_watcher_cb(lws_sorted_usec_list_t *sul) /* Identify service */ lws_start_foreach_dll(struct lws_dll2 *, p, vhd->watcher_services.head) { sai_watcher_service_t *s = lws_container_of(p, sai_watcher_service_t, list); - if (strstr(w->url, s->match)) { + if (sais_watcher_url_matches(s, w->url)) { w->service = s; break; } diff --git a/src/server/s-ws-builder.c b/src/server/s-ws-builder.c index 8637b6d..284ce37 100644 --- a/src/server/s-ws-builder.c +++ b/src/server/s-ws-builder.c @@ -260,28 +260,16 @@ sais_log_to_db(struct vhd *vhd, sai_log_t *log) lws_sul_schedule(vhd->context, 0, &vhd->sul_logcache, sais_dump_logs_to_db, 250 * LWS_US_PER_MS); - if (log->channel != 3 /* control channel */ || - log->len < 5 || memcmp(log->log, " Step ", 5)) + if (log->channel != 3 /* control channel */) return; - step = atoi(&log->log[5]); - - sai_task_uuid_to_event_uuid(event_uuid, log->task_uuid); - - if (sai_event_db_ensure_open(vhd->context, &vhd->sqlite3_cache, - vhd->sqlite3_path_lhs, event_uuid, 0, &pdb)) - return; - - lws_sql_purify(esc_uuid, log->task_uuid, sizeof(esc_uuid)); - - lws_snprintf(q, sizeof(q), - "UPDATE tasks SET build_step=%d WHERE uuid='%s' and run=(select max(run) from tasks where uuid='%s')", - step, esc_uuid, esc_uuid); - - if (sai_sqlite3_statement(pdb, q, "update build_step")) - lwsl_err("%s: failed to update build_step\n", __func__); - - sai_event_db_close(&vhd->sqlite3_cache, &pdb); + /* + * Repo-controlled control-channel line asking us to watch a public + * status url for this task. The url is attacker-influenced, so which + * service (if any) it belongs to is decided by + * sais_watcher_url_matches(): the configured match host must be the + * url's host (or parent of it), not a substring found anywhere in it. + */ if (log->len >= 14 && !memcmp(log->log, "SAI_WATCH_URL:", 14)) { const char *url = log->log + 14; @@ -300,7 +288,7 @@ sais_log_to_db(struct vhd *vhd, sai_log_t *log) /* Identify service immediately to store service_name */ lws_start_foreach_dll(struct lws_dll2 *, p, vhd->watcher_services.head) { sai_watcher_service_t *s = lws_container_of(p, sai_watcher_service_t, list); - if (strstr(w.url, s->match)) { + if (sais_watcher_url_matches(s, w.url)) { lws_strncpy(w.service_name, s->name, sizeof(w.service_name)); break; } @@ -319,11 +307,33 @@ sais_log_to_db(struct vhd *vhd, sai_log_t *log) "REPLACE INTO watchers (service_name, event_hash, task_hash, url, state, created, last_polled, metrics_json) " "VALUES ('%s', '%s', '%s', '%s', %d, %llu, 0, '{}')", esc_svc, esc_event, esc_task, esc_url, w.state, (unsigned long long)w.created); - + if (sai_sqlite3_statement(vhd->server.pdb, q, "insert watcher")) lwsl_err("%s: failed to insert watcher\n", __func__); } } + + if (log->len < 5 || memcmp(log->log, " Step ", 5)) + return; + + step = atoi(&log->log[5]); + + sai_task_uuid_to_event_uuid(event_uuid, log->task_uuid); + + if (sai_event_db_ensure_open(vhd->context, &vhd->sqlite3_cache, + vhd->sqlite3_path_lhs, event_uuid, 0, &pdb)) + return; + + lws_sql_purify(esc_uuid, log->task_uuid, sizeof(esc_uuid)); + + lws_snprintf(q, sizeof(q), + "UPDATE tasks SET build_step=%d WHERE uuid='%s' and run=(select max(run) from tasks where uuid='%s')", + step, esc_uuid, esc_uuid); + + if (sai_sqlite3_statement(pdb, q, "update build_step")) + lwsl_err("%s: failed to update build_step\n", __func__); + + sai_event_db_close(&vhd->sqlite3_cache, &pdb); } sai_plat_t *
Page fetched 0s ago, creation time: 4ms (vhost etag hits: 0%, cache hits: 0%)