From 44d7426e9f31dbcb91185f043b33a58268320e28 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sun, 16 Aug 2026 15:47:55 +0200 Subject: [PATCH] fix(http): normalize User-Agent injection with Headers Object-spreading fetchOptions.headers breaks when callers pass a Headers instance (spread yields no entries) or a tuple array (numeric keys), and is case-sensitive. Build a Headers instance and set the default User-Agent only when no case-insensitive User-Agent entry exists, via a shared helper in both fetch paths. Adds unit tests covering record, Headers, tuple-array, and casing variants. --- .../src/infrastructure/http/request.spec.ts | 77 +++++++++++++++++++ .../core/src/infrastructure/http/request.ts | 20 ++--- 2 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/infrastructure/http/request.spec.ts diff --git a/packages/core/src/infrastructure/http/request.spec.ts b/packages/core/src/infrastructure/http/request.spec.ts new file mode 100644 index 000000000..643791781 --- /dev/null +++ b/packages/core/src/infrastructure/http/request.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() })); + +vi.mock("undici", async (importOriginal) => { + const actual = await (importOriginal as () => Promise>)(); + return { ...actual, fetch: fetchMock }; +}); + +import { Headers } from "undici"; +import { fetchWithTrustedCertificatesAsync, getDefaultUserAgent } from "./request"; + +const dummyDispatcher = {} as never; + +const lastFetchHeaders = (): Headers => { + const [, init] = fetchMock.mock.calls.at(-1) ?? []; + return init?.headers as unknown as Headers; +}; + +describe("fetchWithTrustedCertificatesAsync default User-Agent", () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + const defaultUserAgent = getDefaultUserAgent(); + + it("sets the default User-Agent when no header is provided", async () => { + await fetchWithTrustedCertificatesAsync("https://example.com", { dispatcher: dummyDispatcher }); + expect(lastFetchHeaders().get("User-Agent")).toBe(defaultUserAgent); + }); + + it("keeps caller-provided record headers", async () => { + await fetchWithTrustedCertificatesAsync("https://example.com", { + dispatcher: dummyDispatcher, + headers: { Authorization: "token" }, + }); + const headers = lastFetchHeaders(); + expect(headers.get("authorization")).toBe("token"); + expect(headers.get("User-Agent")).toBe(defaultUserAgent); + }); + + it("normalizes a Headers instance without losing entries", async () => { + await fetchWithTrustedCertificatesAsync("https://example.com", { + dispatcher: dummyDispatcher, + headers: new Headers({ "x-custom": "present" }), + }); + const headers = lastFetchHeaders(); + expect(headers.get("x-custom")).toBe("present"); + expect(headers.get("User-Agent")).toBe(defaultUserAgent); + }); + + it("normalizes a tuple-array of headers", async () => { + await fetchWithTrustedCertificatesAsync("https://example.com", { + dispatcher: dummyDispatcher, + headers: [["x-tuple", "value"]], + }); + const headers = lastFetchHeaders(); + expect(headers.get("x-tuple")).toBe("value"); + expect(headers.get("User-Agent")).toBe(defaultUserAgent); + }); + + it("does not override a caller-provided User-Agent regardless of casing", async () => { + await fetchWithTrustedCertificatesAsync("https://example.com", { + dispatcher: dummyDispatcher, + headers: { "user-agent": "custom-agent" }, + }); + expect(lastFetchHeaders().get("User-Agent")).toBe("custom-agent"); + }); + + it("sets the default User-Agent on the timeout path too", async () => { + await fetchWithTrustedCertificatesAsync("https://example.com", { + dispatcher: dummyDispatcher, + timeout: 1000, + }); + expect(lastFetchHeaders().get("User-Agent")).toBe(defaultUserAgent); + }); +}); diff --git a/packages/core/src/infrastructure/http/request.ts b/packages/core/src/infrastructure/http/request.ts index 81e581e6b..97ba40859 100644 --- a/packages/core/src/infrastructure/http/request.ts +++ b/packages/core/src/infrastructure/http/request.ts @@ -3,7 +3,7 @@ import { Agent as HttpsAgent } from "node:https"; import { checkServerIdentity } from "node:tls"; import axios from "axios"; import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici"; -import { fetch } from "undici"; +import { Headers, fetch } from "undici"; import { getAllTrustedCertificatesAsync, @@ -17,6 +17,14 @@ import { withTimeoutAsync } from "./timeout"; export const getDefaultUserAgent = () => `Homarr/${VERSION} (+https://homarr.dev)`; +const createFetchHeaders = (initial: RequestInit["headers"]): Headers => { + const headers = new Headers(initial); + if (!headers.has("User-Agent")) { + headers.set("User-Agent", getDefaultUserAgent()); + } + return headers; +}; + export const createCustomCheckServerIdentity = ( trustedHostnames: TrustedCertificateHostname[], ): typeof checkServerIdentity => { @@ -82,10 +90,7 @@ export const fetchWithTrustedCertificatesAsync = async ( async (signal) => fetch(url, { ...fetchOptions, - headers: { - "User-Agent": getDefaultUserAgent(), - ...fetchOptions.headers, - }, + headers: createFetchHeaders(fetchOptions.headers), signal, dispatcher: agent, }), @@ -96,10 +101,7 @@ export const fetchWithTrustedCertificatesAsync = async ( const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {}; return fetch(url, { ...fetchOptions, - headers: { - "User-Agent": getDefaultUserAgent(), - ...fetchOptions.headers, - }, + headers: createFetchHeaders(fetchOptions.headers), dispatcher: agent, }); };