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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user