2025-12-26 Authentication & Authorization Security 🟡 Fortgeschritten

TYPO3 Extbase Security Hardening - Sicherheitsrichtlinien für v12, v13, v14

Einleitung

Extbase-Extensions sind mächtig, aber ohne bewusste Härtung anfällig für SQL Injection, Mass Assignment, Identity Manipulation, CSRF und XSS. Dieses Tutorial basiert auf den offiziellen TYPO3 Security Guidelines und zeigt konkrete Maßnahmen für TYPO3 v12 bis v14.

"All input data your extension receives from the user can be potentially malicious. That applies to all data being transmitted via GET and POST requests." — TYPO3 Core API Documentation

Die Beispiele in diesem Tutorial sind allgemeingültig und orientieren sich an Best Practices aus etablierten Extensions wie EXT:news und EXT:blog.

1. Versionskompatibilität im Überblick

Feature TYPO3 12 TYPO3 13 TYPO3 14
CSP Header Report-Only Enforce (BE) + YAML Config Enforce (FE+BE)
Trusted Properties HMAC-basiert HMAC + RequestToken Auto für Plugins
Rate Limiting Manuell Core Middleware Symfony RateLimiter
Doctrine DBAL 3.x 4.x 4.x + Prepared
HTML Sanitizer ViewHelper ❌ (parseFunc) ✅ f:sanitize.html ✅ f:sanitize.html
PHP Version 8.1+ 8.2+ 8.3+

2. Grundprinzip: Never Trust User Input

Alle Eingabedaten können potenziell bösartig sein – GET, POST und auch Cookies. Formulare können manipuliert werden, daher muss das erwartete Format serverseitig validiert werden.

Wichtige Unterscheidung: TCA-Validierung schützt das Backend, Domain Model Validation schützt das Frontend (Extbase). Beide müssen implementiert werden!

2.1 TCA-basierte Validierung (Backend)

Für Backend-Formulare die korrekten TCA-Typen verwenden:

// Configuration/TCA/tx_myext_domain_model_user.php
'email' => [
    'label' => 'E-Mail',
    'config' => [
        'type' => 'email',  // Eingebaute E-Mail-Validierung (v12+)
        'required' => true,
    ],
],

Hinweis: In TYPO3 v12/v13 werden eval-Optionen zunehmend in dedizierte TCA-Typen (email, link, color) oder Doctrine DBAL Types ausgelagert. Der type => 'email' ist dem alten eval => 'email' vorzuziehen.

TCA-Validierung greift NUR im Backend! Für Frontend-Formulare (Extbase Plugins) ist zusätzlich die Validierung im Domain Model zwingend erforderlich.

2.2 Extbase Validation Framework (Frontend)

Ab TYPO3 12 sind PHP 8 Attributes der Standard für Frontend-Validierung:

<?php
declare(strict_types=1);

use TYPO3\CMS\Extbase\Annotation\Validate;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

final class User extends AbstractEntity
{
    #[Validate(['validator' => 'NotEmpty'])]
    #[Validate(['validator' => 'StringLength',
        'options' => ['minimum' => 2, 'maximum' => 100]])]
    #[Validate(['validator' => 'RegularExpression',
        'options' => ['regularExpression' => '/^[a-zA-Z\-]+$/']])]
    protected string $lastName = '';

    #[Validate(['validator' => 'EmailAddress'])]
    protected string $email = '';
}

2.3 Strict Typing als Sicherheits-Feature

Bonus-Tipp: Seit PHP 8.0 und TYPO3 v12 verhindern typisierte Properties viele Injection-Angriffe.

Domain Model mit Typed Properties:

<?php
declare(strict_types=1);

namespace MyVendor\MyExt\Domain\Model;

use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

class Article extends AbstractEntity
{
    // Typisierte Properties verhindern Array/Object-Injection
    protected int $sysLanguageUid = 0;
    protected int $l10nParent = 0;
    protected string $title = '';
    protected string $teaser = '';
    protected string $bodytext = '';
    protected bool $isHighlighted = false;

    // Nullable Types für optionale Felder
    protected ?\DateTime $publishDate = null;
    protected ?\DateTime $archiveDate = null;
}

Wenn ein Angreifer versucht, ein Array oder Objekt in ein String-Feld zu injizieren, führt das zu einem harten TypeError, der die Ausführung stoppt (Fail Secure).

3. Sichere Filterung von Request-Parametern

Request-Parameter (GET/POST) dürfen niemals direkt an Repository-Methoden oder Datenbank-Queries übergeben werden.

3.1 Das Problem: Direkte Parameter-Übernahme

