Tenant Isolation
build-and-publish / build (Trixie, debian:13, NEXUS_PASS_TRIXIE, NEXUS_REPO_TRIXIE, NEXUS_USER_TRIXIE, trixie) (push) Has been cancelled
build-and-publish / build (Raccoon, ubuntu:26.04, NEXUS_PASS_RACCOON, NEXUS_REPO_RACCOON, NEXUS_USER_RACCOON, raccoon) (push) Has been cancelled

This commit is contained in:
theraw
2026-07-02 15:14:21 +00:00
parent 730a4cc47f
commit 9675c00f99
3 changed files with 80 additions and 9 deletions
+23
View File
@@ -8,6 +8,7 @@
- [x] ModSecurity v3 (libmodsecurity) - [x] ModSecurity v3 (libmodsecurity)
- [x] Naxsi - [x] Naxsi
- [x] Lua (LuaJIT 2.1) - [x] Lua (LuaJIT 2.1)
- [x] Tenant Isolation.
- [x] Cookie-based challenge - [x] Cookie-based challenge
- [x] [Versions List](https://git.julio.al/theraw/The-World-Is-Yours/src/branch/master/version) - [x] [Versions List](https://git.julio.al/theraw/The-World-Is-Yours/src/branch/master/version)
@@ -71,6 +72,28 @@ where `<distro>` is `trixie` or `raccoon`.
LUA RESTY CORE SCRIPTS = /usr/nginx_lua 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/<user>;` — widen the confinement root to the tenant home
(allows above-docroot in-tree symlinks, e.g. Laravel's `storage`).
## How to install lua scripts ## How to install lua scripts
``` ```
. /root/The-World-Is-Yours/version . /root/The-World-Is-Yours/version
@@ -1,5 +1,5 @@
--- a/src/http/modules/ngx_http_static_module.c 2026-06-17 14:40:35.000000000 +0000 --- a/src/http/modules/ngx_http_static_module.c
+++ b/src/http/modules/ngx_http_static_module.c 2026-06-26 00:59:37.635956019 +0000 +++ b/src/http/modules/ngx_http_static_module.c
@@ -14,6 +14,97 @@ @@ -14,6 +14,97 @@
static ngx_int_t ngx_http_static_init(ngx_conf_t *cf); 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)); ngx_memzero(&of, sizeof(ngx_open_file_info_t));
of.read_ahead = clcf->read_ahead; of.read_ahead = clcf->read_ahead;
@@ -278,6 +384,209 @@ @@ -278,6 +384,242 @@
} }
@@ -212,8 +212,21 @@
+ if (slcf->tenant_isolation_root.len) { + if (slcf->tenant_isolation_root.len) {
+ croot = slcf->tenant_isolation_root; + 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 + 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, + ngx_log_error(NGX_LOG_ERR, r->connection->log, 0,
+ "tenant_isolation: \"%V\" is outside root \"%V\"", + "tenant_isolation: \"%V\" is outside root \"%V\"",
@@ -283,6 +296,17 @@
+ /* genuinely-missing target: let nginx produce its normal 404 */ + /* genuinely-missing target: let nginx produce its normal 404 */
+ return NGX_OK; + 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 EXDEV: /* RESOLVE_BENEATH: resolution would escape the root */
+ case NGX_ELOOP: /* magic-link / symlink loop refused */ + case NGX_ELOOP: /* magic-link / symlink loop refused */
+ case EAGAIN: /* lost a resolution race: fail closed */ + case EAGAIN: /* lost a resolution race: fail closed */
@@ -293,9 +317,12 @@
+ case NGX_ENOSYS: + case NGX_ENOSYS:
+ case EOPNOTSUPP: + case EOPNOTSUPP:
+ case EPERM: + case EPERM:
+ /* openat2 unavailable (old kernel, or a seccomp filter that + case EINVAL:
+ * ENOSYS/EPERMs it -- e.g. a hardened systemd unit). Latch and use the + /* openat2 unavailable or rejecting our resolve flags (old kernel, a
+ * realpath containment check from now on rather than failing open. */ + * 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) { + if (!openat2_unavailable) {
+ openat2_unavailable = 1; + openat2_unavailable = 1;
+ ngx_log_error(NGX_LOG_WARN, r->connection->log, err, + 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); + return ngx_http_static_tenant_check_realpath(r, root_buf, path);
+ +
+ default: + default:
+ /* unexpected: let nginx's own open path decide */ + /* Unexpected errno from openat2. Fail CLOSED: an escape we have no named
+ return NGX_OK; + * 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;
+ } + }
+} +}
+ +
+15
View File
@@ -59,6 +59,21 @@ http {
open_file_cache_min_uses 2; open_file_cache_min_uses 2;
open_file_cache_errors on; 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 ============================ # # ===================== TLS ============================ #
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off; ssl_prefer_server_ciphers off;