fix: measure and trace the sign-in check's calls to Stalwart

This commit is contained in:
Linus Rath
2026-11-01 03:49:53 +01:00
parent 77d4114d4a
commit 75cb0f62fe
2 changed files with 31 additions and 7 deletions
+16 -7
View File
@@ -5,6 +5,7 @@ import {
type PublicFetchResponse,
} from '@/lib/security/url-guard';
import { forwardedClientHeaders } from '@/lib/auth/client-address';
import { observeUpstream } from '@/lib/metrics/upstream';
const VERIFY_TIMEOUT_MS = 10000;
const MAX_REDIRECTS = 3;
@@ -150,7 +151,7 @@ export function assertBasicAuthMatchesUsername(authHeader: string, claimedUserna
export interface VerifiedJmapSession {
apiUrl: string;
/** Canonical login name the server reports for the credential (RFC 8620 §2). */
/** Canonical login name the server reports for the credential (RFC 8620 §2). */
username?: string;
primaryAccounts?: Record<string, string>;
accounts: Record<string, unknown>;
@@ -361,9 +362,13 @@ async function fetchIdentityEmails(
};
// No redirect handling here: a redirect on the POST would downgrade it to
// a GET and can only come from a misconfigured proxy - treat as failure.
const response = options.trusted
? await fetch(apiUrl, { ...requestInit, redirect: 'manual' })
: await fetchPublicUrl(apiUrl, requestInit);
// Measured and traced like every call to Stalwart (lib/metrics/upstream.ts).
const response = await observeUpstream('stalwart', async (trace) => {
const init = { ...requestInit, headers: { ...requestInit.headers, ...trace } };
return options.trusted
? await fetch(apiUrl, { ...init, redirect: 'manual' })
: await fetchPublicUrl(apiUrl, init);
});
if (!response.ok) {
throw new JmapAuthVerificationError(
response.status === 401 || response.status === 403
@@ -435,9 +440,13 @@ async function fetchVerifiedSession(
// Untrusted (user-supplied) endpoints connect through the rebinding-safe
// fetch so the address validated above is the one the socket uses.
// Admin-configured servers may legitimately live on private addresses.
response = options.trusted
? await fetch(currentUrl, { ...requestInit, redirect: 'manual' })
: await fetchPublicUrl(currentUrl, requestInit);
const target = currentUrl;
response = await observeUpstream('stalwart', async (trace) => {
const init = { ...requestInit, headers: { ...requestInit.headers, ...trace } };
return options.trusted
? await fetch(target, { ...init, redirect: 'manual' })
: await fetchPublicUrl(target, init);
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
+15
View File
@@ -13,6 +13,7 @@ import { markTracingActive } from '../config';
import { scrubAttributes, scrubSpanName } from '../scrub';
import { observeUpstream } from '@/lib/metrics/upstream';
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
import { verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
const exporter = new InMemorySpanExporter();
const provider = createProvider(exporter, false);
@@ -56,6 +57,20 @@ describe('spans for upstream calls', () => {
}
});
it('reach Stalwart from the sign-in check too', async () => {
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({ apiUrl: 'https://mail.example.org/jmap/', username: 'u@example.org' }), { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
try {
await verifyJmapAuth('https://mail.example.org', 'Basic dTpw', { trusted: true }).catch(() => undefined);
const headers = new Headers((fetchSpy.mock.calls[0][1] as RequestInit).headers);
expect(String(fetchSpy.mock.calls[0][0])).toBe('https://mail.example.org/.well-known/jmap');
expect(headers.get('traceparent')).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
expect(headers.get('authorization')).toBe('Basic dTpw');
} finally {
vi.unstubAllGlobals();
}
});
it('mark 5xx and thrown errors as errors, without the error message', async () => {
await expect(observeUpstream('translate', async () => { throw new Error(`refused for ${ADDRESS}`); })).rejects.toThrow();
const [span] = exporter.getFinishedSpans();