fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann

- API: ausdrueckliche iconUrl wird nur auf Form (http/https, <= 2048) geprueft
  und auch gespeichert, wenn der Server sie nicht abrufen kann; keine 422 mehr
- Erkennung: Seite mit Fehlerstatus, aber HTML mit <link rel=icon>, liefert
  diesen Verweis (docuvita); og:image einer Fehlerseite zaehlt nicht
- Kachel: Proxy -> iconUrl direkt im Browser (no-referrer, nur http/https) ->
  Origin-Favicon -> Buchstabe
- Meldung iconUrlUnreachable (de/en) entfernt, Hinweis zum Vorrang angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 15:34:58 +02:00
parent 7188c5b958
commit b15c74632b
12 changed files with 307 additions and 112 deletions
+42 -26
View File
@@ -9,7 +9,6 @@ import {
Logger,
NotFoundException,
PayloadTooLargeException,
UnprocessableEntityException,
} from '@nestjs/common';
import type { UploadedFileLike } from '../auth/types/auth-user';
import { PrismaService } from '../prisma/prisma.service';
@@ -77,12 +76,16 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
* Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
* Dateifehler wird protokolliert und geschluckt.
* - Abrufprobe: `assertIconUrlLoadable()` ruft `fetchIconBytes` einmal ab,
* um eine im Formular NICHT abrufbare Logo-Adresse (z. B. hinter einer
* Cloudflare-Pruefung) mit `UnprocessableEntityException` (422) statt
* stiller Speicherung abzuweisen — keine Umgehung von Bot-Sperren, nur
* derselbe Abruf, den `GET /favorites/:id/icon` ohnehin ausloest.
* - 260929-lh3 (loest die Abrufprobe von 260923-lrr ab): eine ausdrueckliche
* Logo-Adresse wird nur auf Form (http/https, <= 2048 Zeichen) geprueft und
* auch gespeichert, wenn der Server sie nicht abrufen kann — der Browser der
* Kachel laedt sie dann direkt. Ein hochgeladenes Symbol wird von einer
* neuen, abweichenden Adresse verdraengt (Vorrang der Datei sonst: Adresse
* gespeichert, aber unsichtbar).
*/
/** Hoechstlaenge einer ausdruecklichen Logo-Adresse (260929-lh3). */
const ICON_URL_MAX_LENGTH = 2048;
@Injectable()
export class FavoritesService {
private readonly logger = new Logger(FavoritesService.name);
@@ -107,19 +110,31 @@ export class FavoritesService {
}
/**
* Prueft, ob sich das Bild unter `iconUrl` serverseitig abrufen laesst
* (260923-lrr) — derselbe `fetchIconBytes`-Aufruf, den `getIconBytes`
* ohnehin ausloest, hier nur zur Speicherzeit als Probe. Jeder Fehler
* (SSRF-Ablehnung, Zeitgrenze, kein `image/*`, Cloudflare-Pruefung o. ae.)
* wird zu derselben deutschen 422-Meldung — keine Unterscheidung, aus der
* sich etwas ueber die gepruefte Adresse ablesen liesse.
* Prueft eine ausdruecklich eingetragene Logo-Adresse NUR auf Form (260929-lh3):
* gueltige http/https-Adresse, hoechstens 2048 Zeichen. Bewusst KEIN
* serverseitiger Abruf mehr — Server wie docuvita liefern dem Server ein
* 404/HTML, dem Browser aber das Bild; die fruehere Abrufprobe (422,
* 260923-lrr) machte genau diese Adressen unspeicherbar. Entscheidung: auch
* eine Antwort, die der Server sieht und die kein Bild ist, weist NICHT ab —
* "Server bekommt kein Bild" heisst nicht "Browser bekommt keins", und der
* Server kann beides nicht unterscheiden. Der SSRF-Schutz bleibt unveraendert
* dort, wo der Server tatsaechlich abruft (`getIconBytes`/Erkennung); scheitert
* der Proxy, laedt die Kachel die Adresse direkt im Browser.
*/
private async assertIconUrlLoadable(iconUrl: string): Promise<void> {
private assertIconUrlWellFormed(iconUrl: string): void {
let parsed: URL | null = null;
try {
await this.iconDiscovery.fetchIconBytes(iconUrl);
parsed = new URL(iconUrl);
} catch {
throw new UnprocessableEntityException(
'Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.',
parsed = null;
}
if (
parsed === null ||
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
iconUrl.length > ICON_URL_MAX_LENGTH
) {
throw new BadRequestException(
'Die Logo-Adresse muss eine gültige http- oder https-Adresse sein (höchstens 2048 Zeichen).',
);
}
}
@@ -129,8 +144,8 @@ export class FavoritesService {
* Verifies the target widget belongs to the caller BEFORE any icon
* discovery network call (T-GWH-05).
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
* If iconUrl IS provided (260923-lrr), it must load successfully or the
* create is rejected with 422 — nothing is written on a failed probe.
* If iconUrl IS provided it is stored as given after a form check only
* (260929-lh3, see assertIconUrlWellFormed) — no server-side fetch.
*/
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -154,8 +169,8 @@ export class FavoritesService {
let iconUrl = dto.iconUrl ?? null;
if (iconUrl) {
// 260923-lrr: explizit uebergebene Adresse wird einmal probiert.
await this.assertIconUrlLoadable(iconUrl);
// 260929-lh3: nur Formpruefung, kein serverseitiger Abruf.
this.assertIconUrlWellFormed(iconUrl);
} else {
// Server-side icon discovery (D-05) — only when caller did not supply an icon
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
@@ -179,10 +194,11 @@ export class FavoritesService {
* Verifies userId ownership before applying changes (T-08-06).
* Accepts null as an explicit value for iconUrl (clears stored icon).
*
* 260923-lrr: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
* durchlaeuft die Abrufprobe (`assertIconUrlLoadable`), bevor irgendetwas
* geschrieben wird; misslingt sie, bleibt die Zeile unveraendert. Jede
* tatsaechliche Aenderung der Symbolquelle erhoeht `iconVersion`.
* 260929-lh3: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
* durchlaeuft nur die Formpruefung (`assertIconUrlWellFormed`), bevor
* irgendetwas geschrieben wird; sie wird auch gespeichert, wenn der Server
* sie nicht abrufen kann. Jede tatsaechliche Aenderung der Symbolquelle
* erhoeht `iconVersion`.
*/
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -205,8 +221,8 @@ export class FavoritesService {
if ('iconUrl' in dto) {
if (dto.iconUrl) {
if (dto.iconUrl !== link.iconUrl) {
// 260923-lrr: nur eine NEUE, abweichende Adresse wird probiert.
await this.assertIconUrlLoadable(dto.iconUrl);
// 260929-lh3: nur eine NEUE, abweichende Adresse wird geprueft (Form).
this.assertIconUrlWellFormed(dto.iconUrl);
}
// Explicit icon URL supplied — respect it as-is.
data.iconUrl = dto.iconUrl;