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.
This commit is contained in:
ajnart committed 2026-08-16 15:47:55 +02:00
1 parent c08ad3a69a
commit 44d7426e9f
2 files changed
+88 -9

No files matched your search

@@ -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<Record<string, unknown>>)();
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);
});
});
@@ -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,
});
};