| Author | Andy Green <andy@warmcat.com> 2026-09-18 18:52 UTC | | Committer | Andy Green <andy@warmcat.com> 2026-09-21 07:06 UTC | | Tree | 5c18dab4841f14e0dcbb1acb09a6730588d26ee9 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 *
|