Client-Error-Endpoint POST /api/vitec/client-error (0072-FE / 0077-BE)
Neue PSR-15-Middleware ClientErrorMiddleware: nimmt Browser-Fehler des
React-Frontends als JSON {url, message, stack, release, userAgent}
entgegen und antwortet 204 ohne Body. Schutz: 30 Meldungen pro Minute
und IP (Sliding Window ueber TYPO3-RateLimiter, 429 + Retry-After),
8-KB-Body-Limit (413, Content-Length und tatsaechliche Bytes), kein
JSON-Objekt oder leere message = 400, GET = 405. Keine Session, keine
Cookies; der /_json-Praefix funktioniert ebenfalls.
Speicherung in der neuen Tabelle tx_vitec_clienterrorlog mit
anonymisierter IP (IPv4 letztes Oktett 0, IPv6 /48). Sichtbar als
Tabelle "Client errors (last 24 h)" im Modul VITEC SSR (mit Stack als
Aufklapper) und als Zeile "Client errors reported (24 h / 7 d)" in der
Dashboard-Kachel VITEC: SSR statistics.
CLI vitec:client-error-cleanup [--days=30] loescht Eintraege aelter als
30 Tage (taeglich per Cron).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
ff6f86b829
commit
5121eaa000
7 files changed
+360
No files matched your search
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Http\NormalizedParams;
|
||||
use TYPO3\CMS\Core\Http\Response;
|
||||
use TYPO3\CMS\Core\RateLimiter\RateLimiterFactory;
|
||||
|
||||
/**
|
||||
* Client error endpoint: POST /api/vitec/client-error
|
||||
*
|
||||
* The React frontend reports browser-side failures here (window.onerror,
|
||||
* unhandledrejection, chunk reloads after a deploy, element error
|
||||
* boundaries) - contract 0072-FE section 4, accepted in 0077-BE:
|
||||
*
|
||||
* {url, message, stack, release, userAgent} -> 204 No Content
|
||||
*
|
||||
* Guards, all answered with a 4xx and no body:
|
||||
* 405 method other than POST
|
||||
* 413 body larger than 8 KB (Content-Length or actual bytes)
|
||||
* 400 not a JSON object, or "message" missing/empty
|
||||
* 429 more than 30 reports per minute from one IP (sliding window
|
||||
* via TYPO3's rate limiter, cache "ratelimiter")
|
||||
*
|
||||
* Entries land in tx_vitec_clienterrorlog with the IP anonymized (IPv4
|
||||
* last octet zeroed, IPv6 cut to /48); the full IP is only the key of
|
||||
* the rate limiter and is never stored. No session, no cookies - a
|
||||
* navigator.sendBeacon() call must get through unchanged. Rows older
|
||||
* than 30 days are removed by vitec:client-error-cleanup.
|
||||
*/
|
||||
final class ClientErrorMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private const PATH = '/api/vitec/client-error';
|
||||
private const MAX_BODY_BYTES = 8192;
|
||||
private const LIMIT_PER_MINUTE = 30;
|
||||
private const TABLE = 'tx_vitec_clienterrorlog';
|
||||
|
||||
public function __construct(
|
||||
private readonly ConnectionPool $connectionPool,
|
||||
private readonly RateLimiterFactory $rateLimiterFactory,
|
||||
) {}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$path = rtrim($request->getUri()->getPath(), '/');
|
||||
if ($path !== self::PATH) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$method = strtoupper($request->getMethod());
|
||||
if ($method === 'OPTIONS') {
|
||||
return $this->empty(204)->withHeader('Allow', 'POST, OPTIONS');
|
||||
}
|
||||
if ($method !== 'POST') {
|
||||
return $this->empty(405)->withHeader('Allow', 'POST, OPTIONS');
|
||||
}
|
||||
|
||||
$declared = (int)$request->getHeaderLine('Content-Length');
|
||||
if ($declared > self::MAX_BODY_BYTES) {
|
||||
return $this->empty(413);
|
||||
}
|
||||
|
||||
// Rate limit first, so a flood of oversized or broken bodies is
|
||||
// cheap to refuse as well.
|
||||
$ip = '';
|
||||
try {
|
||||
$normalized = $request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request);
|
||||
$ip = (string)$normalized->getRemoteAddress();
|
||||
$limiter = $this->rateLimiterFactory->createLimiter([
|
||||
'id' => 'vitec-client-error',
|
||||
'policy' => 'sliding_window',
|
||||
'limit' => self::LIMIT_PER_MINUTE,
|
||||
'interval' => '1 minute',
|
||||
], $ip);
|
||||
$limit = $limiter->consume(1);
|
||||
if (!$limit->isAccepted()) {
|
||||
$retry = max(1, $limit->getRetryAfter()->getTimestamp() - time());
|
||||
return $this->empty(429)->withHeader('Retry-After', (string)$retry);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// A broken limiter must not take the endpoint down; the
|
||||
// body limit and the 30-day cleanup still bound the damage.
|
||||
}
|
||||
|
||||
$body = $request->getBody();
|
||||
if ($body->isSeekable()) {
|
||||
$body->rewind();
|
||||
}
|
||||
$raw = $body->read(self::MAX_BODY_BYTES + 1);
|
||||
if (strlen($raw) > self::MAX_BODY_BYTES) {
|
||||
return $this->empty(413);
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
return $this->empty(400);
|
||||
}
|
||||
if (!\is_array($data) || array_is_list($data)) {
|
||||
return $this->empty(400);
|
||||
}
|
||||
$message = trim($this->str($data['message'] ?? ''));
|
||||
if ($message === '') {
|
||||
return $this->empty(400);
|
||||
}
|
||||
|
||||
$row = [
|
||||
'pid' => 0,
|
||||
'crdate' => time(),
|
||||
'url' => $this->cut($this->str($data['url'] ?? ''), 1024),
|
||||
'message' => $this->cut($message, 2000),
|
||||
'stack' => $this->cut($this->str($data['stack'] ?? ''), 6000),
|
||||
'release' => $this->cut($this->str($data['release'] ?? ''), 100),
|
||||
'user_agent' => $this->cut($this->str($data['userAgent'] ?? $request->getHeaderLine('User-Agent')), 500),
|
||||
'ip' => self::anonymizeIp($ip),
|
||||
];
|
||||
try {
|
||||
$this->connectionPool->getConnectionForTable(self::TABLE)->insert(self::TABLE, $row);
|
||||
} catch (\Throwable) {
|
||||
// Table missing (extension:setup not run yet) or DB hiccup:
|
||||
// the client gets its 204 anyway - a reporting endpoint must
|
||||
// never turn into a second error in the browser console.
|
||||
}
|
||||
|
||||
return $this->empty(204);
|
||||
}
|
||||
|
||||
/**
|
||||
* IPv4: last octet zeroed (192.0.2.0). IPv6: first three groups
|
||||
* kept (2001:db8:1::). Anything else: empty.
|
||||
*/
|
||||
public static function anonymizeIp(string $ip): string
|
||||
{
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
|
||||
$parts = explode('.', $ip);
|
||||
$parts[3] = '0';
|
||||
return implode('.', $parts);
|
||||
}
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
|
||||
$packed = inet_pton($ip);
|
||||
if ($packed === false) {
|
||||
return '';
|
||||
}
|
||||
$hex = bin2hex($packed);
|
||||
$groups = [];
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$groups[] = ltrim(substr($hex, $i * 4, 4), '0') ?: '0';
|
||||
}
|
||||
return implode(':', $groups) . '::';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function str(mixed $value): string
|
||||
{
|
||||
if (\is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (\is_int($value) || \is_float($value) || \is_bool($value)) {
|
||||
return (string)$value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function cut(string $value, int $max): string
|
||||
{
|
||||
$value = str_replace("\0", '', $value);
|
||||
return mb_strlen($value) > $max ? mb_substr($value, 0, $max) : $value;
|
||||
}
|
||||
|
||||
private function empty(int $status): ResponseInterface
|
||||
{
|
||||
return (new Response())
|
||||
->withStatus($status)
|
||||
->withHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user