From 9675c00f99dfe8b3a550165ff2010a0cf51cc835 Mon Sep 17 00:00:00 2001 From: theraw Date: Thu, 2 Jul 2026 15:14:21 +0000 Subject: [PATCH] Tenant Isolation --- README.md | 23 +++++++++ .../nginx-1.31.2-tenant-isolation.patch | 51 +++++++++++++++---- static/nginx/nginx.conf | 15 ++++++ 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 222f747..ff73760 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - [x] ModSecurity v3 (libmodsecurity) - [x] Naxsi - [x] Lua (LuaJIT 2.1) +- [x] Tenant Isolation. - [x] Cookie-based challenge - [x] [Versions List](https://git.julio.al/theraw/The-World-Is-Yours/src/branch/master/version) @@ -71,6 +72,28 @@ where `` is `trixie` or `raccoon`. LUA RESTY CORE SCRIPTS = /usr/nginx_lua ``` +## Tenant Isolation + +Confines each site's **static** file serving to its own `root` directory. A symlink, `..`, +magic-link or cross-tenant path that escapes the site tree (e.g. a symlink to `/etc/passwd`) +is refused with **403**, kernel-enforced via `openat2(RESOLVE_BENEATH)`. +**On by default — no vhost config needed.** In-tree symlinks still work. + +**Covered** +- Static files served by nginx (the normal file path). +- Backstopped at the real serving `open()` by `disable_symlinks if_not_owner`, which also + covers `gzip_static`, `autoindex`, `dav`, `mp4`/`flv`. + +**Not covered** +- Dynamic content (PHP-FPM / NGINX Unit) — isolated separately per tenant (user / cgroup / namespace). +- Not fully race-free: a narrow probe→open timing window remains (closing it fully would + break `open_file_cache`), so it's *mitigated* by the `disable_symlinks` backstop, not eliminated. + +**Optional per-vhost tuning** +- `tenant_isolation off;` — turn it off for a `server`/`location`. +- `tenant_isolation_root /home/;` — widen the confinement root to the tenant home + (allows above-docroot in-tree symlinks, e.g. Laravel's `storage`). + ## How to install lua scripts ``` . /root/The-World-Is-Yours/version diff --git a/build/patches/nginx-1.31.2-tenant-isolation.patch b/build/patches/nginx-1.31.2-tenant-isolation.patch index cc68102..35600af 100644 --- a/build/patches/nginx-1.31.2-tenant-isolation.patch +++ b/build/patches/nginx-1.31.2-tenant-isolation.patch @@ -1,5 +1,5 @@ ---- a/src/http/modules/ngx_http_static_module.c 2026-06-17 14:40:35.000000000 +0000 -+++ b/src/http/modules/ngx_http_static_module.c 2026-06-26 00:59:37.635956019 +0000 +--- a/src/http/modules/ngx_http_static_module.c ++++ b/src/http/modules/ngx_http_static_module.c @@ -14,6 +14,97 @@ static ngx_int_t ngx_http_static_init(ngx_conf_t *cf); @@ -139,7 +139,7 @@ ngx_memzero(&of, sizeof(ngx_open_file_info_t)); of.read_ahead = clcf->read_ahead; -@@ -278,6 +384,209 @@ +@@ -278,6 +384,242 @@ } @@ -212,8 +212,21 @@ + if (slcf->tenant_isolation_root.len) { + 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). ++ */ + if (path->len < croot.len -+ || ngx_strncmp(path->data, croot.data, croot.len) != 0) ++ || ngx_strncmp(path->data, croot.data, croot.len) != 0 ++ || (croot.data[croot.len - 1] != '/' ++ && path->data[croot.len] != '/' ++ && path->data[croot.len] != '\0')) + { + ngx_log_error(NGX_LOG_ERR, r->connection->log, 0, + "tenant_isolation: \"%V\" is outside root \"%V\"", @@ -283,6 +296,17 @@ + /* genuinely-missing target: let nginx produce its normal 404 */ + return NGX_OK; + ++ case NGX_EACCES: ++ case EMFILE: ++ 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. */ ++ return NGX_OK; ++ + case EXDEV: /* RESOLVE_BENEATH: resolution would escape the root */ + case NGX_ELOOP: /* magic-link / symlink loop refused */ + case EAGAIN: /* lost a resolution race: fail closed */ @@ -293,9 +317,12 @@ + case NGX_ENOSYS: + case EOPNOTSUPP: + case EPERM: -+ /* openat2 unavailable (old kernel, or a seccomp filter that -+ * ENOSYS/EPERMs it -- e.g. a hardened systemd unit). Latch and use the -+ * realpath containment check from now on rather than failing open. */ ++ 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. */ + if (!openat2_unavailable) { + openat2_unavailable = 1; + ngx_log_error(NGX_LOG_WARN, r->connection->log, err, @@ -307,8 +334,14 @@ + return ngx_http_static_tenant_check_realpath(r, root_buf, path); + + default: -+ /* unexpected: let nginx's own open path decide */ -+ return NGX_OK; ++ /* 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. */ ++ ngx_log_error(NGX_LOG_ERR, r->connection->log, err, ++ "tenant_isolation: openat2 failed with an unexpected error " ++ "for \"%V\", denying", path); ++ return NGX_HTTP_FORBIDDEN; + } +} + diff --git a/static/nginx/nginx.conf b/static/nginx/nginx.conf index ff3f1fa..79d34b1 100644 --- a/static/nginx/nginx.conf +++ b/static/nginx/nginx.conf @@ -59,6 +59,21 @@ 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). + disable_symlinks if_not_owner from=$document_root; + # ===================== TLS ============================ # ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off;