From a4e03830c1870b64562cf7c06890b6e8f988920f Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 29 Jun 2026 10:02:48 +0200 Subject: [PATCH] fix(07): NTLM support for Exchange EWS + crypto key init timing fix - ExchangeInboxProvider rewritten to use httpntlm + raw EWS SOAP: FindItem / GetItem / GetAttachment via NTLM challenge-response. No longer requires Basic Auth on Exchange EWS virtual directory. Folder name mapped to EWS DistinguishedFolderId (Inbox/SentItems/etc). - CalendarCryptoService: move key init from onModuleInit to constructor so MailModule.forRootAsync() factory can call decrypt() before NestJS lifecycle hooks execute (startup crash when SmtpConfig row has password). Co-Authored-By: Claude Sonnet 4.6 --- apps/api/package.json | 3 +- apps/api/src/calendar/crypto.service.ts | 14 +- .../dkv/providers/exchange-inbox.provider.ts | 488 +++++++++++------- pnpm-lock.yaml | 25 + 4 files changed, 330 insertions(+), 200 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 3fa19f2..5238561 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,14 +20,15 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.0", "@nestjs/schedule": "^6.1.3", - "cron": "4.4.0", "@prisma/client": "^6.0.0", "@tessera/shared": "workspace:*", "argon2": "^0.44.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "cookie-parser": "^1.4.7", + "cron": "4.4.0", "ews-javascript-api": "0.15.3", + "httpntlm": "^1.8.13", "imapflow": "^1.4.3", "ldapts": "^8.1.8", "node-ical": "0.26.1", diff --git a/apps/api/src/calendar/crypto.service.ts b/apps/api/src/calendar/crypto.service.ts index f562452..7347f5d 100644 --- a/apps/api/src/calendar/crypto.service.ts +++ b/apps/api/src/calendar/crypto.service.ts @@ -1,4 +1,4 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; @@ -9,14 +9,16 @@ import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; * Encrypted values are stored as `iv:authTag:ciphertext` (hex-joined). * * Security: T-05-10 — credentials encrypted at rest, never returned in GET responses. + * + * Key is initialised in the constructor (not onModuleInit) so that async factory + * functions in other modules (e.g. MailModule.forRootAsync) can call decrypt() + * before NestJS lifecycle hooks run. */ @Injectable() -export class CalendarCryptoService implements OnModuleInit { - private key!: Buffer; +export class CalendarCryptoService { + private readonly key: Buffer; - constructor(private readonly configService: ConfigService) {} - - onModuleInit() { + constructor(private readonly configService: ConfigService) { const hexKey = this.configService.get('CALENDAR_ENCRYPTION_KEY'); if (!hexKey) { diff --git a/apps/api/src/dkv/providers/exchange-inbox.provider.ts b/apps/api/src/dkv/providers/exchange-inbox.provider.ts index a5673e6..9322819 100644 --- a/apps/api/src/dkv/providers/exchange-inbox.provider.ts +++ b/apps/api/src/dkv/providers/exchange-inbox.provider.ts @@ -1,247 +1,349 @@ import { Injectable, Logger } from '@nestjs/common'; -import type { InboxConfig, InboxEmail } from './inbox-provider.interface'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const httpntlm = require('httpntlm') as { post: (opts: any, cb: (err: Error | null, res: any) => void) => void }; +import type { InboxAttachment, InboxConfig, InboxEmail } from './inbox-provider.interface'; import type { InboxProvider } from './inbox-provider.interface'; -/** - * Max attachment size (bytes) accepted before buffering. - * Mirrors the same limit in ImapProvider (T-07-05 — PDF-bomb mitigation). - */ -const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB +const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB — T-07-05 + +// ─── EWS SOAP namespace constants ──────────────────────────────────────────── + +const NS_SOAP = 'http://schemas.xmlsoap.org/soap/envelope/'; +const NS_TYPES = 'http://schemas.microsoft.com/exchange/services/2006/types'; +const NS_MESSAGES = 'http://schemas.microsoft.com/exchange/services/2006/messages'; + +// ─── SOAP envelope builders ─────────────────────────────────────────────────── + +function soapEnvelope(body: string): string { + return ` + + ${body} +`; +} + +function findItemSoap(folderId: string, maxResults: number, senderFilter?: string): string { + const restriction = senderFilter + ? ` + + + + + ` + : ''; + + return soapEnvelope(` + + + IdOnly + + + + + + + + + + ${restriction} + + + + `); +} + +function getItemSoap(itemIds: string[]): string { + const ids = itemIds.map((id) => ``).join(''); + return soapEnvelope(` + + + IdOnly + + + + + + + + + ${ids} + `); +} + +function getAttachmentSoap(attachmentId: string): string { + return soapEnvelope(` + + + + + `); +} + +// ─── XML helpers ───────────────────────────────────────────────────────────── + +function escapeXml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** Extract all text values of a tag name from an XML string (non-recursive, fast). */ +function extractAll(xml: string, tag: string): string[] { + const results: string[] = []; + const open = `<${tag}`; + const close = ``; + let pos = 0; + while (pos < xml.length) { + const start = xml.indexOf(open, pos); + if (start === -1) break; + const end = xml.indexOf(close, start); + if (end === -1) break; + // Get inner text (content between > and ) + const innerStart = xml.indexOf('>', start) + 1; + results.push(xml.slice(innerStart, end)); + pos = end + close.length; + } + return results; +} + +/** Extract first value of attribute from a tag. */ +function extractAttr(xml: string, tag: string, attr: string): string { + const tagStart = xml.indexOf(`<${tag}`); + if (tagStart === -1) return ''; + const tagEnd = xml.indexOf('>', tagStart); + const tagStr = xml.slice(tagStart, tagEnd + 1); + const attrMatch = tagStr.match(new RegExp(`${attr}="([^"]*)"`)); + return attrMatch ? attrMatch[1] : ''; +} + +/** Extract all matching tag attributes from repeated elements. */ +function extractAttrs(xml: string, tag: string, attr: string): string[] { + const results: string[] = []; + const open = `<${tag}`; + let pos = 0; + while (pos < xml.length) { + const start = xml.indexOf(open, pos); + if (start === -1) break; + const tagEnd = xml.indexOf('>', start); + const tagStr = xml.slice(start, tagEnd + 1); + const match = tagStr.match(new RegExp(`${attr}="([^"]*)"`)); + if (match) results.push(match[1]); + pos = tagEnd + 1; + } + return results; +} + +/** Map configured folder name to EWS DistinguishedFolderId. */ +function resolveDistinguishedFolder(folder?: string): string { + const name = (folder ?? 'INBOX').toLowerCase().replace(/[\s_-]/g, ''); + const map: Record = { + inbox: 'inbox', + posteingang: 'inbox', + deleteditems: 'deleteditems', + gelöschteelemente: 'deleteditems', + trash: 'deleteditems', + sentitems: 'sentitems', + gesendet: 'sentitems', + drafts: 'drafts', + entwürfe: 'drafts', + junk: 'junkemail', + junkemail: 'junkemail', + spam: 'junkemail', + }; + return map[name] ?? 'inbox'; +} + +// ─── NTLM HTTP helper ──────────────────────────────────────────────────────── + +interface NtlmOptions { + url: string; + username: string; + password: string; + domain: string; + workstation: string; + body: string; + headers: Record; + rejectUnauthorized?: boolean; +} + +function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> { + return new Promise((resolve, reject) => { + (httpntlm as any).post(opts, (err: Error | null, res: any) => { + if (err) return reject(err); + resolve({ statusCode: res.statusCode, body: res.body?.toString('utf-8') ?? '' }); + }); + }); +} + +// ─── Provider ──────────────────────────────────────────────────────────────── /** - * Exchange inbox provider using ews-javascript-api. + * ExchangeInboxProvider — NTLM-authenticated EWS via raw SOAP over httpntlm. * - * Implements InboxProvider so it is interchangeable with ImapProvider. - * - * Mirrors the ExchangeProvider calendar pattern (apps/api/src/calendar/providers/exchange.provider.ts) - * but uses email-specific EWS item types instead of calendar types. + * Replaces the ews-javascript-api approach which only supports Basic Auth. + * Uses httpntlm to perform the NTLM challenge-response handshake transparently. * * Security: - * - T-07-03: Error messages are generic — credentials never appear in logs - * - T-07-05: Attachment size is checked before buffering (PDF-bomb mitigation) - * - * Pitfall 6 avoidance: - * - Uses WellKnownFolderName.Inbox + FindItems + EmailMessage.Bind - * - Does NOT use FindAppointments / CalendarView (those are calendar-only EWS APIs) + * - T-07-03: credentials never logged — only generic error messages + * - T-07-05: attachment size checked before buffering (PDF-bomb mitigation) + * - EWS XML is escaped before insertion into SOAP envelopes */ @Injectable() export class ExchangeInboxProvider implements InboxProvider { private readonly logger = new Logger(ExchangeInboxProvider.name); - /** - * Connects to Exchange via EWS, searches the Inbox for emails from the - * configured sender, and downloads PDF attachments. - * - * @param config Decrypted inbox connection parameters - * @returns Emails with PDF attachments as Buffers (empty array on error) - */ async fetchPdfAttachments(config: InboxConfig): Promise { try { return await this.fetchViaEws(config); } catch (error) { - // T-07-03: generic error message — no credential details in log - this.logger.error( - `EWS inbox fetch failed: ${(error as Error).message}`, - ); + this.logger.error(`EWS inbox fetch failed: ${(error as Error).message}`); return []; } } - /** - * Tests whether the Exchange connection can be established. - * Performs a minimal FindItems call on the Inbox with a result limit of 1. - * - * @param config Decrypted inbox connection parameters - * @returns true on success, false on any auth/network failure - */ async testConnection(config: InboxConfig): Promise<{ success: boolean; message?: string }> { try { - // Dynamic import — ews-javascript-api is JS-only, no .d.ts (follows ExchangeProvider pattern) - const ews: any = await import('ews-javascript-api'); - const service = this.buildService(ews, config); + const folder = resolveDistinguishedFolder(config.folder); + const soap = findItemSoap(folder, 1); + const res = await this.ewsPost(config, soap, 'FindItem'); - // Minimal FindItems — just confirm we can connect - const itemView = new ews.ItemView(1); - const folder = this.resolveFolder(ews, config.folder); - await service.FindItems(folder, itemView); + if (res.statusCode === 401) { + return { success: false, message: '401 Unauthorized — credentials rejected or NTLM not allowed' }; + } + if (res.statusCode !== 200) { + return { success: false, message: `HTTP ${res.statusCode}` }; + } + if (res.body.includes('ResponseClass="Error"')) { + const msg = extractAll(res.body, 'm:MessageText')[0] ?? extractAll(res.body, 'MessageText')[0] ?? 'EWS error'; + return { success: false, message: msg }; + } return { success: true }; } catch (err) { const message = (err as Error).message; - // T-07-03: log without credentials; return message to admin for diagnosis this.logger.error(`EWS connection test failed: ${message}`); return { success: false, message }; } } - /** - * Internal EWS fetch implementation. - * Separated from fetchPdfAttachments so the try/catch wraps the entire flow. - */ + // ─── Private ─────────────────────────────────────────────────────────────── + private async fetchViaEws(config: InboxConfig): Promise { - // Dynamic import — ews-javascript-api is JS-only, no .d.ts - // Follows the same pattern as ExchangeProvider (calendar) lines 154–155 - const ews: any = await import('ews-javascript-api'); - const service = this.buildService(ews, config); + const folder = resolveDistinguishedFolder(config.folder); - // Pitfall 6: use WellKnownFolderName with FindItems (NOT FindAppointments) - // FindAppointments is Calendar-only — inbox emails use FindItems - const itemView = new ews.ItemView(50); - const folder = this.resolveFolder(ews, config.folder); - - let findResults: any; - if (config.senderFilter) { - // Filter server-side by sender address (reduces data transfer) - const senderFilter = new ews.SearchFilter.ContainsSubstring( - ews.EmailMessageSchema.From, - config.senderFilter, - ); - findResults = await service.FindItems(folder, senderFilter, itemView); - } else { - findResults = await service.FindItems(folder, itemView); + // 1. FindItem — get IDs of emails with attachments + const findSoap = findItemSoap(folder, 50, config.senderFilter); + const findRes = await this.ewsPost(config, findSoap, 'FindItem'); + if (findRes.statusCode !== 200) { + this.logger.warn(`EWS FindItem returned HTTP ${findRes.statusCode}`); + return []; } + // Parse item IDs and HasAttachments flag from FindItem response + const rawIds = extractAttrs(findRes.body, 't:ItemId', 'Id'); + if (rawIds.length === 0) return []; + + // Only fetch items that have attachments + const hasAttachFlags = extractAll(findRes.body, 't:HasAttachments'); + const itemIds = rawIds.filter((_, i) => hasAttachFlags[i] === 'true'); + if (itemIds.length === 0) return []; + const results: InboxEmail[] = []; - if (!findResults?.Items?.length) { - return results; - } - // Bind each email to load properties including attachments - // EmailMessage.Bind loads the full message with all properties - const propertySet = new ews.PropertySet( - ews.BasePropertySet.FirstClassProperties, - ews.EmailMessageSchema.Attachments, - ); + // 2. GetItem in batches of 10 to load attachment metadata + for (let i = 0; i < itemIds.length; i += 10) { + const batch = itemIds.slice(i, i + 10); + const getRes = await this.ewsPost(config, getItemSoap(batch), 'GetItem'); + if (getRes.statusCode !== 200) continue; - for (const item of findResults.Items) { - let message: any; - try { - message = await ews.EmailMessage.Bind(service, item.Id, propertySet); - } catch (bindErr) { - this.logger.warn( - `Failed to bind EmailMessage (id: ${String(item.Id?.UniqueId ?? 'unknown')}): ${(bindErr as Error).message}`, - ); - continue; - } + // Parse each Message element from GetItem response + const messageBlocks = this.splitMessageBlocks(getRes.body); - // Filter client-side by sender if not already filtered server-side - // (server-side ContainsSubstring may not be case-sensitive on all servers) - if (config.senderFilter && message.From?.Address) { - const senderLower = String(message.From.Address).toLowerCase(); - if (!senderLower.includes(config.senderFilter.toLowerCase())) { + for (const block of messageBlocks) { + const uid = extractAttr(block, 't:ItemId', 'Id'); + const subject = extractAll(block, 't:Subject')[0] ?? ''; + const messageId = extractAll(block, 't:InternetMessageId')[0] ?? ''; + const from = (extractAttr(block, 't:Mailbox', 'SmtpAddress') || + extractAll(block, 't:EmailAddress')[0]) ?? ''; + const dateStr = extractAll(block, 't:DateTimeReceived')[0] ?? ''; + const date = dateStr ? new Date(dateStr) : new Date(); + + // Filter by sender if provided (client-side fallback for case-sensitivity) + if (config.senderFilter && from && + !from.toLowerCase().includes(config.senderFilter.toLowerCase())) { continue; } + + // Collect PDF attachment IDs + const attachmentIds = extractAttrs(block, 't:FileAttachment', 'Id') + .filter((_, idx) => { + const types = extractAll(block, 't:ContentType'); + return (types[idx] ?? '').toLowerCase().includes('pdf'); + }); + + if (attachmentIds.length === 0) continue; + + // 3. GetAttachment for each PDF + const attachments: InboxAttachment[] = []; + for (const attId of attachmentIds) { + const attRes = await this.ewsPost(config, getAttachmentSoap(attId), 'GetAttachment'); + if (attRes.statusCode !== 200) continue; + + const name = extractAll(attRes.body, 't:Name')[0] ?? 'attachment.pdf'; + const content = extractAll(attRes.body, 't:Content')[0] ?? ''; + if (!content) continue; + + const buffer = Buffer.from(content, 'base64'); + if (buffer.length > MAX_ATTACHMENT_BYTES) { + this.logger.warn(`Skipped EWS attachment "${name}" — exceeds size limit (T-07-05)`); + continue; + } + attachments.push({ filename: name, contentType: 'application/pdf', buffer }); + } + + if (attachments.length === 0) continue; + + results.push({ uid, messageId, subject, from, date, attachments }); } - - const pdfAttachments = await this.extractPdfAttachments(message); - if (pdfAttachments.length === 0) continue; - - results.push({ - uid: String(item.Id?.UniqueId ?? String(Date.now())), - messageId: String(message.InternetMessageId ?? ''), - subject: String(message.Subject ?? ''), - from: String(message.From?.Address ?? ''), - date: message.DateTimeReceived - ? new Date(String(message.DateTimeReceived)) - : new Date(), - attachments: pdfAttachments, - }); } return results; } - /** - * Extracts and downloads PDF FileAttachments from an EmailMessage. - * Enforces MAX_ATTACHMENT_BYTES before buffering (T-07-05). - */ - private async extractPdfAttachments( - message: any, - ): Promise> { - const attachments = message.Attachments; - if (!attachments?.Count) return []; - - const pdfs: Array<{ filename: string; contentType: string; buffer: Buffer }> = []; - - for (let i = 0; i < attachments.Count; i++) { - const attachment = attachments.GetAttachment(i); - - // Only process FileAttachments (not ItemAttachments which are embedded messages) - if (!attachment || !attachment.ContentType) continue; - - const contentType: string = String(attachment.ContentType).toLowerCase(); - if (contentType !== 'application/pdf') continue; - - try { - // Load attachment content from Exchange server - await attachment.Load(); - - const content: Buffer | Uint8Array | null = attachment.Content; - if (!content) continue; - - // T-07-05: enforce max attachment size before buffering - if (content.length > MAX_ATTACHMENT_BYTES) { - this.logger.warn( - `Skipped EWS PDF attachment "${String(attachment.Name ?? 'unknown')}" — exceeds ${MAX_ATTACHMENT_BYTES} bytes (T-07-05)`, - ); - continue; - } - - const buffer = Buffer.isBuffer(content) - ? content - : Buffer.from(content); - - pdfs.push({ - filename: String(attachment.Name ?? `attachment-${i}.pdf`), - contentType: 'application/pdf', - buffer, - }); - } catch (loadErr) { - // T-07-03: generic warning — no credential or content details - this.logger.warn( - `Failed to load EWS attachment "${String(attachment.Name ?? 'unknown')}": ${(loadErr as Error).message}`, - ); - } + /** Split a GetItem response body into per-message XML blocks. */ + private splitMessageBlocks(xml: string): string[] { + const blocks: string[] = []; + const open = ''; + const close = ''; + let pos = 0; + while (pos < xml.length) { + const start = xml.indexOf(open, pos); + if (start === -1) break; + const end = xml.indexOf(close, start); + if (end === -1) break; + blocks.push(xml.slice(start + open.length, end)); + pos = end + close.length; } - - return pdfs; + // If no blocks, treat whole response as one block + return blocks.length > 0 ? blocks : [xml]; } - /** - * Constructs and configures an ExchangeService from InboxConfig. - * Follows the same pattern as ExchangeProvider.fetchViaEws() lines 155–163. - */ - private buildService(ews: any, config: InboxConfig): any { - const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013); - service.Url = new ews.Uri(config.host); - service.Credentials = new ews.WebCredentials( - config.username ?? '', - config.password ?? '', - config.domain ?? undefined, - ); - return service; - } - - /** - * Maps a folder name string to an EWS WellKnownFolderName enum value. - * Supports common German and English names. Defaults to Inbox. - */ - private resolveFolder(ews: any, folder?: string): any { - const name = (folder ?? 'INBOX').toLowerCase().replace(/[\s_-]/g, ''); - const map: Record = { - inbox: 'Inbox', - posteingang: 'Inbox', - deleteditems: 'DeletedItems', - gelöschteelemente: 'DeletedItems', - trash: 'DeletedItems', - sentitems: 'SentItems', - gesendet: 'SentItems', - drafts: 'Drafts', - entwürfe: 'Drafts', - junk: 'JunkEmail', - junkemail: 'JunkEmail', - spam: 'JunkEmail', + /** POST a SOAP body to the EWS endpoint using NTLM authentication. */ + private async ewsPost( + config: InboxConfig, + soap: string, + action: string, + ): Promise<{ statusCode: number; body: string }> { + const opts: NtlmOptions = { + url: config.host, // must be full EWS URL: https://server/EWS/Exchange.asmx + username: config.username ?? '', + password: config.password ?? '', + domain: config.domain ?? '', + workstation: '', + body: soap, + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + 'SOAPAction': `"http://schemas.microsoft.com/exchange/services/2006/messages/${action}"`, + }, }; - const key = map[name] ?? 'Inbox'; - return ews.WellKnownFolderName[key] ?? ews.WellKnownFolderName.Inbox; + return ntlmPost(opts); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b74e38..fa47340 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: ews-javascript-api: specifier: 0.15.3 version: 0.15.3 + httpntlm: + specifier: ^1.8.13 + version: 1.8.13 imapflow: specifier: ^1.4.3 version: 1.4.3 @@ -3069,6 +3072,14 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + httpntlm@1.8.13: + resolution: {integrity: sha512-2F2FDPiWT4rewPzNMg3uPhNkP3NExENlUGADRUDPQvuftuUTGW98nLZtGemCIW3G40VhWZYgkIDcQFAwZ3mf2Q==} + engines: {node: '>=10.4.0'} + + httpreq@1.1.1: + resolution: {integrity: sha512-uhSZLPPD2VXXOSN8Cni3kIsoFHaU2pT/nySEU/fHr/ePbqHYr0jeiQRmUKLEirC09SFPsdMoA7LU7UXMd/w0Kw==} + engines: {node: '>= 6.15.1'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -4974,6 +4985,9 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + underscore@1.12.1: + resolution: {integrity: sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -8192,6 +8206,15 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + httpntlm@1.8.13: + dependencies: + des.js: 1.1.0 + httpreq: 1.1.1 + js-md4: 0.3.2 + underscore: 1.12.1 + + httpreq@1.1.1: {} + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -10907,6 +10930,8 @@ snapshots: uint8array-extras@1.5.0: {} + underscore@1.12.1: {} + undici-types@6.21.0: {} undici@6.27.0: