From f35ed6fb02fa9f655baf46556cb1107984eede48 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:32:28 +0200 Subject: [PATCH] fix: give every upstream mail server request a deadline --- lib/__tests__/server-fetch.test.ts | 15 +++++++++++++++ lib/stalwart/server-fetch.ts | 8 +++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/server-fetch.test.ts b/lib/__tests__/server-fetch.test.ts index bb6b29acf..f524feaea 100644 --- a/lib/__tests__/server-fetch.test.ts +++ b/lib/__tests__/server-fetch.test.ts @@ -70,6 +70,21 @@ describe('fetchJmapServer', () => { expect(guardedFetch).not.toHaveBeenCalled(); }); + it('gives every request a deadline unless the caller brought one', async () => { + // A slow or silent server (with custom endpoints on, one the user chose) + // used to be able to hold the connection open for as long as it liked. + await fetchJmapServer('http://10.0.0.5/jmap/', { method: 'POST', body: '{}' }, true); + expect(plainFetch.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); + + guardedFetch.mockResolvedValueOnce(response(200, {}, 'guarded')); + await fetchJmapServer('https://custom.example/jmap/', { method: 'GET' }, false); + expect(guardedFetch.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); + + const own = new AbortController().signal; + await fetchJmapServer('http://10.0.0.5/jmap/', { method: 'GET', signal: own }, true); + expect(plainFetch.mock.calls[1][1].signal).toBe(own); + }); + it('routes untrusted servers through fetchPublicUrl', async () => { guardedFetch.mockResolvedValueOnce(response(200, {}, 'guarded')); const res = await fetchJmapServer( diff --git a/lib/stalwart/server-fetch.ts b/lib/stalwart/server-fetch.ts index eac47724d..6c615d383 100644 --- a/lib/stalwart/server-fetch.ts +++ b/lib/stalwart/server-fetch.ts @@ -20,6 +20,8 @@ import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-server import { fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard'; const MAX_REDIRECTS = 5; +/** Deadline for one upstream request when the caller sets none. */ +export const UPSTREAM_TIMEOUT_MS = 30_000; export async function isTrustedJmapServerUrl(serverUrl: string): Promise { await configManager.ensureLoaded(); @@ -56,9 +58,13 @@ function headersToRecord(init: HeadersInit | undefined): Record */ export async function fetchJmapServer( url: string, - init: ServerFetchInit = {}, + givenInit: ServerFetchInit = {}, trusted: boolean | undefined, ): Promise { + // Every call gets a deadline: a slow or silent server (with custom + // endpoints on, one the user chose) must not pin a connection for as long + // as it likes. Callers with their own signal keep it. + const init: ServerFetchInit = givenInit.signal ? givenInit : { ...givenInit, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) }; if (trusted !== false) { return fetch(url, init); }