// UNSICHER: Direkte Übernahme von GET-Parametern
public function listAction(): ResponseInterface
{
    $category = $this->request->getArgument('category');
    $limit = $this->request->getArgument('limit');
    $orderBy = $this->request->getArgument('orderBy');

    // Gefährlich: Unkontrollierte Werte in Query
    $items = $this->repository->findFiltered($category, $limit, $orderBy);
}

3.2 Die Lösung: Validierung und Whitelisting

Regel 1: Settings statt Request-Parameter verwenden

Filter-Werte sollten primär aus TypoScript/FlexForm kommen, nicht aus dem Request:

public function listAction(): ResponseInterface
{
    // Sicher: Werte aus Plugin-Konfiguration (vom Integrator gesetzt)
    $limit = (int)($this->settings['limit'] ?? 10);
    $categoryId = (int)($this->settings['category'] ?? 0);

    $items = $this->repository->findByCategory($categoryId, $limit);
    // ...
}

Regel 2: Bei Request-Parametern immer validieren

Falls Request-Parameter nötig sind (z.B. Pagination, Filter-Formulare):

public function listAction(): ResponseInterface
{
    // 1. Type-Casting erzwingen
    $page = max(1, (int)($this->request->getArgument('page') ?? 1));

    // 2. Grenzwerte prüfen
    $limit = (int)($this->settings['limit'] ?? 10);
    $limit = min(max($limit, 1), 100);  // Zwischen 1 und 100

    // 3. Whitelist für String-Werte
    $allowedSortFields = ['title', 'date', 'author'];
    $orderBy = $this->request->getArgument('orderBy') ?? 'date';
    $orderBy = in_array($orderBy, $allowedSortFields, true) ? $orderBy : 'date';

    // 4. Whitelist für Sortierrichtung
    $direction = strtoupper($this->request->getArgument('direction') ?? 'DESC');
    $direction = in_array($direction, ['ASC', 'DESC'], true) ? $direction : 'DESC';

    $items = $this->repository->findFiltered($orderBy, $direction, $limit, $page);
    // ...
}

Regel 3: Integer-Arrays validieren

// Kategorie-Filter aus Request
$categoryParam = $this->request->getArgument('categories') ?? '';

// Sicher: Nur Integer-Werte extrahieren
$categoryIds = array_map(intval(...), explode(',', $categoryParam));
$categoryIds = array_filter($categoryIds); // Nullen entfernen

// Optional: Auf erlaubte Kategorien beschränken
$allowedCategories = [1, 2, 5, 10];
$categoryIds = array_intersect($categoryIds, $allowedCategories);

3.3 Zusammenfassung der Validierungsregeln

Datentyp Validierung
Integer (int)$value, max(), min()
Integer-Liste array_map(intval(...), explode(',', $value))
String (enum) in_array($value, $whitelist, true)
String (frei) Regex-Prüfung, htmlspecialchars() bei Output
Boolean (bool)$value oder filter_var($value, FILTER_VALIDATE_BOOLEAN)

Best Practice: Je weniger Parameter aus dem Request kommen, desto sicherer. Bevorzuge Plugin-Konfiguration (TypoScript/FlexForm) gegenüber Request-Parametern.

4. Trusted Properties & Identity Manipulation

KRITISCH: HMAC schützt NICHT gegen Identity-Feld-Manipulation!

Ein Angreifer kann das __identity-Feld manipulieren und so Records aktualisieren, auf die er keinen Zugriff haben sollte. Die eigene Validierung des Identity-Werts ist zwingend erforderlich.

4.1 Das Problem verstehen

Extbase verwendet transparentes Argument Mapping. Bei einem Update-Formular werden die übermittelten Properties auf das Domain-Objekt gemappt. Fluid erzeugt ein __trustedProperties Hidden-Field mit HMAC-Hash, das nur die erlaubten Properties enthält.

ABER: Das __identity-Feld kann manipuliert werden! Ein Angreifer kann die ID eines fremden Users einsetzen und dessen Daten überschreiben.

4.2 Sichere Update-Action mit Ownership-Check

Wichtig: Die Ownership-Prüfung sollte vor dem Extbase Property Mapping erfolgen, nicht erst in der Action. Andernfalls werden bei eager-loaded Relationen bereits Datenbankabfragen ausgeführt, bevor die Berechtigung geprüft wird.

Variante 1: Frühe Prüfung in initializeAction (empfohlen)

<?php
declare(strict_types=1);

use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use Psr\Log\LoggerInterface;

final class ProfileController extends ActionController
{
    public function __construct(
        private readonly Context $context,
        private readonly ProfileRepository $profileRepository,
        private readonly LoggerInterface $logger
    ) {}

    /**
     * Prüft Ownership BEVOR Extbase das Objekt mappt
     */
    public function initializeUpdateAction(): void
    {
        $profileUid = (int)($this->request->getArgument('profile')['__identity'] ?? 0);
        $currentUserId = (int)$this->context->getPropertyFromAspect('frontend.user', 'id');

        // Lightweight Query ohne eager-loading
        if (!$this->profileRepository->isOwnedByUser($profileUid, $currentUserId)) {
            $this->logger->warning('Identity manipulation attempt blocked', [
                'requested_profile_uid' => $profileUid,
                'frontend_user_id' => $currentUserId,
                'ip' => $this->request->getAttribute('normalizedParams')?->getRemoteAddress(),
            ]);
            throw new AccessDeniedException('Not authorized to edit this profile');
        }
    }

    public function updateAction(Profile $profile): ResponseInterface
    {
        // Ownership bereits in initializeUpdateAction geprüft
        $this->profileRepository->update($profile);
        // ...
    }
}

Repository-Methode für Ownership-Check:

<?php
// ProfileRepository.php
public function isOwnedByUser(int $profileUid, int $userId): bool
{
    $qb = $this->connectionPool->getQueryBuilderForTable('tx_myext_profile');

    $count = $qb->count('uid')
        ->from('tx_myext_profile')
        ->where(
            $qb->expr()->eq('uid', $qb->createNamedParameter($profileUid, Connection::PARAM_INT)),
            $qb->expr()->eq('frontend_user', $qb->createNamedParameter($userId, Connection::PARAM_INT))
        )
        ->executeQuery()
        ->fetchOne();

    return $count > 0;
}

4.3 StoragePid-Validierung für Detail-Ansichten

Bei Detail-Ansichten muss geprüft werden, ob der angeforderte Record im erlaubten Storage-Bereich liegt. Andernfalls könnte ein Angreifer durch URL-Manipulation auf Records zugreifen, die nicht für das Plugin vorgesehen sind.

<?php
declare(strict_types=1);

namespace MyVendor\MyExt\Controller;

use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use Psr\Log\LoggerInterface;

class ArticleController extends ActionController
{
    public function __construct(
        private readonly ArticleRepository $articleRepository,
        private readonly LoggerInterface $logger
    ) {}

    public function showAction(Article $article): ResponseInterface
    {
        // Prüfung: Liegt der Record im erlaubten Storage?
        if (!$this->isRecordInAllowedStorage($article)) {
            $this->logger->warning('Access to record outside allowed storage denied', [
                'record_uid' => $article->getUid(),
                'record_pid' => $article->getPid(),
                'allowed_pids' => $this->getAllowedStoragePages(),
            ]);

            return $this->handleRecordNotFound();
        }

        $this->view->assign('article', $article);
        return $this->htmlResponse();
    }

    /**
     * Prüft ob ein Record in den erlaubten Storage-Pages liegt
     */
    protected function isRecordInAllowedStorage(AbstractEntity $record): bool
    {
        $allowedPages = $this->getAllowedStoragePages();

        // Wenn keine Einschränkung konfiguriert, alle erlauben
        if (empty($allowedPages)) {
            return true;
        }

        return in_array($record->getPid(), $allowedPages, true);
    }

    /**
     * Ermittelt erlaubte Storage-Pages aus Plugin-Konfiguration
     */
    protected function getAllowedStoragePages(): array
    {
        $storagePidSetting = (string)($this->settings['storagePid'] ?? '');
        $storagePids = array_map(intval(...), explode(',', $storagePidSetting));
        $storagePids = array_filter($storagePids);

        $recursive = (int)($this->settings['recursive'] ?? 0);

        if ($recursive > 0 && !empty($storagePids)) {
            $pageRepository = GeneralUtility::makeInstance(PageRepository::class);
            return $pageRepository->getPageIdsRecursive($storagePids, $recursive);
        }

        return $storagePids;
    }

    protected function handleRecordNotFound(): ResponseInterface
    {
        // Option 1: 404 Response
        return $this->htmlResponse('Record not found')->withStatus(404);

        // Option 2: Redirect zur Liste
        // return $this->redirect('list');
    }
}

TypoScript-Konfiguration:

plugin.tx_myext.settings {
    # Erlaubte Storage-Pages (kann auch via FlexForm gesetzt werden)
    storagePid = 10,11,12

    # Rekursive Suche in Unterseiten (0 = aus)
    recursive = 2
}

5. Property Mapping Configuration & DTO-Pattern

Auch mit __trustedProperties sollten sensible Properties explizit geschützt werden.

Wichtig: Verwende entweder einen Whitelist-Ansatz (allowProperties) oder einen Blacklist-Ansatz (skipProperties), nicht beides gemischt. Der Whitelist-Ansatz ist sicherer, da neue Properties automatisch blockiert werden.

Empfohlen: Whitelist-Ansatz

<?php
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

final class UserController extends ActionController
{
    public function initializeUpdateAction(): void
    {
        $config = $this->arguments['user']
            ->getPropertyMappingConfiguration();

        // WHITELIST: Nur diese Properties erlauben - alles andere wird abgelehnt
        $config->allowProperties(
            'firstName', 'lastName', 'email', 'description'
        );

        // Unbekannte Properties ablehnen (Fail Secure)
        $config->skipUnknownProperties();
    }
}

Alternative: Blacklist-Ansatz (weniger sicher)

<?php
public function initializeUpdateAction(): void
{
    $config = $this->arguments['user']
        ->getPropertyMappingConfiguration();

    // BLACKLIST: Diese Properties niemals akzeptieren
    // Achtung: Neue Properties werden automatisch erlaubt!
    $config->skipProperties(
        'isAdmin', 'role', 'password', 'usergroup'
    );
}

Warnung: Der Blacklist-Ansatz ist anfällig für neue Properties, die später zum Model hinzugefügt werden. Diese würden automatisch akzeptiert, wenn sie nicht explizit geblacklisted sind.

Warnung bei Relationen: Wenn man Relationen (z.B. 1:n FileReferences) erlaubt, versucht der Property Mapper, diese aufzulösen. Bei Datei-Uploads direkt am Domain-Model ist extreme Vorsicht geboten!

5.1 Best Practice: Data Transfer Objects (DTO)

Für komplexe Formulare (User Profile, Registrierung) ist ein DTO oft sicherer als die direkte Domain Entity:

<?php
declare(strict_types=1);

// Das DTO hat NUR die Felder, die das Formular benötigt
final class ProfileUpdateDto
{
    public function __construct(
        #[Validate(['validator' => 'NotEmpty'])]
        #[Validate(['validator' => 'EmailAddress'])]
        public readonly string $email,

        #[Validate(['validator' => 'StringLength', 
            'options' => ['minimum' => 2, 'maximum' => 50]])]
        public readonly string $firstName,

        #[Validate(['validator' => 'StringLength', 
            'options' => ['minimum' => 2, 'maximum' => 50]])]
        public readonly string $lastName,
    ) {}
}

Vorteile des DTO-Patterns:
- Mass Assignment per Design verhindert
- Klare Trennung zwischen Formular-Daten und Persistenz
- Einfacher zu testen
- Keine Property Mapping Configuration nötig

6. SQL Injection Defense

Extbase-Queries werden automatisch escaped. Bei manuellen Queries gilt:

NIEMALS: $query->statement('SELECT * FROM users WHERE name = "' . $name . '"')

IMMER: createNamedParameter() für jeden User-Input verwenden!

6.1 Sichere Query-Patterns

Integer-Parameter:

<?php
$queryBuilder->expr()->eq(
    'sys_language_uid', 
    $queryBuilder->createNamedParameter($language, Connection::PARAM_INT)
)

Integer-Arrays (z.B. für IN-Clauses):

<?php
$queryBuilder->expr()->in(
    'category', 
    $queryBuilder->createNamedParameter($categoryIds, Connection::PARAM_INT_ARRAY)
)

String-Parameter:

<?php
$queryBuilder
    ->select('*')
    ->from('tx_myext_domain_model_article')
    ->where(
        $queryBuilder->expr()->eq(
            'slug', 
            $queryBuilder->createNamedParameter($slug, Connection::PARAM_STR)
        )
    );

Kombinierte Bedingungen (z.B. MM-Relationen):

<?php
$queryBuilder
    ->select('a.*')
    ->from('tx_myext_domain_model_article', 'a')
    ->join(
        'a',
        'sys_category_record_mm',
        'mm',
        $queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->quoteIdentifier('a.uid'))
    )
    ->where(
        $queryBuilder->expr()->eq(
            'mm.tablenames', 
            $queryBuilder->createNamedParameter('tx_myext_domain_model_article', Connection::PARAM_STR)
        ),
        $queryBuilder->expr()->eq(
            'mm.uid_local', 
            $queryBuilder->createNamedParameter($categoryId, Connection::PARAM_INT)
        )
    );

6.2 LIKE-Queries mit Wildcard-Escaping

<?php
public function findBySearchTerm(string $term): array
{
    $qb = $this->connectionPool
        ->getQueryBuilderForTable('tx_myext_user');

    // LIKE mit vollständigem Wildcard-Escaping
    // Wichtig: Backslash muss ebenfalls escaped werden (MySQL Escape-Character)
    $escapedTerm = addcslashes($term, '%_\\');

    return $qb->select('*')
        ->from('tx_myext_user')
        ->where(
            $qb->expr()->like(
                'name',
                $qb->createNamedParameter('%' . $escapedTerm . '%')
            )
        )
        ->executeQuery()
        ->fetchAllAssociative();
}

Hinweis: Der Backslash (\) ist in MySQL der Standard-Escape-Character für LIKE-Patterns. Ohne Escaping könnte ein Angreifer mit \% oder \_ die Wildcard-Bedeutung aufheben und unerwartete Ergebnisse erzeugen.

7. XSS-Prävention in Fluid

Fluid escaped Output automatisch mit htmlspecialchars() – aber nur für .html-Templates. Es gibt gefährliche Ausnahmen.

7.1 Gefährliche ViewHelper

NIEMALS User-Input an f:format.raw übergeben!

<!-- UNSICHER: XSS-Lücke! -->
<f:format.raw>{userComment}</f:format.raw>
{userComment -> f:format.raw()}

Sichere Alternativen:

<!-- TYPO3 13+: HTML Sanitizer (Symfony-basiert) -->
<f:sanitize.html>{userContent}</f:sanitize.html>

<!-- TYPO3 12: parseFunc mit strikter Konfiguration -->
<f:format.html>{richTextContent}</f:format.html>

Hinweis für TYPO3 12: Der f:sanitize.html ViewHelper existiert dort nicht im Core. Entwickler müssen auf f:format.html mit einer strikten lib.parseFunc Konfiguration zurückgreifen.

7.2 ViewHelper-Argumente sind nicht escaped

Wichtig: Inhalte in ViewHelper-Argumenten werden NICHT automatisch escaped!

{variable1}  <!-- wird escaped -->
<f:format.crop append="{variable2}">...</f:format.crop>
<!-- variable2 wird NICHT escaped! -->

7.3 Content Security Policy

PHP-basierte Konfiguration (für Extensions)

<?php
// Configuration/ContentSecurityPolicies.php
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Directive;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Mutation;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\MutationCollection;
use TYPO3\CMS\Core\Security\ContentSecurityPolicy\SourceScheme;

return new MutationCollection(
    new Mutation(Directive::ScriptSrc, SourceScheme::self),
    new Mutation(Directive::StyleSrc, SourceScheme::self),
    new Mutation(Directive::ScriptSrc, 'https://cdn.example.com'),
);

YAML-basierte Konfiguration (TYPO3 13+, für Integratoren)

# config/sites/<site>/csp.yaml
frontend:
  inheritDefault: true
  mutations:
    - directive: script-src
      sources:
        - "'self'"
        - "https://cdn.example.com"

Empfehlung: Für Integratoren ist der YAML-Weg oft einfacher zu warten. Der PHP-Weg ist für Extensions, die eigene Policies erzwingen müssen, weiterhin korrekt.

8. CSRF-Schutz

8.1 Fluid-Formulare

Das f:form ViewHelper generiert automatisch __trustedProperties und CSRF-Token:

<f:form action="update" object="{user}">
    <f:form.textfield property="email" />
    <f:form.submit value="Speichern" />
</f:form>

8.2 AJAX mit RequestToken (TYPO3 13+)

Wichtig: Das Token muss serverseitig beim Rendern der Delete-Schaltfläche generiert werden, nicht aus Request-Daten abgeleitet. Andernfalls könnte ein Angreifer ein gültiges Token für beliebige UIDs erzeugen.

Schritt 1: Token beim Rendern generieren (Controller oder ViewHelper)

<?php
// Im Controller beim Rendern der Liste
use TYPO3\CMS\Core\Security\RequestToken;

public function listAction(): ResponseInterface
{
    $items = $this->itemRepository->findAll();

    // Token pro Item generieren
    $itemsWithTokens = [];
    foreach ($items as $item) {
        $itemsWithTokens[] = [
            'item' => $item,
            'deleteToken' => RequestToken::create('my_ext_delete_' . $item->getUid())->toHashSignedJwt(),
        ];
    }

    $this->view->assign('items', $itemsWithTokens);
    return $this->htmlResponse();
}

Schritt 2: Token bei AJAX-Request verifizieren

<?php
use TYPO3\CMS\Core\Security\RequestToken;
use Psr\Log\LoggerInterface;

final class AjaxController
{
    public function __construct(
        private readonly ItemRepository $itemRepository,
        private readonly LoggerInterface $logger
    ) {}

    public function deleteAction(ServerRequestInterface $request): ResponseInterface
    {
        $token = $request->getHeaderLine('X-TYPO3-RequestToken');
        $itemUid = (int)($request->getParsedBody()['uid'] ?? 0);

        // 1. Prüfen ob Item existiert (verhindert Enumeration)
        $item = $this->itemRepository->findByUid($itemUid);
        if ($item === null) {
            return new JsonResponse(['error' => 'Not found'], 404);
        }

        // 2. Token gegen die EXISTIERENDE Item-UID verifizieren
        $scope = 'my_ext_delete_' . $item->getUid();
        if (!RequestToken::verify($token, $scope)) {
            $this->logger->warning('Invalid CSRF token for delete action', [
                'item_uid' => $itemUid,
                'ip' => $request->getAttribute('normalizedParams')?->getRemoteAddress(),
            ]);
            return new JsonResponse(['error' => 'Invalid token'], 403);
        }

        // 3. Löschen durchführen
        $this->itemRepository->remove($item);
        return new JsonResponse(['success' => true]);
    }
}

JavaScript Frontend:

async function deleteItem(uid, token) {
    const response = await fetch('/api/item/delete', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-TYPO3-RequestToken': token  // Token aus data-Attribut
        },
        body: JSON.stringify({ uid: uid })
    });
    return response.json();
}

Best Practice: Der Scope enthält die UID des betroffenen Objekts. Da das Token serverseitig beim Rendern generiert wird, kann ein Angreifer kein gültiges Token für fremde UIDs erzeugen.

9. Sichere Datei-Uploads

<?php
use TYPO3\CMS\Core\Resource\Security\FileNameValidator;
use Psr\Log\LoggerInterface;

final class UploadService
{
    private const ALLOWED_EXTENSIONS = ['pdf', 'jpg', 'png'];
    private const ALLOWED_MIME_TYPES = [
        'pdf' => 'application/pdf',
        'jpg' => 'image/jpeg',
        'png' => 'image/png',
    ];
    private const MAX_SIZE = 5 * 1024 * 1024;

    public function __construct(
        private readonly FileNameValidator $validator,
        private readonly LoggerInterface $logger
    ) {}

    public function validate(UploadedFileInterface $file, string $clientIp = ''): void
    {
        $filename = $file->getClientFilename();

        // 1. Path Traversal Protection
        if ($this->containsPathTraversal($filename)) {
            $this->logger->warning('Path traversal attempt in upload', [
                'filename' => $filename,
                'ip' => $clientIp,
            ]);
            throw new InvalidFileException('Invalid filename');
        }

        $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));

        // 2. Extension Whitelist
        if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
            throw new InvalidFileException('Extension not allowed');
        }

        // 3. TYPO3 FileNameValidator (blockt .php, .htaccess, etc.)
        if (!$this->validator->isValid($filename)) {
            throw new InvalidFileException('Filename blocked');
        }

        // 4. Größenlimit
        if ($file->getSize() > self::MAX_SIZE) {
            throw new InvalidFileException('File too large');
        }

        // 5. MIME-Type prüfen (nicht nur Extension!)
        $finfo = new \finfo(FILEINFO_MIME_TYPE);
        $actualMime = $finfo->buffer($file->getStream()->getContents());
        $expectedMime = self::ALLOWED_MIME_TYPES[$ext] ?? null;

        if ($actualMime !== $expectedMime) {
            $this->logger->warning('MIME type mismatch in upload', [
                'filename' => $filename,
                'expected_mime' => $expectedMime,
                'actual_mime' => $actualMime,
                'ip' => $clientIp,
            ]);
            throw new InvalidFileException('Invalid file type');
        }
    }

    /**
     * Prüft auf Path Traversal Versuche im Dateinamen
     */
    private function containsPathTraversal(string $filename): bool
    {
        // Null-Bytes (können Dateiendung-Checks umgehen)
        if (str_contains($filename, "\0")) {
            return true;
        }

        // Directory Traversal Sequenzen
        if (str_contains($filename, '..')) {
            return true;
        }

        // Absolute oder relative Pfade
        if (str_contains($filename, '/') || str_contains($filename, '\\')) {
            return true;
        }

        return false;
    }
}

Hinweis: Der FileNameValidator von TYPO3 blockiert gefährliche Dateiendungen wie .php, .phtml, .htaccess. Die zusätzliche Path-Traversal-Prüfung verhindert Angriffe wie ../../../etc/passwd oder file.php\0.jpg.

10. Security Logging & Monitoring

Sicherheitsrelevante Events sollten geloggt werden, um Angriffe zu erkennen und forensisch analysieren zu können.

10.1 Logging-Strategie

<?php
declare(strict_types=1);

use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;

final class SecurityLogger
{
    public function __construct(
        private readonly LoggerInterface $logger
    ) {}

    /**
     * Loggt sicherheitsrelevante Events mit strukturierten Daten
     */
    public function logSecurityEvent(
        string $event,
        string $level,
        array $context,
        ?ServerRequestInterface $request = null
    ): void {
        // Standardisierte Context-Daten
        $context = array_merge([
            'event_type' => $event,
            'timestamp' => (new \DateTimeImmutable())->format('c'),
        ], $context);

        // Request-Daten hinzufügen (wenn verfügbar)
        if ($request !== null) {
            $context['ip'] = $request->getAttribute('normalizedParams')?->getRemoteAddress();
            $context['user_agent'] = $request->getHeaderLine('User-Agent');
            $context['request_uri'] = $request->getUri()->getPath();
        }

        $this->logger->log($level, "Security: {$event}", $context);
    }
}

10.2 Wichtige Events zum Loggen

Event Log Level Context-Daten
Identity Manipulation WARNING requested_uid, user_id, ip
Invalid CSRF Token WARNING action, ip, user_agent
Path Traversal Attempt WARNING filename, ip
MIME Type Mismatch WARNING filename, expected, actual
Failed Login (Brute-Force) NOTICE username, ip, attempt_count
Access Denied NOTICE resource, user_id, ip
Successful Admin Login INFO user_id, ip

10.3 Logger-Konfiguration

# config/system/settings.php oder AdditionalConfiguration.php
$GLOBALS['TYPO3_CONF_VARS']['LOG']['MyVendor']['MyExt']['Security']['writerConfiguration'] = [
    \Psr\Log\LogLevel::WARNING => [
        \TYPO3\CMS\Core\Log\Writer\FileWriter::class => [
            'logFile' => \TYPO3\CMS\Core\Core\Environment::getVarPath() . '/log/security.log',
        ],
        // Optional: Syslog für zentrale Log-Aggregation
        \TYPO3\CMS\Core\Log\Writer\SyslogWriter::class => [
            'facility' => LOG_LOCAL4,
        ],
    ],
];

Best Practice: Security-Logs sollten getrennt von Application-Logs geführt werden, um einfachere Analyse und längere Aufbewahrung zu ermöglichen.

11. Rate Limiting & Brute-Force-Schutz

Rate Limiting schützt vor automatisierten Angriffen wie Brute-Force-Login-Versuchen oder API-Missbrauch.

11.1 TYPO3 12: Manuelles Rate Limiting

<?php
declare(strict_types=1);

use TYPO3\CMS\Core\Cache\CacheManager;
use Psr\Log\LoggerInterface;

final class RateLimiter
{
    private const MAX_ATTEMPTS = 5;
    private const WINDOW_SECONDS = 300; // 5 Minuten

    public function __construct(
        private readonly CacheManager $cacheManager,
        private readonly LoggerInterface $logger
    ) {}

    /**
     * Prüft und erhöht den Zähler für eine Aktion
     * 
     * @throws TooManyRequestsException
     */
    public function checkAndIncrement(string $action, string $identifier): void
    {
        $cache = $this->cacheManager->getCache('myext_ratelimit');
        $key = $this->buildKey($action, $identifier);

        $attempts = (int)($cache->get($key) ?: 0);

        if ($attempts >= self::MAX_ATTEMPTS) {
            $this->logger->notice('Rate limit exceeded', [
                'action' => $action,
                'identifier' => $identifier,
                'attempts' => $attempts,
            ]);
            throw new TooManyRequestsException(
                'Too many attempts. Please try again later.',
                1700000001
            );
        }

        $cache->set($key, $attempts + 1, ['ratelimit'], self::WINDOW_SECONDS);
    }

    private function buildKey(string $action, string $identifier): string
    {
        return hash('sha256', $action . ':' . $identifier);
    }
}

Cache-Konfiguration:

// ext_localconf.php
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['myext_ratelimit'] ??= [];
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['myext_ratelimit']['backend'] 
    ??= \TYPO3\CMS\Core\Cache\Backend\Typo3DatabaseBackend::class;

11.2 TYPO3 14+: Symfony RateLimiter

Ab TYPO3 14 kann der Symfony RateLimiter direkt genutzt werden:

<?php
declare(strict_types=1);

use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\CacheStorage;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;

final class LoginController
{
    private RateLimiterFactory $loginLimiter;

    public function __construct()
    {
        $this->loginLimiter = new RateLimiterFactory([
            'id' => 'login',
            'policy' => 'sliding_window',
            'limit' => 5,
            'interval' => '5 minutes',
        ], new CacheStorage(new FilesystemAdapter()));
    }

    public function loginAction(ServerRequestInterface $request): ResponseInterface
    {
        $ip = $request->getAttribute('normalizedParams')?->getRemoteAddress();
        $limiter = $this->loginLimiter->create($ip);

        if (!$limiter->consume()->isAccepted()) {
            return new JsonResponse(
                ['error' => 'Too many login attempts'],
                429
            );
        }

        // Login-Logik...
    }
}

11.3 Anwendungsfälle für Rate Limiting

Aktion Empfohlenes Limit Identifier
Login 5 pro 5 Min IP + Username
Passwort-Reset 3 pro 15 Min E-Mail
API-Aufrufe 100 pro Min API-Key oder IP
Formular-Submit 10 pro Min IP + Session
Datei-Upload 20 pro Stunde User-ID

Hinweis: Kombiniere IP und User-Identifier, um sowohl Distributed Attacks (viele IPs, ein User) als auch Single-IP Attacks (eine IP, viele User) zu erkennen.

12. Security Checklist

# Check Backend Frontend
1 Input Validation: TCA-Typen (email, url) verwendet?
2 Input Validation: #[Validate] Attributes im Domain Model?
3 Strict Typing: Typisierte Properties (string, int, bool)?
4 Request-Filter: Parameter validiert (Type-Cast, Whitelist, Grenzwerte)?
5 Settings bevorzugt: Filter aus TypoScript/FlexForm statt Request?
6 Identity Check: Ownership-Validierung in initializeAction vor Mapping?
7 Property Mapping: Whitelist mit allowProperties() + skipUnknownProperties()?
8 SQL: createNamedParameter() für alle User-Inputs?
9 SQL: LIKE-Wildcards escaped inkl. Backslash (%_\\)?
10 XSS: Kein f:format.raw mit User-Content?
11 CSRF: RequestToken serverseitig beim Rendern generiert?
12 Uploads: Path Traversal + Extension + MIME-Type + FileNameValidator?
13 CSP: ContentSecurityPolicies.php oder YAML konfiguriert?
14 Logging: Security-Events geloggt (Identity, CSRF, Upload-Fehler)?
15 Rate Limiting: Brute-Force-Schutz für Login/sensitive Actions?

13. Migrationspfade

v11 → v12

  1. @validate Annotations → #[Validate] Attributes migrieren
  2. Untypisierte Properties → Typed Properties (protected string $email)
  3. CSP Report-Only aktivieren und Violations monitoren
  4. TCA eval Optionen → dedizierte TCA-Typen (type => 'email')

v12 → v13

  1. RequestToken API für AJAX-Endpoints mit spezifischen Scopes
  2. f:sanitize.html für Rich-Text statt f:format.html
  3. CSP-Konfiguration: Backend-Modul und YAML-Support nutzen
  4. Doctrine DBAL 4.x API-Änderungen beachten

v13 → v14

  1. CSP im Frontend aktivieren und testen
  2. PHP 8.3 Features (Typed Class Constants) nutzen
  3. Symfony RateLimiter für Brute-Force-Schutz integrieren

14. Weiterführende Links

Offizielle Dokumentation:
- TYPO3 Security Guidelines for Extension Developers
- TYPO3 Extbase Validation Reference
- TYPO3 Content Security Policy
- TYPO3 Doctrine DBAL

Referenz-Extensions:
- EXT:news – Umfangreiche News-Extension mit Demand-Pattern
- EXT:blog – Offizielle Blog-Extension der TYPO3 GmbH

15. Fazit

Die wichtigsten Erkenntnisse aus den offiziellen TYPO3 Security Guidelines:

Identity & Authorization:
- __trustedProperties schützt NICHT vor Identity Manipulation
- Ownership-Checks gehören in initializeAction() vor das Property Mapping
- StoragePid-Validierung verhindert Zugriff auf Records außerhalb erlaubter Bereiche

Input Handling:
- TCA validiert Backend, Domain Model Annotations validieren Frontend
- Request-Parameter immer validieren: Type-Cast, Whitelist, Grenzwerte
- Filter-Werte bevorzugt aus TypoScript/FlexForm statt aus Request
- Property Mapping: Whitelist-Ansatz mit allowProperties() + skipUnknownProperties() bevorzugen

Output & Queries:
- ViewHelper-Argumente werden NICHT automatisch escaped
- Alle DB-Queries gehören ins Repository mit createNamedParameter()
- LIKE-Queries: Backslash (\) zusätzlich zu % und _ escapen

Uploads & CSRF:
- Path Traversal im Dateinamen prüfen (..\, /, \0)
- RequestTokens serverseitig beim Rendern generieren, nicht aus Request-Daten ableiten

Monitoring & Defense:
- Security-Events loggen für Forensik und Monitoring
- Rate Limiting für Login, Passwort-Reset und sensitive API-Endpoints
- DTOs sind für komplexe Formulare sicherer als direkte Domain Entities

Tipp: Studiere den Source-Code etablierter Extensions wie EXT:news und EXT:blog – sie demonstrieren viele Security-Best-Practices in produktionsreifem Code.