Author: Andy Green Date: Fri Sep 18 19:55:50 2026 +0100 builder: keep repo artifact globs inside the instance dir, fixes F-020 The .sai.json artifacts list is repo-controlled end-to-end and was taken with no validation at notification intake. On the builder, the pattern's fully-defined path part (everything before the first '*') was appended to the per-instance dir verbatim, so "artifacts": "../../../etc/passwd" made the artifact scan walk the builder host filesystem outside the instance dir and rename() what it matched into .sai-uploads/, for publish as a downloadable build artifact. That exfiltrates host files (including e.g. ~/.ssh) to a public read-back channel and destructively removes them from their original location, and it works from containerized platforms too since the glob and rename run in the builder host process, defeating the nspawn containment. Reject the escape shapes on both sides: - sai_artifacts_list_safe() at sai-server notification intake rejects the notification when any pattern in the comma-separated list is absolute, has a windows drive / UNC / ADS shape or backslash separators, or has a ".." that is a whole path component (matching the existing hostile-field handling for ref / hash / fetchurl); - the builder applies sai_artifacts_pattern_safe() to each pattern before scanning, ignoring unsafe ones, and additionally resolves each match with realpath() to confirm it sits under the instance dir before the rename, so a symlink planted in the build dir pointing at host files cannot bypass the pattern checks either. diff --git a/src/builder/b-task.c b/src/builder/b-task.c index 2f075f3..3879469 100644 --- a/src/builder/b-task.c +++ b/src/builder/b-task.c @@ -24,6 +24,7 @@ #include #include #include +#include /* realpath() on posix */ #include "sai-git-hash.h" #include "b-private.h" @@ -551,6 +552,31 @@ artifact_glob_cb(void *data, const char *path) * + filename part */ +#if !defined(WIN32) + { + char rp[384], rip[384]; + size_t rl; + + /* + * The glob came from repo-controlled .sai.json, so before we + * rename the match away, make sure the resolved path really + * sits under the instance dir: a symlink planted in the build + * dir points the scan at host files outside it even when the + * pattern itself looked clean. + */ + + if (!realpath(path, rp) || !realpath(ns->inp, rip)) + return 1; + + rl = strlen(rip); + if (strncmp(rp, rip, rl) || (rp[rl] && rp[rl] != '/')) { + lwsl_err("%s: artifact '%s' resolves outside the " + "instance dir, skipping\n", __func__, path); + return 1; + } + } +#endif + p = path; while (*p) { if (*p == '/' || *p == '\\') @@ -686,6 +712,24 @@ saib_start_artifact_upload(struct sai_nspawn *ns) break; continue; scan: + /* + * The glob is repo-controlled all the way from .sai.json: + * its path part decides where we scan from, so it must stay + * inside the instance dir (no ".." components, absolute + * patterns, or windows drive / UNC shapes). + */ + + if (!sai_artifacts_pattern_safe(filt)) { + lwsl_err("%s: ignoring artifact glob '%s' that escapes " + "the build dir\n", __func__, filt); + filt[0] = '\0'; + m = 0; + + if (ts.e == LWS_TOKZE_ENDED) + break; + continue; + } + lws_strncpy(scandir, ns->inp, sizeof(scandir)); m = (int)strlen(scandir); diff --git a/src/common/c-utils.c b/src/common/c-utils.c index 436ec1c..c075e3e 100644 --- a/src/common/c-utils.c +++ b/src/common/c-utils.c @@ -82,6 +82,69 @@ sai_get_ref(const char *fullref) } /* + * Core single-pattern check for sai_artifacts_pattern_safe() / + * sai_artifacts_list_safe(): a pattern is unsafe if it is absolute, has a + * windows drive / UNC / ADS shape or backslash separators, or contains a + * ".." that is a whole path component (ie, could climb out of the build + * instance dir the pattern is scanned under). + */ +static int +artifacts_pattern_n_safe(const char *p, size_t len) +{ + size_t n; + + if (!len) + return 1; + + if (p[0] == '/' || p[0] == '\\') + return 0; + + for (n = 0; n < len; n++) { + if (p[n] == ':' || p[n] == '\\') + return 0; + + if (n + 1 < len && p[n] == '.' && p[n + 1] == '.' && + (n == 0 || p[n - 1] == '/') && + (n + 2 == len || p[n + 2] == '/' || p[n + 2] == '*')) + return 0; + } + + return 1; +} + +int +sai_artifacts_pattern_safe(const char *pat) +{ + if (!pat) + return 1; + + return artifacts_pattern_n_safe(pat, strlen(pat)); +} + +int +sai_artifacts_list_safe(const char *list) +{ + const char *p = list; + + if (!p) + return 1; + + while (1) { + const char *e = strchr(p, ','); + size_t len = e ? (size_t)(e - p) : strlen(p); + + if (!artifacts_pattern_n_safe(p, len)) + return 0; + + if (!e) + break; + p = e + 1; + } + + return 1; +} + +/* * Returns nonzero if s contains any byte that is dangerous to interpolate into * a shell context: the shell metacharacters ` $ ; | & < > ( ) \ and the quote * characters, plus glob chars, any control byte (< 0x20) or DEL. Used to gate diff --git a/src/common/include/private.h b/src/common/include/private.h index 1f6fd38..bb11c4f 100644 --- a/src/common/include/private.h +++ b/src/common/include/private.h @@ -1026,6 +1026,22 @@ sai_is_git_hash(const char *s); int 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 + * 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 + * drive / UNC shape) turns repo content into host-file exfiltration and + * destructive moves. These return 1 when safe to scan, else 0. + */ +int +sai_artifacts_pattern_safe(const char *pat); + +int +sai_artifacts_list_safe(const char *list); + void sai_dump_stderr(const uint8_t *buf, size_t w); diff --git a/src/server/s-notification.c b/src/server/s-notification.c index ab6deb2..4977830 100644 --- a/src/server/s-notification.c +++ b/src/server/s-notification.c @@ -713,6 +713,18 @@ next_plat: ; case LEJPNSAIF_CONFIGURATIONS_ARTIFACTS: lws_strncpy(sn->t.artifacts, ctx->buf, sizeof(sn->t.artifacts)); + /* + * The builder scans these globs under the task's instance dir + * and renames what matches into its uploads dir, for publish + * as downloadable artifacts. Reject path escapes at intake + * like the other hostile-field cases; the builder checks + * again on its side. + */ + if (!sai_artifacts_list_safe(sn->t.artifacts)) { + lwsl_notice("%s: rejecting artifacts list with path " + "escape '%s'\n", __func__, sn->t.artifacts); + return -1; + } break; case LEJPNSAIF_CONFIGURATIONS_CPACK: