From 6ff134b37d8351563840b0fa89380d7119e19b0b Mon Sep 17 00:00:00 2001 From: theraw Date: Fri, 3 Jul 2026 02:11:52 +0000 Subject: [PATCH] Tenant Isolation --- .../nginx-1.31.2-tenant-isolation.patch | 33 +++++++------------ build/raccoon.sh | 25 +++++++------- build/trixie.sh | 25 +++++++------- static/nginx/nginx.conf | 17 +++------- version | 10 +----- 5 files changed, 42 insertions(+), 68 deletions(-) diff --git a/build/patches/nginx-1.31.2-tenant-isolation.patch b/build/patches/nginx-1.31.2-tenant-isolation.patch index 35600af..a6c4706 100644 --- a/build/patches/nginx-1.31.2-tenant-isolation.patch +++ b/build/patches/nginx-1.31.2-tenant-isolation.patch @@ -139,7 +139,7 @@ ngx_memzero(&of, sizeof(ngx_open_file_info_t)); of.read_ahead = clcf->read_ahead; -@@ -278,6 +384,242 @@ +@@ -278,6 +384,231 @@ } @@ -213,14 +213,9 @@ + croot = slcf->tenant_isolation_root; + + /* -+ * Require a path-separator boundary immediately after the prefix, so a -+ * docroot that merely shares a string prefix with the confinement root -+ * (root "/home/u1" vs docroot "/home/u10/...") is not mistaken for being -+ * inside it -- otherwise `rel` would be computed against the wrong root -+ * and the check would silently no-op (fail open). If the configured root -+ * itself ends in '/', that boundary is already implied. The length guard -+ * is evaluated first so the path->data[croot.len] read stays in bounds -+ * (path->data is NUL-terminated at path->len). ++ * Require '/' (or end) right after the prefix so root "/home/u1" is not ++ * treated as a parent of "/home/u10/..." (would fail open). Length checked ++ * first to keep the read in bounds; a root ending in '/' implies it. + */ + if (path->len < croot.len + || ngx_strncmp(path->data, croot.data, croot.len) != 0 @@ -301,10 +296,8 @@ + case ENFILE: + case ENOMEM: + case ESTALE: -+ /* Not an escape verdict: a permission, fd/memory-exhaustion or stale-NFS -+ * condition that stopped resolution before it could conclude. Let nginx's -+ * own open run so it surfaces the correct 403/500/503 rather than masking -+ * it (or a transient hiccup) as a tenant_isolation 403. */ ++ /* Not an escape: perm / fd exhaustion / stale NFS. Let nginx's own open ++ * surface the real 403/500/503 instead of masking it as a 403. */ + return NGX_OK; + + case EXDEV: /* RESOLVE_BENEATH: resolution would escape the root */ @@ -318,11 +311,9 @@ + case EOPNOTSUPP: + case EPERM: + case EINVAL: -+ /* openat2 unavailable or rejecting our resolve flags (old kernel, a -+ * seccomp filter that ENOSYS/EPERMs it -- e.g. a hardened systemd unit -- -+ * or a kernel that predates RESOLVE_BENEATH). Latch and use the realpath -+ * containment check from now on rather than failing open or 403-ing every -+ * request. */ ++ /* openat2 unavailable or rejecting our flags (old kernel / seccomp, e.g. ++ * RestrictSUIDSGID). Latch to the realpath fallback instead of failing ++ * open or 403-ing every request. */ + if (!openat2_unavailable) { + openat2_unavailable = 1; + ngx_log_error(NGX_LOG_WARN, r->connection->log, err, @@ -334,10 +325,8 @@ + return ngx_http_static_tenant_check_realpath(r, root_buf, path); + + default: -+ /* Unexpected errno from openat2. Fail CLOSED: an escape we have no named -+ * case for must not be served. Benign resource/permission errnos that -+ * genuinely mean "not an escape" are enumerated above, so reaching here -+ * is anomalous -- deny and log rather than fall through to nginx. */ ++ /* Unexpected errno: fail closed. Benign non-escape errnos are handled ++ * above, so treat anything here as a possible escape. */ + ngx_log_error(NGX_LOG_ERR, r->connection->log, err, + "tenant_isolation: openat2 failed with an unexpected error " + "for \"%V\", denying", path); diff --git a/build/raccoon.sh b/build/raccoon.sh index a530baa..80af36d 100644 --- a/build/raccoon.sh +++ b/build/raccoon.sh @@ -244,18 +244,19 @@ function clean_install() { cd /opt/mod/; git clone --recurse-submodules https://github.com/wargio/naxsi.git naxsi fi - # NGX_MOD_ZSTD — Zstandard compression module from eilandert (maintained - # fork of tokers/zstd-nginx-module, which has been abandoned since 0.1.1 in - # 2023-10-23). Pinned via NGX_MOD_ZSTD; tarball pattern (dir name embeds - # version → cache invalidates automatically when the pin moves). - # - # The fork ships the flush-loop CPU-spin fix we previously carried as - # build/patches/zstd-nginx-module-0.1.1-flush-cpu-spin.patch — see PR #23, - # PR #49, and the "content-less flush completed" branch in - # filter/ngx_http_zstd_filter_module.c. No local patching needed. - if [ ! -d /opt/mod/zstd-nginx-module-${NGX_MOD_ZSTD} ]; then - cd /opt/mod/; wget https://github.com/eilandert/zstd-nginx-module/archive/refs/tags/${NGX_MOD_ZSTD}.tar.gz - cd /opt/mod/; tar xf ${NGX_MOD_ZSTD}.tar.gz; rm -Rf ${NGX_MOD_ZSTD}.tar.gz + # zstd module from https://github.com/theraw/zstd-nginx-module (NGX_MOD_ZSTD = + # git tag). Extract with --strip-components=1 into a fixed local dir name so the + # --add-module path never depends on the archive's top folder; guard on config + # so a partial extract re-fetches. + if [ ! -f /opt/mod/zstd-nginx-module-${NGX_MOD_ZSTD}/config ]; then + cd /opt/mod/ + rm -rf zstd-nginx-module-${NGX_MOD_ZSTD} + wget -O zstd-nginx-module-${NGX_MOD_ZSTD}.tar.gz \ + https://github.com/theraw/zstd-nginx-module/archive/refs/tags/${NGX_MOD_ZSTD}.tar.gz + mkdir -p zstd-nginx-module-${NGX_MOD_ZSTD} + tar xf zstd-nginx-module-${NGX_MOD_ZSTD}.tar.gz \ + -C zstd-nginx-module-${NGX_MOD_ZSTD} --strip-components=1 + rm -Rf zstd-nginx-module-${NGX_MOD_ZSTD}.tar.gz fi # END OF NGINX MODULES diff --git a/build/trixie.sh b/build/trixie.sh index 41d96e1..4c9be53 100644 --- a/build/trixie.sh +++ b/build/trixie.sh @@ -244,18 +244,19 @@ function clean_install() { cd /opt/mod/; git clone --recurse-submodules https://github.com/wargio/naxsi.git naxsi fi - # NGX_MOD_ZSTD — Zstandard compression module from eilandert (maintained - # fork of tokers/zstd-nginx-module, which has been abandoned since 0.1.1 in - # 2023-10-23). Pinned via NGX_MOD_ZSTD; tarball pattern (dir name embeds - # version → cache invalidates automatically when the pin moves). - # - # The fork ships the flush-loop CPU-spin fix we previously carried as - # build/patches/zstd-nginx-module-0.1.1-flush-cpu-spin.patch — see PR #23, - # PR #49, and the "content-less flush completed" branch in - # filter/ngx_http_zstd_filter_module.c. No local patching needed. - if [ ! -d /opt/mod/zstd-nginx-module-${NGX_MOD_ZSTD} ]; then - cd /opt/mod/; wget https://github.com/eilandert/zstd-nginx-module/archive/refs/tags/${NGX_MOD_ZSTD}.tar.gz - cd /opt/mod/; tar xf ${NGX_MOD_ZSTD}.tar.gz; rm -Rf ${NGX_MOD_ZSTD}.tar.gz + # zstd module from https://github.com/theraw/zstd-nginx-module (NGX_MOD_ZSTD = + # git tag). Extract with --strip-components=1 into a fixed local dir name so the + # --add-module path never depends on the archive's top folder; guard on config + # so a partial extract re-fetches. + if [ ! -f /opt/mod/zstd-nginx-module-${NGX_MOD_ZSTD}/config ]; then + cd /opt/mod/ + rm -rf zstd-nginx-module-${NGX_MOD_ZSTD} + wget -O zstd-nginx-module-${NGX_MOD_ZSTD}.tar.gz \ + https://github.com/theraw/zstd-nginx-module/archive/refs/tags/${NGX_MOD_ZSTD}.tar.gz + mkdir -p zstd-nginx-module-${NGX_MOD_ZSTD} + tar xf zstd-nginx-module-${NGX_MOD_ZSTD}.tar.gz \ + -C zstd-nginx-module-${NGX_MOD_ZSTD} --strip-components=1 + rm -Rf zstd-nginx-module-${NGX_MOD_ZSTD}.tar.gz fi # END OF NGINX MODULES diff --git a/static/nginx/nginx.conf b/static/nginx/nginx.conf index 79d34b1..9c2372f 100644 --- a/static/nginx/nginx.conf +++ b/static/nginx/nginx.conf @@ -59,19 +59,10 @@ http { open_file_cache_min_uses 2; open_file_cache_errors on; - # Symlink backstop for tenant static files. Enforced by nginx at the SAME - # open() that serves the file, so it complements the tenant_isolation patch - # (docs/tenant-isolation.md) on the axes that patch cannot cover from its - # separate openat2 probe: - # * the probe->open TOCTOU window — a tenant that swaps a benign file for an - # escaping symlink after the probe is still caught here, at serve time; - # * the open paths the patch does not intercept — gzip_static, autoindex, - # dav, mp4/flv — which all funnel through this same disable_symlinks check. - # `if_not_owner` (not `on`) is deliberate: it permits a tenant's OWN in-tree - # symlinks (e.g. Laravel's public/storage) while refusing a symlink whose - # target has a different owner (docroot/x -> /etc/passwd, root-owned => 403), - # which matches the tenant_isolation semantics. `from=$document_root` skips - # symlink checks on the docroot prefix itself (owned by the platform). + # Symlink backstop for tenant_isolation, checked at the serving open(): catches + # the probe->open race and the paths the patch skips (gzip_static, autoindex, + # dav, mp4/flv). if_not_owner allows a tenant's own symlinks, blocks ones whose + # target has a different owner (docroot/x -> /etc/passwd => 403). disable_symlinks if_not_owner from=$document_root; # ===================== TLS ============================ # diff --git a/version b/version index 3967cc9..6ae4185 100644 --- a/version +++ b/version @@ -55,15 +55,7 @@ export NGX_MOD_LUA_LOCK="0.09" export NGX_MOD_LUA_SRCACHE="0.33" # https://github.com/eilandert/zstd-nginx-module/tags -# Zstandard compression module. Chrome 123+ and Firefox 126+ send -# `Accept-Encoding: zstd`; older clients fall back to brotli/gzip. -# Switched from tokers/zstd-nginx-module (unmaintained since 0.1.1, 2023-10-23) -# to eilandert/zstd-nginx-module, an actively maintained fork that has already -# absorbed the flush-loop CPU-spin fix we previously carried as a local patch -# (PR #23 + PR #49 + follow-up work — see filter/ngx_http_zstd_filter_module.c -# in the fork for the "content-less flush completed" branch that supersedes -# our patch). -export NGX_MOD_ZSTD="0.90.6" +export NGX_MOD_ZSTD="v1.1" # https://github.com/zlib-ng/zlib-ng/releases # Drop-in libz replacement with SIMD-accelerated DEFLATE. Built in --zlib-compat