fix(desktop,favorites): keine zweite Update-Installation, Lesegrenzen bei der Symbolsuche

- Desktop: Merker "Installation laeuft" sperrt Pruefschleife und Klick; ein angebotenes Update bleibt nach fehlgeschlagener Pruefung per Klick installierbar
- Desktop: Benachrichtigungsrecht erst nach erfolgreichem add_capability vermerken
- Favoriten: HTML nur bis MAX_HTML_CHARS und hoechstens 4 s lesen, Nicht-HTML-Antworten verwerfen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 03:20:16 +02:00
parent c2e4467dd8
commit 071082983b
3 changed files with 450 additions and 91 deletions
@@ -18,24 +18,21 @@ vi.mock('undici', () => ({
import { Agent } from 'undici';
import {
discardBody,
IconDiscoveryService,
isPublicHttpUrl,
normalizeUrl,
readTextCapped,
} from './icon-discovery.service';
function mockResponse(options: {
contentType?: string;
body?: ArrayBuffer;
}): Response {
function mockResponse(options: { contentType?: string; body?: ArrayBuffer }): Response {
const body = options.body ?? new ArrayBuffer(10);
return {
ok: true,
status: 200,
headers: {
get: (name: string) =>
name.toLowerCase() === 'content-type'
? (options.contentType ?? 'image/png')
: null,
name.toLowerCase() === 'content-type' ? (options.contentType ?? 'image/png') : null,
},
arrayBuffer: async () => body,
} as unknown as Response;
@@ -106,9 +103,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => {
status: 200,
headers: {
get: (n: string) =>
n.toLowerCase() === 'content-type'
? 'text/html; charset=utf-8'
: null,
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
},
text: async () => html,
}),
@@ -164,7 +159,8 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatu
});
it('Fehlerseite ohne Symbol-Verweis, nur og:image -> Rueckfall <origin>/favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => {
const html = '<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></html>';
const html =
'<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></html>';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(404, html)));
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
@@ -173,7 +169,10 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatu
});
it('fetchIconBytes bleibt streng: Fehlerstatus -> wirft (kein allowErrorStatus fuer Bilder)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } }));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } }),
);
await expect(
new IconDiscoveryService().fetchIconBytes('http://8.8.8.8/favicon.ico'),
@@ -203,16 +202,13 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
});
it('rejects when Content-Type is not an image', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
const service = new IconDiscoveryService();
await expect(
service.fetchIconBytes('http://8.8.8.8/favicon.ico'),
).rejects.toThrow(/not an image/);
await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
/not an image/,
);
});
it('rejects when the SSRF guard blocks the target', async () => {
@@ -221,9 +217,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
const service = new IconDiscoveryService();
await expect(
service.fetchIconBytes('http://127.0.0.1/favicon.ico'),
).rejects.toThrow(/blocked or failed/);
await expect(service.fetchIconBytes('http://127.0.0.1/favicon.ico')).rejects.toThrow(
/blocked or failed/,
);
expect(fetchSpy).not.toHaveBeenCalled();
});
@@ -236,9 +232,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
const service = new IconDiscoveryService();
await expect(
service.fetchIconBytes('http://8.8.8.8/favicon.ico'),
).rejects.toThrow(/size limit/);
await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
/size limit/,
);
});
});
@@ -258,10 +254,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', (
});
it('still returns a URL string', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
const service = new IconDiscoveryService();
const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page');
@@ -335,3 +328,143 @@ describe('IconDiscoveryService — Dispatcher (260917-jdd)', () => {
expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher);
});
});
describe('readTextCapped / discardBody — Groessendeckel beim Lesen (T-08-09)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
/** Stream aus `chunks` Stuecken je `chunkChars` ASCII-Zeichen; zaehlt gelesene Stuecke und Abbruch. */
function countingStream(chunks: number, chunkChars: number) {
const state = { pulled: 0, cancelled: false };
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (state.pulled >= chunks) {
controller.close();
return;
}
state.pulled += 1;
controller.enqueue(encoder.encode('a'.repeat(chunkChars)));
},
cancel() {
state.cancelled = true;
},
});
return { body, state };
}
it('bricht den Stream nach der Grenze ab statt alles zu lesen', async () => {
const { body, state } = countingStream(1000, 1000);
const text = await readTextCapped({ body, text: async () => 'unbenutzt' } as never, 2500);
expect(text).toHaveLength(2500);
expect(state.pulled).toBeLessThan(10);
expect(state.cancelled).toBe(true);
});
it('gibt nach der Zeitgrenze zurueck, was bis dahin da ist (tropfender Server)', async () => {
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('<link rel="icon">'));
// danach kommt nichts mehr, der Stream bleibt offen
},
cancel() {
cancelled = true;
},
});
const text = await readTextCapped({ body, text: async () => '' } as never, 200000, 50);
expect(text).toBe('<link rel="icon">');
expect(cancelled).toBe(true);
});
it('liest kurze Seiten vollstaendig, auch Mehrbyte-Zeichen ueber Chunk-Grenzen', async () => {
const bytes = new TextEncoder().encode('<p>Grüße</p>');
const body = new ReadableStream<Uint8Array>({
start(controller) {
// Das "ü" (2 Bytes) wird absichtlich zerteilt.
controller.enqueue(bytes.slice(0, 5));
controller.enqueue(bytes.slice(5));
controller.close();
},
});
const text = await readTextCapped({ body, text: async () => '' } as never, 200000);
expect(text).toBe('<p>Grüße</p>');
});
it('ohne Stream: Rueckfall auf text() mit Deckel', async () => {
const text = await readTextCapped(
{ body: null, text: async () => 'x'.repeat(50) } as never,
10,
);
expect(text).toBe('x'.repeat(10));
});
it('discardBody bricht einen offenen Body ab und vertraegt fehlenden Body', () => {
const { body, state } = countingStream(5, 10);
discardBody({ body } as never);
expect(state.cancelled).toBe(true);
expect(() => discardBody({ body: null } as never)).not.toThrow();
});
it('Discovery: Fehlerstatus ohne HTML-Typ -> Body wird verworfen, Rueckfall favicon.ico', async () => {
const { body, state } = countingStream(5, 10);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: {
get: (n: string) => (n.toLowerCase() === 'content-type' ? 'application/json' : null),
},
body,
}),
);
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
expect(icon).toBe('http://8.8.8.8/favicon.ico');
expect(state.cancelled).toBe(true);
});
it('Discovery: riesige HTML-Seite wird nur bis zur Grenze gelesen, Symbol am Anfang gefunden', async () => {
const head = '<html><head><link rel="icon" href="/klein.png" /></head><body>';
const encoder = new TextEncoder();
const state = { pulled: 0, cancelled: false };
const body = new ReadableStream<Uint8Array>({
pull(controller) {
state.pulled += 1;
controller.enqueue(encoder.encode(state.pulled === 1 ? head : 'a'.repeat(64 * 1024)));
},
cancel() {
state.cancelled = true;
},
});
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: (n: string) => (n.toLowerCase() === 'content-type' ? 'text/html' : null) },
body,
text: async () => {
throw new Error('text() darf bei vorhandenem Stream nicht laufen');
},
}),
);
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/');
expect(icon).toBe('http://8.8.8.8/klein.png');
expect(state.cancelled).toBe(true);
// 200 000 Zeichen bei 64-KiB-Stuecken: hoechstens eine Handvoll gelesen.
expect(state.pulled).toBeLessThan(10);
});
});
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici';
import { Injectable } from '@nestjs/common';
import { Agent, type Response as UndiciResponse, fetch as undiciFetch } from 'undici';
/**
* Server-side favicon / icon discovery with SSRF protection (T-08-05).
@@ -58,9 +58,7 @@ function isPrivateIpv4(address: string): boolean {
if (
parts.length !== 4 ||
parts.some(
(part) => !Number.isInteger(part) || part < 0 || part > 255,
)
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
) {
return true;
}
@@ -117,12 +115,7 @@ function isPrivateIpAddress(address: string): boolean {
function isBlockedHostname(hostname: string): boolean {
const h = hostname.trim().toLowerCase();
return (
h === 'localhost' ||
h.endsWith('.localhost') ||
h.endsWith('.local') ||
h === '0.0.0.0'
);
return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h === '0.0.0.0';
}
export async function isPublicHttpUrl(url: URL): Promise<boolean> {
@@ -204,11 +197,7 @@ function toAbsoluteUrl(value: string | undefined, base: string): string | null {
}
}
function extractIconFromHtml(
html: string,
baseUrl: string,
linkTagsOnly = false,
): string | null {
function extractIconFromHtml(html: string, baseUrl: string, linkTagsOnly = false): string | null {
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
@@ -220,27 +209,19 @@ function extractIconFromHtml(
}))
.filter((c) => c.href);
const appleTouchIcon = linkCandidates.find((c) =>
c.rel.includes('apple-touch-icon'),
)?.href;
const appleTouchIcon = linkCandidates.find((c) => c.rel.includes('apple-touch-icon'))?.href;
if (appleTouchIcon) return appleTouchIcon;
const icon = linkCandidates.find((c) =>
c.rel.split(/\s+/).includes('icon'),
)?.href;
const icon = linkCandidates.find((c) => c.rel.split(/\s+/).includes('icon'))?.href;
if (icon) return icon;
const shortcutIcon = linkCandidates.find((c) =>
c.rel.includes('shortcut icon'),
)?.href;
const shortcutIcon = linkCandidates.find((c) => c.rel.includes('shortcut icon'))?.href;
if (shortcutIcon) return shortcutIcon;
const imageSrc = linkCandidates.find((c) =>
c.rel.includes('image_src'),
)?.href;
const imageSrc = linkCandidates.find((c) => c.rel.includes('image_src'))?.href;
if (imageSrc) return imageSrc;
@@ -257,9 +238,7 @@ function extractIconFromHtml(
.find(
(c) =>
c.content &&
(c.property === 'og:image' ||
c.property === 'og:logo' ||
c.property === 'twitter:image'),
(c.property === 'og:image' || c.property === 'og:logo' || c.property === 'twitter:image'),
)?.content;
return metaImage ?? null;
@@ -327,6 +306,71 @@ async function fetchWithRedirectGuard(
return null;
}
/** Minimaler Ausschnitt einer Antwort, den die beiden Helfer brauchen. */
type BodyResponse = Pick<UndiciResponse, 'body' | 'text'>;
/**
* Verwirft den Body einer nicht gebrauchten Antwort. Fehler (bereits
* gelesen/abgebrochen) sind egal.
*/
export function discardBody(response: Pick<UndiciResponse, 'body'>): void {
try {
response.body?.cancel().catch(() => {});
} catch {
// Body gesperrt oder schon verbraucht — nichts zu tun.
}
}
/**
* Liest den Antworttext hoechstens bis `maxChars` Zeichen und bricht den
* Stream danach ab (T-08-09). Vorher wurde der komplette Body gelesen und
* erst danach abgeschnitten — eine riesige Seite landete ganz im Speicher.
* Dekodiert wird UTF-8 wie bei `Response.text()`; da jedes Zeichen aus
* mindestens einem Byte entsteht, bleibt der Speicher bei ~maxChars plus
* einem Chunk. Ohne Stream (`body === null`) wie bisher ueber `text()`.
* `timeoutMs` begrenzt zusaetzlich die Lesedauer: die Zeitgrenze von
* `fetchWithRedirectGuard` endet mit den Kopfzeilen, ein Server, der den
* Body tropfenweise liefert, hielte die Anfrage sonst beliebig lange auf.
* Nach Ablauf zaehlt, was bis dahin gelesen ist.
*/
export async function readTextCapped(
response: BodyResponse,
maxChars: number,
timeoutMs = HTML_FETCH_TIMEOUT_MS,
): Promise<string> {
if (!response.body) {
return (await response.text()).slice(0, maxChars);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = '';
// cancel() beendet ein haengendes read() mit done: true.
const deadline = setTimeout(() => void reader.cancel().catch(() => {}), timeoutMs);
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
text += decoder.decode();
break;
}
text += decoder.decode(value, { stream: true });
if (text.length >= maxChars) {
await reader.cancel().catch(() => {});
break;
}
}
} finally {
clearTimeout(deadline);
}
return text.slice(0, maxChars);
}
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
const result = await fetchWithRedirectGuard(pageUrl, {
accept: 'text/html,application/xhtml+xml,*/*',
@@ -340,12 +384,18 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
const contentType = result.response.headers.get('content-type') ?? '';
if (!contentType.toLowerCase().includes('text/html')) return null;
if (!contentType.toLowerCase().includes('text/html')) {
// Kein HTML (auch bei Fehlerstatus dank allowErrorStatus hier moeglich):
// Body verwerfen, sonst haelt undici die Verbindung bis zum Timeout offen.
discardBody(result.response);
return null;
}
const html = await result.response.text();
// T-08-09: HTML cap — schon beim Lesen, nicht erst nach dem kompletten Body.
const html = await readTextCapped(result.response, MAX_HTML_CHARS);
return {
html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap
html,
finalUrl: result.finalUrl.toString(),
ok: result.response.ok,
};
@@ -370,10 +420,7 @@ export class IconDiscoveryService {
if (!htmlResult) return fallback;
return (
extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ??
fallback
);
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ?? fallback;
} catch {
return fallback;
}
@@ -388,9 +435,7 @@ export class IconDiscoveryService {
* or an oversized body. Callers must not return a placeholder image; let
* the caller map the failure to an HTTP error status instead.
*/
async fetchIconBytes(
iconUrl: string,
): Promise<{ contentType: string; body: Buffer }> {
async fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }> {
const url = new URL(iconUrl);
const result = await fetchWithRedirectGuard(url, {