From b647e933332e821e29e5488635c58a590188d13a Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Tue, 4 Aug 2026 08:10:47 +0200 Subject: [PATCH] [FEATURE] User Content Isolation Funded by Martin Luther University Halle-Wittenberg Serve user-generated content from a separate content domain so that uploaded files can no longer run in the security context of the ILIAS host. FileDelivery: - IsolationConfig holds the activated flag, the content domain and the derived ILIAS domain; it is built from the isolation.php artefact and therefore needs no ini/DB access at runtime. - BaseDelivery/StreamDelivery/LegacyDelivery emit the isolation headers and CORS allow-origin for the ILIAS domain; Services builds delivery URIs against the content domain. - IsolationObjective derives the ILIAS domain from the installed http_path at build time and bakes it into the artefact. It declares ilHttpConfigStoredObjective as a precondition and throws an UnachievableException when the content domain equals the ILIAS host. - FileDeliverySetupConfig accepts content_domain / ilias_domain as a bare host or a full http(s) origin. - The stale tracked artefact src/artifacts/delivery_method.php is removed; the setup generates it into public/data. Setup: - New optional NamedAgent interface lets a namespaced agent declare its own name, i.e. the top-level key used for its configuration in config.json. The FileDelivery agent uses "content_isolation". ImplementationOfAgentFinder falls back to the previous class-name derivation, so existing agents are unaffected. Documentation: - FileDelivery README describes the setup including a web server vhost example; docs/configuration/secure.md links to it. --- components/ILIAS/FileDelivery/README.md | 112 ++++++++ .../src/Delivery/BaseDelivery.php | 83 +++++- .../src/Delivery/LegacyDelivery.php | 6 + .../src/Delivery/StreamDelivery.php | 22 +- components/ILIAS/FileDelivery/src/Init.php | 24 +- .../src/Isolation/IsolationConfig.php | 221 +++++++++++++++ .../ILIAS/FileDelivery/src/Services.php | 25 +- .../ILIAS/FileDelivery/src/Setup/Agent.php | 46 +++- .../src/Setup/FileDeliverySetupConfig.php | 73 +++++ .../src/Setup/IsolationObjective.php | 117 ++++++++ .../src/artifacts/delivery_method.php | 21 -- .../Delivery/BaseDeliveryIsolationTest.php | 148 ++++++++++ .../tests/Isolation/IsolationConfigTest.php | 256 ++++++++++++++++++ .../tests/ServicesBaseUriTest.php | 110 ++++++++ .../FileDelivery/tests/Setup/AgentTest.php | 99 +++++++ .../Setup/FileDeliverySetupConfigTest.php | 101 +++++++ .../tests/Setup/IsolationObjectiveTest.php | 171 ++++++++++++ .../Init/classes/class.ilInitialisation.php | 18 +- .../Init/src/Environment/HttpPathBuilder.php | 18 +- .../ILIAS/Init/tests/HttpPathBuilderTest.php | 68 ++++- .../Setup/src/ImplementationOfAgentFinder.php | 8 +- components/ILIAS/Setup/src/NamedAgent.php | 41 +++ .../tests/ImplementationOfAgentFinderTest.php | 67 +++++ .../Test/tests/ilTestBaseTestCaseTrait.php | 10 +- docs/configuration/secure.md | 9 + 25 files changed, 1814 insertions(+), 60 deletions(-) create mode 100644 components/ILIAS/FileDelivery/src/Isolation/IsolationConfig.php create mode 100644 components/ILIAS/FileDelivery/src/Setup/FileDeliverySetupConfig.php create mode 100644 components/ILIAS/FileDelivery/src/Setup/IsolationObjective.php delete mode 100755 components/ILIAS/FileDelivery/src/artifacts/delivery_method.php create mode 100644 components/ILIAS/FileDelivery/tests/Delivery/BaseDeliveryIsolationTest.php create mode 100644 components/ILIAS/FileDelivery/tests/Isolation/IsolationConfigTest.php create mode 100644 components/ILIAS/FileDelivery/tests/ServicesBaseUriTest.php create mode 100644 components/ILIAS/FileDelivery/tests/Setup/AgentTest.php create mode 100644 components/ILIAS/FileDelivery/tests/Setup/FileDeliverySetupConfigTest.php create mode 100644 components/ILIAS/FileDelivery/tests/Setup/IsolationObjectiveTest.php create mode 100644 components/ILIAS/Setup/src/NamedAgent.php create mode 100644 components/ILIAS/Setup/tests/ImplementationOfAgentFinderTest.php diff --git a/components/ILIAS/FileDelivery/README.md b/components/ILIAS/FileDelivery/README.md index bbb7ced483df..d33d151f357b 100755 --- a/components/ILIAS/FileDelivery/README.md +++ b/components/ILIAS/FileDelivery/README.md @@ -233,3 +233,115 @@ http://trunk.ilias.localhost/src/FileDelivery/deliver.php/LY3NasMwEITy[...]RFiKc > Important: Do not combine the Singed Delivery with other mechanisms such > as the [WebAcceessChecker](../WebAccessChecker/README.md) + +# IRSS User Content Isolation + +User-uploaded content (and derived files such as previews) can contain active +markup (SVG, HTML, …). Serving it from the same origin as the application allows +attacks such as stored XSS, content sniffing or canvas exfiltration against the +logged-in session. *User Content Isolation* serves all IRSS assets from a +dedicated, cookie-less **content domain** while the application keeps running on +the ILIAS domain (OWASP: *"use an isolated server with a different domain to +serve uploaded files"*). + +This is **proxy mode**: the content domain is an additional vhost pointing at the +*same* ILIAS installation. Signed token delivery (`deliver.php`) is unchanged; +only the host that the embed URLs point to differs. + +## What ILIAS enforces when the feature is active + +* `Services::getBaseURI()` generates all IRSS embed URLs against the content + domain instead of the request host — consumers need no changes. +* `deliver.php` (`StreamDelivery::deliverFromToken()`) serves assets **only** when + reached via the content host; a request on the ILIAS host returns `404`. +* `LegacyDelivery` refuses to serve on the content host (`404`) — the content + domain is reserved for signed token delivery. +* `HttpPathBuilder` rejects any attempt to load the regular ILIAS application via + the content domain. +* Delivery responses are hardened with `X-Content-Type-Options: nosniff`, + `Cross-Origin-Resource-Policy: cross-origin`, `Referrer-Policy: no-referrer` + and a CORS `Access-Control-Allow-Origin` restricted to the ILIAS domain (with + `Vary: Origin`). No credentials are allowed, so session cookies are never used + for asset requests. + +## Configuration (Setup only — no GUI) + +Per JourFixe decision the feature is configured exclusively through the Setup so +that installations can be made secure-by-default via CLI. The settings are +written to a static PHP artefact (`public/data/isolation.php`) and read at +runtime without any DB access. + +Add the following block to your setup `config.json`. The top-level key is +`content_isolation`: + +```json +{ + "content_isolation": { + "activated": true, + "content_domain": "content.example.org" + } +} +``` + +* `content_domain` is the dedicated origin user content is served from. Either a + bare host (`content.example.org`, normalised to `https://`) or a full `http(s)` + origin (`scheme://host[:port]`, no userinfo/path/query) is accepted. It is + **required** and must differ from the ILIAS origin when `activated` is `true`, + otherwise the Setup aborts with a clear error. +* The **ILIAS domain** (used as the CORS `Access-Control-Allow-Origin` for asset + requests) is **not** configured here. It is derived from the installed + `http.path` at setup time and baked into the artefact, so it never has to be + repeated in this block and the runtime never reads `ilias.ini.php` to obtain it. + Reconfiguring `http.path` and re-running the Setup updates it automatically. +* `activated: false` (the default) keeps the previous behaviour: assets are + served from the ILIAS domain via the regular signed delivery. + +Run `setup install`/`setup update` to write the artefact. Because the ILIAS domain +is taken from `http.path`, run the FileDelivery setup *after* `http.path` is +configured (the Setup enforces this ordering via objective preconditions). + +> **`http.allowed_hosts` is not involved.** The content domain does **not** need to +> be added to `http.allowed_hosts`. Asset delivery runs through `deliver.php`, which +> boots via the FileDelivery component entry point and never reaches the +> `allowed_hosts` check in `HttpPathBuilder`. Keeping the content host out of +> `allowed_hosts` is in fact intended: the regular application must not be reachable +> under the content domain. + +## Operational requirements + +* **DNS/TLS/vhost**: the content domain needs its own DNS record and TLS + certificate and must be served by a vhost pointing at the *same* ILIAS + installation. Use a domain clearly different from the ILIAS domain + (e.g. `iliascontent.de` vs `ilias.de`). +* **Session cookie must stay host-only**: ILIAS sets the session cookie without a + `Domain` attribute (`IL_COOKIE_DOMAIN = ''`), so it is never sent to the + content host. Do **not** configure a shared parent cookie domain — that would + leak the session to the content domain and defeat the isolation. +* **Extra headers**: ILIAS already emits the headers needed for image/asset + embedding. For cross-origin embedding of `.css`/`.js`/`.html` you may still + need to set the corresponding headers on your web server / the content vhost. + +## Example web server setup (one possible approach) + +The content domain is not a second installation: it points at the **same** +document root and the same PHP as the ILIAS domain, and the isolation is enforced +in PHP by host name. So in the simplest case you just let your existing ILIAS +vhost answer both host names — no second vhost, no extra rules. Point the content +domain at the same server via DNS and use a certificate covering both names. + +Apache — add one line to the existing vhost: + +```apache +ServerName ilias.example.org +ServerAlias content.example.org +``` + +nginx — add the host to the existing `server_name`: + +```nginx +server_name ilias.example.org content.example.org; +``` + +Everything else stays as it is. Depending on your setup (reverse proxy, +container, where TLS is terminated) the details will differ — the only +requirement is that the content domain reaches the same ILIAS installation. diff --git a/components/ILIAS/FileDelivery/src/Delivery/BaseDelivery.php b/components/ILIAS/FileDelivery/src/Delivery/BaseDelivery.php index 5b9aaf84e64d..ffbad3b07164 100644 --- a/components/ILIAS/FileDelivery/src/Delivery/BaseDelivery.php +++ b/components/ILIAS/FileDelivery/src/Delivery/BaseDelivery.php @@ -22,6 +22,7 @@ use ILIAS\HTTP\Services; use ILIAS\FileDelivery\Delivery\ResponseBuilder\ResponseBuilder; +use ILIAS\FileDelivery\Isolation\IsolationConfig; use ILIAS\HTTP\Response\ResponseHeader; use Psr\Http\Message\ResponseInterface; @@ -38,6 +39,7 @@ public function __construct( protected Services $http, protected ResponseBuilder $response_builder, protected ResponseBuilder $fallback_response_builder, + protected IsolationConfig $isolation = new IsolationConfig(false, null, null), ) { if (is_readable(self::MIME_TYPE_MAP)) { $map = include self::MIME_TYPE_MAP; @@ -45,6 +47,57 @@ public function __construct( $this->mime_type_map = $map ?? []; } + /** + * When user content isolation is active, only requests targeting the + * configured content domain may be served. Requests reaching the + * delivery endpoint via the main ILIAS domain are rejected. + */ + protected function isRequestHostAllowed(): bool + { + if (!$this->isolation->isActivated()) { + return true; + } + + $expected = $this->isolation->getContentHost(); + if ($expected === null) { + return true; + } + + return strcasecmp($this->http->request()->getUri()->getHost(), $expected) === 0; + } + + /** + * Inverse of {@see self::isRequestHostAllowed()} for the legacy/internal + * delivery path: the content domain is reserved for signed token delivery + * via deliver.php. Legacy or app-context delivery must never happen on the + * content host, so callers reject such requests. + */ + protected function isRequestOnContentHost(): bool + { + if (!$this->isolation->isActivated()) { + return false; + } + + $content_host = $this->isolation->getContentHost(); + if ($content_host === null) { + return false; + } + + return strcasecmp($this->http->request()->getUri()->getHost(), $content_host) === 0; + } + + /** + * Send an empty 404 response and terminate. Declared `never` so the type + * system guarantees callers cannot fall through to actually serving a file + * after a rejected request. + */ + protected function notFound(ResponseInterface $r): never + { + $this->http->saveResponse($r->withStatus(404)); + $this->http->sendResponse(); + $this->http->close(); + } + protected function saveAndClose( ResponseInterface $r, ?string $path_to_delete = null @@ -86,10 +139,36 @@ protected function setGeneralHeaders( $disposition->value . '; filename="' . $file_name . '"' ); $r = $r->withHeader(ResponseHeader::CACHE_CONTROL, 'max-age=31536000, immutable, private'); - - return $r->withHeader( + $r = $r->withHeader( ResponseHeader::EXPIRES, date("D, j M Y H:i:s", strtotime('+5 days')) . " GMT" ); + + return $this->applyIsolationHeaders($r); + } + + /** + * When isolation is active, harden delivery responses: + * - prevent MIME sniffing + * - mark as cross-origin so the main app may embed assets via ,