---
title: "Rendering LLM Markdown server-side safely"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:developer-safe-markdown-rendering@0.35"
source: "Developer/SafeMarkdownRendering.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# Rendering LLM Markdown server-side safely

LLM responses are untrusted output (see
[best practices](https://docs.typo3.org/permalink/netresearch/nr-llm:developer-best-practices@0.35)): the model can echo raw
HTML, `javascript:` links, or attacker-supplied fragments straight from
its prompt context back into its answer. If your extension converts that
Markdown to HTML on the server and injects it into a page, the converter
configuration is a security boundary.

This page shows the hardened server-side recipe, and when to prefer the
alternative that nr-llm itself uses — client-side escaping plus sandboxed
iframes.

-   [Hardened league/commonmark configuration](https://docs.typo3.org/permalink/netresearch/nr-llm:hardened-league-commonmark-configuration@0.35)
-   [Escaping fallback on converter failure](https://docs.typo3.org/permalink/netresearch/nr-llm:escaping-fallback-on-converter-failure@0.35)
-   [Alternative: client-side escaping and sandboxed iframes](https://docs.typo3.org/permalink/netresearch/nr-llm:alternative-client-side-escaping-and-sandboxed-iframes@0.35)
-   [Reference implementation](https://docs.typo3.org/permalink/netresearch/nr-llm:reference-implementation@0.35)

## Hardened league/commonmark configuration

`league/commonmark` is *not* a dependency of nr-llm; add it to your
extension:

**Add the dependency**

```bash
composer require "league/commonmark:^2.4"
```

The library's own defaults are unsafe for LLM output: `html_input`
defaults to `allow` (raw HTML in the Markdown passes through verbatim)
and `allow_unsafe_links` defaults to `true` (`javascript:` and
`data:` link destinations are kept). Both must be overridden explicitly:

**Configuration/Services.yaml**

```yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true
        public: false

    League\CommonMark\CommonMarkConverter:
        arguments:
            $config:
                html_input: 'strip'
                allow_unsafe_links: false

    League\CommonMark\ConverterInterface:
        alias: League\CommonMark\CommonMarkConverter
```

`html_input: strip` removes any raw HTML the model emitted;
`allow_unsafe_links: false` suppresses link and image destinations with
unsafe schemes such as `javascript:`.

## Escaping fallback on converter failure

The converter can throw — on malformed input or on a configuration error.
Neither case may end in an uncaught exception (denial of service via
crafted output) or in emitting the raw text unescaped. Fall back to
`htmlspecialchars()`, which yields correctly escaped, if unformatted,
output:

**Classes/Presentation/SafeMarkdownRenderer.php**

```php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Presentation;

use League\CommonMark\ConverterInterface;
use League\CommonMark\Exception\CommonMarkException;
use League\Config\Exception\ConfigurationExceptionInterface;

final readonly class SafeMarkdownRenderer
{
    public function __construct(
        private ConverterInterface $markdownConverter,
    ) {}

    public function render(string $llmMarkdown): string
    {
        try {
            return $this->markdownConverter
                ->convert($llmMarkdown)
                ->getContent();
        } catch (CommonMarkException | ConfigurationExceptionInterface) {
            return htmlspecialchars(
                $llmMarkdown,
                \ENT_QUOTES | \ENT_HTML5,
            );
        }
    }
}
```

In a Fluid template, output the result raw — it is already HTML — and
never pass it through another escaping layer that would double-encode it:

**Resources/Private/Templates/Show.html**

```html
<div class="llm-answer">{answerHtml -> f:format.raw()}</div>
```

> [!WARNING]
> The hardened converter only guards content that goes *through* it.
> Hyperlink targets your template builds outside the converter — for
> example source URLs taken from index metadata and placed into `href`
> attributes — are a separate trust boundary: Fluid's default
> htmlspecialchars-based escaping does not neutralize a `javascript:`
> URI inside an `href`. Validate such URIs server-side (allow absolute
> `http(s)://` URLs and single-slash site-relative paths; reject
> everything else, including protocol-relative `//host` forms).

## Alternative: client-side escaping and sandboxed iframes

nr-llm's own backend module deliberately does *not* render LLM output
server-side. Its task-execution view
(`Resources/Public/JavaScript/Backend/TaskExecute.js`) escapes
plain, Markdown, and JSON output client-side before insertion, and shows
LLM-*generated HTML* only inside an iframe with `sandbox=""` — the
fully restrictive sandbox, which blocks script execution, form
submission, and same-origin access entirely.

Which strategy fits depends on where and how the output is displayed:

-   **Server-side hardened rendering (this page)**

    Choose it for anonymous frontend pages: the output is complete
    HTML that works without JavaScript, can be cached with the page,
    and is identical for every client. The
    [endpoint-protection recipes](https://docs.typo3.org/permalink/netresearch/nr-llm:developer-endpoint-protection@0.35)
    pair with it on the same controller.

-   **Client-side escaping + sandboxed iframes (nr-llm's approach)**

    Choose it for interactive views — backend modules,
    single-page-style widgets — where output arrives incrementally
    (streaming) and is inserted into a live DOM, or where the feature is
    previewing LLM-generated *HTML itself*, which no Markdown converter
    setting can make safe to inline. The sandbox attribute confines the
    document instead of sanitizing it.

The two are not exclusive: an extension with a frontend plugin and a
backend preview module uses both, each at its own surface.

## Reference implementation

The `nr_ai_search` extension (Netresearch) implements the server-side
recipe for its anonymous search and chat plugins: the hardened converter
configuration lives in its `Configuration/Services.yaml`, and its
`MarkdownAnswerPresenter` (`Classes/Provider/`) combines the
converter with the `htmlspecialchars()` fallback and server-side
`href` validation for source links.
