diff --git a/apps/api/package.json b/apps/api/package.json index dc1e93d..646850c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -48,6 +48,7 @@ "reflect-metadata": "^0.2.0", "rxjs": "^7.0.0", "tsdav": "2.2.2", + "undici": "7.28.0", "xlsx": "^0.18.5" }, "devDependencies": { diff --git a/apps/api/src/favorites/dto/reorder-favorites.dto.ts b/apps/api/src/favorites/dto/reorder-favorites.dto.ts new file mode 100644 index 0000000..b1157d0 --- /dev/null +++ b/apps/api/src/favorites/dto/reorder-favorites.dto.ts @@ -0,0 +1,28 @@ +import { + ArrayMaxSize, + ArrayMinSize, + ArrayUnique, + IsArray, + IsUUID, +} from 'class-validator'; + +/** + * DTO for `PUT /favorites/order` (260917-jdd). + * + * `ids` is the FULL id list of a widget's favorites, in the desired display + * order — the service requires an exact match against the caller's existing + * favorites for this widget (no partial reorder, no foreign/unknown ids). + * `ArrayMaxSize(500)` is a DoS cap for the `updateMany` loop inside the + * transaction (T-JDD-05); a widget has a handful of links in practice. + */ +export class ReorderFavoritesDto { + @IsUUID() + widgetId!: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(500) + @ArrayUnique() + @IsUUID('all', { each: true }) + ids!: string[]; +} diff --git a/apps/api/src/favorites/favorites.controller.ts b/apps/api/src/favorites/favorites.controller.ts index a8b2cb5..a49cb5f 100644 --- a/apps/api/src/favorites/favorites.controller.ts +++ b/apps/api/src/favorites/favorites.controller.ts @@ -8,12 +8,14 @@ import { ParseUUIDPipe, Patch, Post, + Put, Query, Req, Res, } from '@nestjs/common'; import { Request, Response } from 'express'; import { CreateFavoriteDto } from './dto/create-favorite.dto'; +import { ReorderFavoritesDto } from './dto/reorder-favorites.dto'; import { UpdateFavoriteDto } from './dto/update-favorite.dto'; import { FavoritesService } from './favorites.service'; @@ -35,6 +37,8 @@ import { FavoritesService } from './favorites.service'; * Routes: * - GET /favorites?widgetId= — list favorites for a widget instance * - POST /favorites — create a favorite (triggers server-side icon discovery) + * - PUT /favorites/order — reorder favorites for a widget instance (260917-jdd) + * - GET /favorites/:id/icon — stream a favorite's stored icon bytes * - PATCH /favorites/:id — update a favorite (ownership verified in service) * - DELETE /favorites/:id — delete a favorite (ownership verified in service) */ @@ -77,6 +81,23 @@ export class FavoritesController { return this.favoritesService.create(tenantId, userId, dto); } + /** + * PUT /favorites/order — persists the display order for a widget's + * favorites (260917-jdd). Declared BEFORE the `:id` routes below on + * purpose (NestJS route order — a later `:id` route would otherwise + * shadow the literal segment "order"; precedent tenders.controller.ts + * Z. 636-648). + */ + @Put('order') + async reorder( + @Body() dto: ReorderFavoritesDto, + @Req() req: Request, + ) { + const { userId, tenantId } = this.extractContext(req); + + return this.favoritesService.reorder(tenantId, userId, dto); + } + /** * GET /favorites/:id/icon — streams the stored icon bytes for a favorite * owned by the caller, from Tessera's own origin. This avoids the browser @@ -101,6 +122,12 @@ export class FavoritesController { res.setHeader('Content-Type', contentType); res.setHeader('Cache-Control', 'public, max-age=86400'); + // 260917-jdd: die Bytes kommen jetzt auch von Hosts ohne gueltiges + // Zertifikat. Als -Unterressource ignoriert der Browser diese + // Header, aber ein direkt im Tab geoeffnetes SVG laeuft damit ohne + // Skript und ohne Tessera-Origin (T-JDD-02). + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox"); res.send(body); } diff --git a/apps/api/src/favorites/favorites.service.spec.ts b/apps/api/src/favorites/favorites.service.spec.ts index 8feaf6d..ffd0877 100644 --- a/apps/api/src/favorites/favorites.service.spec.ts +++ b/apps/api/src/favorites/favorites.service.spec.ts @@ -1,7 +1,7 @@ import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FavoritesService } from './favorites.service'; -import { forTenant } from '../prisma/prisma-tenant.extension'; +import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension'; /** * FavoritesService.spec — NEU (260911-gwh). Der Bereich `favorites` hatte @@ -14,9 +14,18 @@ import { forTenant } from '../prisma/prisma-tenant.extension'; * Modellzugriff scheitert mit "Cannot read properties of undefined" (die * dkv-Form der Falsifizierung, siehe auth.service.spec.ts:280). Der * GEBUNDENE Klient hat ausschliesslich `favoriteLink`/`widgetInstance`. + * + * 260917-jdd: `withTenantTransaction` kommt zum Mock hinzu (Muster + * groups.service.spec.ts Z. 30-35/296-299) — `prisma.__withTenantTransaction` + * reicht den gebundenen Klienten als `tx` durch und protokolliert den + * Aufruf. Der Fake bekommt zusaetzlich `updateMany` auf `favoriteLink` fuer + * `reorder()`. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)), + withTenantTransaction: vi.fn((unboundClient: any, tenantId: string, fn: (tx: any) => any) => + unboundClient.__withTenantTransaction(tenantId, fn), + ), })); interface FakeFavoriteRow { @@ -40,7 +49,7 @@ interface FakeWidgetRow { interface BoundCall { tenantId: string; - model: 'favoriteLink' | 'widgetInstance'; + model: 'favoriteLink' | 'widgetInstance' | '$transaction'; method: string; } @@ -117,6 +126,20 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi favorites.delete(where.id); return row; }, + // 260917-jdd: reorder() — filtert nach tenantId sowie, falls in + // `where` vorhanden, id/userId/widgetId; wendet `data` auf jede + // Treffer-Zeile an; liefert { count }. + updateMany: async ({ where, data }: any) => { + boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'updateMany' }); + let rows = Array.from(favorites.values()).filter((f) => f.tenantId === tenantId); + if (where?.id) rows = rows.filter((f) => f.id === where.id); + if (where?.userId) rows = rows.filter((f) => f.userId === where.userId); + if (where?.widgetId) rows = rows.filter((f) => f.widgetId === where.widgetId); + for (const row of rows) { + favorites.set(row.id, { ...row, ...data, updatedAt: new Date() }); + } + return { count: rows.length }; + }, }; } @@ -146,6 +169,10 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi widgetInstance: makeScopedWidgetInstance(tenantId), }; }, + __withTenantTransaction(tenantId: string, fn: (tx: any) => any) { + boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' }); + return fn(fake.__makeBoundClient(tenantId)); + }, }; return fake; } @@ -505,4 +532,97 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => { } }); }); + + describe('reorder (260917-jdd)', () => { + const makeAltbestand = () => + makeFakePrisma([ + { id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'A', url: 'https://a.invalid', iconUrl: null, position: 0 }, + { id: 'f2', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 0 }, + { id: 'f3', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'C', url: 'https://c.invalid', iconUrl: null, position: 0 }, + { id: 'f9', userId: 'user-a2', tenantId: 't1', widgetId: 'widget-a1', title: 'D', url: 'https://d.invalid', iconUrl: null, position: 0 }, + ]); + + it('setzt position 0/1/2 in der uebergebenen Reihenfolge und liefert die Liste so sortiert', async () => { + const prisma = makeAltbestand(); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + + const result = await service.reorder('t1', 'user-a1', { + widgetId: 'widget-a1', + ids: ['f3', 'f1', 'f2'], + } as any); + + expect(result.map((r: any) => r.id)).toEqual(['f3', 'f1', 'f2']); + expect(prisma.__favorites.get('f3').position).toBe(0); + expect(prisma.__favorites.get('f1').position).toBe(1); + expect(prisma.__favorites.get('f2').position).toBe(2); + expect(prisma.__favorites.get('f9').position).toBe(0); + expect(vi.mocked(withTenantTransaction)).toHaveBeenCalledWith(prisma, 't1', expect.any(Function)); + expectBoundCall(prisma, 't1', 'favoriteLink', 'updateMany'); + }); + + it('fremde id (user-a2) -> BadRequestException, KEINE Position geaendert', async () => { + const prisma = makeAltbestand(); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + + await expect( + service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f9'] } as any), + ).rejects.toThrow(BadRequestException); + expect(prisma.__favorites.get('f1').position).toBe(0); + expect(prisma.__favorites.get('f2').position).toBe(0); + expect(prisma.__favorites.get('f3').position).toBe(0); + }); + + it('unbekannte id -> BadRequestException', async () => { + const prisma = makeAltbestand(); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + + await expect( + service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f-fehlt'] } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('Teilmenge (2 von 3) -> BadRequestException', async () => { + const prisma = makeAltbestand(); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + + await expect( + service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2'] } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('doppelte ids -> BadRequestException OHNE withTenantTransaction-Aufruf', async () => { + const prisma = makeAltbestand(); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + + vi.mocked(withTenantTransaction).mockClear(); + await expect( + service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f1', 'f2'] } as any), + ).rejects.toThrow(BadRequestException); + expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(0); + }); + + it('fremder Mandant (t2 auf t1-Zeilen) -> BadRequestException, Positionen unveraendert', async () => { + const prisma = makeAltbestand(); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + + await expect( + service.reorder('t2', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2', 'f3'] } as any), + ).rejects.toThrow(BadRequestException); + expect(prisma.__favorites.get('f1').position).toBe(0); + expect(prisma.__favorites.get('f2').position).toBe(0); + expect(prisma.__favorites.get('f3').position).toBe(0); + }); + + it('Wachhund: 0 forTenant-Aufrufe, genau 1 withTenantTransaction-Aufruf fuer den Happy Path', async () => { + const prisma = makeAltbestand(); + const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); + + vi.mocked(forTenant).mockClear(); + vi.mocked(withTenantTransaction).mockClear(); + await service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f2'] } as any); + + expect(vi.mocked(forTenant).mock.calls.length).toBe(0); + expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(1); + }); + }); }); diff --git a/apps/api/src/favorites/favorites.service.ts b/apps/api/src/favorites/favorites.service.ts index 413b9fd..3185102 100644 --- a/apps/api/src/favorites/favorites.service.ts +++ b/apps/api/src/favorites/favorites.service.ts @@ -6,8 +6,9 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { forTenant } from '../prisma/prisma-tenant.extension'; +import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension'; import { CreateFavoriteDto } from './dto/create-favorite.dto'; +import { ReorderFavoritesDto } from './dto/reorder-favorites.dto'; import { UpdateFavoriteDto } from './dto/update-favorite.dto'; import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service'; @@ -37,6 +38,8 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service'; * - Every query is scoped by userId (prevents cross-user access). * - list() additionally scopes by widgetId so each widget instance has its own set. * - update() and remove() verify userId ownership before mutating. + * - reorder() runs as one withTenantTransaction() (T-JDD-03) and scopes + * every updateMany by userId AND widgetId (see reorder() doc below). * * `create()` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert * (T-GWH-05): der Fremdschluessel `FavoriteLink.widgetId` prueft an der @@ -171,6 +174,74 @@ export class FavoritesService { await tenantPrisma.favoriteLink.delete({ where: { id } }); } + /** + * Persists the display order of a user's favorites for one widget + * instance (260917-jdd, PUT /favorites/order). + * + * Laeuft als EINE Transaktion ueber `withTenantTransaction()` — die + * einzige gemessene atomare Form fuer einen Mehrschritt-Zugriff + * (prisma-tenant.extension.ts Z. 33-49/104-109); die Array-Form von + * `$transaction` auf einem mit `forTenant()` gebundenen Klienten ist + * gemessen NICHT atomar, die interaktive Form auf dem gebundenen Klienten + * faellt unter Last aus (siehe dortige Messung). `withTenantTransaction()` + * setzt KEINE Benutzerdimension in der Sitzung (nur `app.current_tenant`) + * — die Regel auf `FavoriteLink` faellt deshalb in ihren `IS NULL`-Zweig + * und zeigt den ganzen Mandanten. Darum traegt JEDE Bedingung unten + * `userId` UND `widgetId` selbst (zweites Netz, wie der Kopfkommentar + * dieser Klasse es fuer alle Methoden vorsieht). + * + * `updateMany` statt `update({ where: { id } })`, weil `update` nur nach + * `id` filtern koennte — der Ownership-Check muesste dann als separater + * Lese-Schritt VOR dem Schreiben stehen, mit derselben TOCTOU-Luecke wie + * ein fehlendes zweites Netz. `updateMany` traegt die Bedingung direkt in + * der Schreiboperation und liefert `count`, das sofort geprueft wird. + * + * Existenzorakel-Vermeidung (T-JDD-06): EINE BadRequestException mit + * DERSELBEN Meldung fuer fremde id, unbekannte id, Teilmenge sowie + * fremdes/unbekanntes Widget oder Mandant — kein Fall verraet, welcher + * Grund zutraf (Muster T-GWH-05). + * + * Altbestand: alle Zeilen mit `position = 0` (vor diesem Plan gab es + * keine Sortierung) normalisiert sich beim ERSTEN Aufruf zu `0..n-1` — + * kein Migrations- oder Sonderpfad noetig. + */ + async reorder(tenantId: string, userId: string, dto: ReorderFavoritesDto) { + if (new Set(dto.ids).size !== dto.ids.length) { + throw new BadRequestException('ids must match the favorites of this widget exactly'); + } + + return withTenantTransaction(this.prisma, tenantId, async (tx: any) => { + const existing = await tx.favoriteLink.findMany({ + where: { userId, widgetId: dto.widgetId }, + select: { id: true }, + }); + const existingIds = new Set(existing.map((r: { id: string }) => r.id)); + + if ( + existing.length !== dto.ids.length || + dto.ids.some((id) => !existingIds.has(id)) + ) { + throw new BadRequestException('ids must match the favorites of this widget exactly'); + } + + for (const [index, id] of dto.ids.entries()) { + const { count } = await tx.favoriteLink.updateMany({ + where: { id, userId, widgetId: dto.widgetId }, + data: { position: index }, + }); + + if (count !== 1) { + throw new BadRequestException('ids must match the favorites of this widget exactly'); + } + } + + return tx.favoriteLink.findMany({ + where: { userId, widgetId: dto.widgetId }, + orderBy: [{ position: 'asc' }, { title: 'asc' }], + }); + }); + } + /** * Fetches the raw bytes of a favorite's stored icon, scoped to the * requesting user (T-08-06 — same ownership check as update/remove). diff --git a/apps/api/src/favorites/icon-discovery.service.spec.ts b/apps/api/src/favorites/icon-discovery.service.spec.ts index 094c99a..0693da0 100644 --- a/apps/api/src/favorites/icon-discovery.service.spec.ts +++ b/apps/api/src/favorites/icon-discovery.service.spec.ts @@ -1,4 +1,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** + * 260917-jdd — `undici` wird gemockt, damit KEIN Test tatsaechlich ins Netz + * geht: die produktive Datei ruft ab jetzt `undiciFetch` statt des globalen + * `fetch` auf, mit einem Modul-Singleton-`Agent` als `dispatcher`. Die + * Mock-Klasse zeichnet nur die uebergebenen `options` auf; `fetch` delegiert + * ZUR LAUFZEIT (Pfeilfunktion, nicht beim Laden aufgeloest) an + * `globalThis.fetch`, damit alle bestehenden `vi.stubGlobal('fetch', …)`- + * Tests wortgleich gruen bleiben. + */ +vi.mock('undici', () => ({ + Agent: class Agent { + constructor(public readonly options: unknown) {} + }, + fetch: (...args: unknown[]) => (globalThis.fetch as any)(...args), +})); + +import { Agent } from 'undici'; import { IconDiscoveryService, isPublicHttpUrl, @@ -203,3 +221,69 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', ( expect(typeof result).toBe('string'); }); }); + +describe('IconDiscoveryService — Dispatcher (260917-jdd)', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('discoverFavoriteIconUrl uebergibt den tolerante-TLS-Agent als dispatcher und redirect: manual', async () => { + const html = ''; + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: (n: string) => + n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null, + }, + text: async () => html, + }); + vi.stubGlobal('fetch', fetchSpy); + + const service = new IconDiscoveryService(); + await service.discoverFavoriteIconUrl('http://8.8.8.8'); + + const init = fetchSpy.mock.calls[0][1]; + expect(init.dispatcher).toBeInstanceOf(Agent); + expect(init.dispatcher.options).toEqual({ connect: { rejectUnauthorized: false } }); + expect(init.redirect).toBe('manual'); + }); + + it('fetchIconBytes uebergibt denselben tolerante-TLS-Agent als dispatcher und redirect: manual', async () => { + const fetchSpy = vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png' })); + vi.stubGlobal('fetch', fetchSpy); + + const service = new IconDiscoveryService(); + await service.fetchIconBytes('http://8.8.8.8/favicon.ico'); + + const init = fetchSpy.mock.calls[0][1]; + expect(init.dispatcher).toBeInstanceOf(Agent); + expect(init.dispatcher.options).toEqual({ connect: { rejectUnauthorized: false } }); + expect(init.redirect).toBe('manual'); + }); + + it('Discovery und fetchIconBytes teilen DENSELBEN Agent (Modul-Singleton)', async () => { + const html = ''; + const fetchSpy = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { + get: (n: string) => + n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null, + }, + text: async () => html, + }) + .mockResolvedValueOnce(mockResponse({ contentType: 'image/png' })); + vi.stubGlobal('fetch', fetchSpy); + + const service = new IconDiscoveryService(); + await service.discoverFavoriteIconUrl('http://8.8.8.8'); + await service.fetchIconBytes('http://8.8.8.8/favicon.ico'); + + const calls = fetchSpy.mock.calls; + expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher); + }); +}); diff --git a/apps/api/src/favorites/icon-discovery.service.ts b/apps/api/src/favorites/icon-discovery.service.ts index 33fed34..90de12a 100644 --- a/apps/api/src/favorites/icon-discovery.service.ts +++ b/apps/api/src/favorites/icon-discovery.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { lookup } from 'dns/promises'; import { isIP } from 'net'; +import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici'; /** * Server-side favicon / icon discovery with SSRF protection (T-08-05). @@ -12,6 +13,9 @@ import { isIP } from 'net'; * - 4000 ms AbortController timeout per request * - 200 000 character HTML cap to prevent memory exhaustion (T-08-09) * - Blocked hostnames: localhost, .local, 0.0.0.0 + * - 260917-jdd: Zertifikatsfehler des Zielhosts werden toleriert (siehe + * LENIENT_TLS_AGENT unten) — DNS-Pruefung, Redirect-Limit, Timeout und + * Groessendeckel bleiben davon unberuehrt. */ const FALLBACK_ICON_PATH = '/favicon.ico'; @@ -21,6 +25,27 @@ const MAX_REDIRECTS = 2; const MAX_HTML_CHARS = 200000; const MAX_ICON_BYTES = 1_000_000; +/** + * 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte, + * abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines + * Favoriten nicht verhindern. Dieser Dispatcher gilt AUSSCHLIESSLICH fuer + * die beiden Aufrufe in dieser Datei (Dispatcher pro Aufruf, keine + * prozessweite Abschaltung der Zertifikatspruefung — insbesondere NICHT + * ueber die Node-Umgebungsvariable, die mit NODE_TLS_ beginnt). + * + * Der Dispatcher wirkt nur zusammen mit undicis EIGENEM `fetch` — Nodes + * globales `fetch` ignoriert einen Agent aus dem npm-Paket (andere Klasse, + * Node 24 buendelt intern undici 7.25.0). Gemessen 2026-09-17 gegen + * self-signed.badssl.com: `undiciFetch(url, { dispatcher: new Agent(...) })` + * -> Status 200; `globalThis.fetch` derselben URL -> DEPTH_ZERO_SELF_SIGNED_CERT. + * Deshalb der Modulimport oben statt des globalen `fetch`. + * + * DNS-Pruefung (isPublicHttpUrl), Redirect-Limit (MAX_REDIRECTS), Timeout + * und Groessendeckel (MAX_ICON_BYTES/MAX_HTML_CHARS) bleiben davon + * unberuehrt (T-JDD-01). + */ +const LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } }); + type FetchHtmlResult = { html: string; finalUrl: string; @@ -243,7 +268,7 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null { async function fetchWithRedirectGuard( pageUrl: URL, options: { accept: string; timeoutMs: number; userAgent?: string }, -): Promise<{ response: Response; finalUrl: URL } | null> { +): Promise<{ response: UndiciResponse; finalUrl: URL } | null> { let currentUrl = pageUrl; for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) { @@ -255,7 +280,8 @@ async function fetchWithRedirectGuard( const timeout = setTimeout(() => controller.abort(), options.timeoutMs); try { - const response = await fetch(currentUrl.toString(), { + const response = await undiciFetch(currentUrl.toString(), { + dispatcher: LENIENT_TLS_AGENT, // 260917-jdd: siehe Kommentar an der Konstante redirect: 'manual', // SSRF: follow manually so each hop is re-validated signal: controller.signal, headers: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f838462..d81eb9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,7 +82,7 @@ importers: version: 7.0.1 ews-javascript-api: specifier: 0.15.3 - version: 0.15.3 + version: 0.15.3(undici@7.28.0) fast-xml-parser: specifier: ^5.10.1 version: 5.10.1 @@ -128,6 +128,9 @@ importers: tsdav: specifier: 2.2.2 version: 2.2.2 + undici: + specifier: 7.28.0 + version: 7.28.0 xlsx: specifier: ^0.18.5 version: 0.18.5 @@ -6190,11 +6193,11 @@ snapshots: optionalDependencies: extend: 3.0.2 - '@ewsjs/xhr@3.1.3': + '@ewsjs/xhr@3.1.3(undici@7.28.0)': dependencies: '@ewsjs/ntlm-client': 3.0.1 axios: 1.18.1 - http-cookie-agent: 5.0.4(tough-cookie@4.1.4) + http-cookie-agent: 5.0.4(tough-cookie@4.1.4)(undici@7.28.0) tough-cookie: 4.1.4 transitivePeerDependencies: - deasync @@ -8511,10 +8514,10 @@ snapshots: events@3.3.0: {} - ews-javascript-api@0.15.3: + ews-javascript-api@0.15.3(undici@7.28.0): dependencies: '@azure/msal-node': 2.16.3 - '@ewsjs/xhr': 3.1.3 + '@ewsjs/xhr': 3.1.3(undici@7.28.0) '@xmldom/xmldom': 0.8.13 base64-js: 1.5.1 moment: 2.30.1 @@ -8987,10 +8990,12 @@ snapshots: entities: 4.5.0 optional: true - http-cookie-agent@5.0.4(tough-cookie@4.1.4): + http-cookie-agent@5.0.4(tough-cookie@4.1.4)(undici@7.28.0): dependencies: agent-base: 7.1.4 tough-cookie: 4.1.4 + optionalDependencies: + undici: 7.28.0 http-errors@2.0.1: dependencies: