Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions components/ILIAS/FileDelivery/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
83 changes: 81 additions & 2 deletions components/ILIAS/FileDelivery/src/Delivery/BaseDelivery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -38,13 +39,65 @@ 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;
}
$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
Expand Down Expand Up @@ -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 <img>, <iframe>, …
* - allow CORS access only from the configured ILIAS domain
* - strip referrer to avoid leaking the content domain back to the app
*/
protected function applyIsolationHeaders(ResponseInterface $r): ResponseInterface
{
if (!$this->isolation->isActivated()) {
return $r;
}

$r = $r->withHeader(ResponseHeader::X_CONTENT_TYPE_OPTIONS, 'nosniff');
$r = $r->withHeader('Cross-Origin-Resource-Policy', 'cross-origin');
$r = $r->withHeader('Referrer-Policy', 'no-referrer');

if (($ilias_domain = $this->isolation->getIliasDomain()) !== null) {
$r = $r->withHeader(ResponseHeader::ACCESS_CONTROL_ALLOW_ORIGIN, $ilias_domain);
$r = $r->withHeader('Vary', 'Origin');
}

return $r;
}
}
6 changes: 6 additions & 0 deletions components/ILIAS/FileDelivery/src/Delivery/LegacyDelivery.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ private function deliver(
?string $mime_type = null,
?bool $delete_file = false
): never {
// The content domain is reserved for signed token delivery via deliver.php.
// Legacy delivery must never serve files on the content host.
if ($this->isRequestOnContentHost()) {
$this->notFound($this->http->response());
}

$r = $this->setGeneralHeaders(
$this->http->response(),
$path_to_file,
Expand Down
22 changes: 9 additions & 13 deletions components/ILIAS/FileDelivery/src/Delivery/StreamDelivery.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@
namespace ILIAS\FileDelivery\Delivery;

use ILIAS\HTTP\Services;
use Psr\Http\Message\ResponseInterface;
use ILIAS\FileDelivery\Token\DataSigner;
use ILIAS\FileDelivery\Delivery\ResponseBuilder\ResponseBuilder;
use ILIAS\Filesystem\Stream\FileStream;
use ILIAS\FileDelivery\Token\Signer\Payload\FilePayload;
use ILIAS\Filesystem\Stream\Streams;
use ILIAS\FileDelivery\Token\Signer\Payload\ShortFilePayload;
use ILIAS\Filesystem\Stream\ZIPStream;
use ILIAS\FileDelivery\Isolation\IsolationConfig;

/**
* @author Fabian Schmid <fabian@sr.solutions>
Expand All @@ -45,18 +45,9 @@ public function __construct(
Services $http,
ResponseBuilder $response_builder,
ResponseBuilder $fallback_response_builder,
IsolationConfig $isolation,
) {
parent::__construct($http, $response_builder, $fallback_response_builder);
}

/**
* @throws \ILIAS\HTTP\Response\Sender\ResponseSendingException
*/
private function notFound(ResponseInterface $r): void
{
$this->http->saveResponse($r->withStatus(404));
$this->http->sendResponse();
$this->http->close();
parent::__construct($http, $response_builder, $fallback_response_builder, $isolation);
}

public function attached(
Expand Down Expand Up @@ -116,6 +107,12 @@ public function deliver(

public function deliverFromToken(string $token): never
{
$r = $this->http->response();

if (!$this->isRequestHostAllowed()) {
$this->notFound($r);
}

// check if $token has a sub-request, such as .../index.html
$parts = explode(self::SUBREQUEST_SEPARATOR, $token);
$sub_request = null;
Expand All @@ -124,7 +121,6 @@ public function deliverFromToken(string $token): never
$sub_request = implode('/', array_slice($parts, 1));
}

$r = $this->http->response();
$payload = $this->data_signer->verifyStreamToken($token);

switch (true) {
Expand Down
24 changes: 21 additions & 3 deletions components/ILIAS/FileDelivery/src/Init.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
use ILIAS\FileDelivery\Delivery\ResponseBuilder\PHPResponseBuilder;
use ILIAS\FileDelivery\Delivery\ResponseBuilder\ResponseBuilder;
use ILIAS\FileDelivery\Setup\DeliveryMethodObjective;
use ILIAS\FileDelivery\Setup\IsolationObjective;
use ILIAS\FileDelivery\Isolation\IsolationConfig;
use ILIAS\FileDelivery\Delivery\LegacyDelivery;
use ILIAS\FileDelivery\Delivery\ResponseBuilder\XAccelResponseBuilder;

Expand All @@ -54,6 +56,19 @@ public static function init(Container $c): void

$c['file_delivery.fallback_response_builder'] = (static fn(): ResponseBuilder => new PHPResponseBuilder());

$c['file_delivery.isolation'] = static function (): IsolationConfig {
$path = IsolationObjective::PATH();
if (!is_file($path)) {
return IsolationConfig::disabled();
}
$data = @include $path;
$data = is_array($data) ? $data : [];

// Both the content domain and the ILIAS domain (derived from http_path)
// are baked into the artefact at setup time, so no ini read is needed.
return IsolationConfig::fromArray($data);
};

$c['file_delivery.data_signer'] = static function (): DataSigner {
$keys = array_map(static fn(string $key): SecretKey => new SecretKey($key), (require KeyRotationObjective::PATH()) ?? []);

Expand All @@ -78,7 +93,8 @@ public static function init(Container $c): void
$c['file_delivery.data_signer'],
$c['http'],
$c['file_delivery.response_builder'],
$c['file_delivery.fallback_response_builder']
$c['file_delivery.fallback_response_builder'],
$c['file_delivery.isolation']
);
};

Expand All @@ -92,15 +108,17 @@ public static function init(Container $c): void
return new LegacyDelivery(
$c['http'],
$c['file_delivery.response_builder'],
$c['file_delivery.fallback_response_builder']
$c['file_delivery.fallback_response_builder'],
$c['file_delivery.isolation']
);
};

$c['file_delivery'] = (static fn(): Services => new Services(
$c['file_delivery.delivery'],
$c['file_delivery.legacy_delivery'],
$c['file_delivery.data_signer'],
$c['http']
$c['http'],
$c['file_delivery.isolation']
));
}
}
Loading
Loading