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 / netbsd-OSX-catalina.svg
Author[]Andy Green <andy@warmcat.com> 2026-09-18 18:55 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-09-21 07:06 UTC
Treee540feb68365c1dcbeeca8e18d953a69e1a222c3   Raw Patch
 
builder: keep repo artifact globs inside the instance dir, fixes F-020
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 <assert.h> #include <fcntl.h> #include <errno.h> +#include <stdlib.h> /* 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:
Page fetched 0s ago, creation time: 4ms (vhost etag hits: 0%, cache hits: 0%)