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
,