From 31e218295da8d0ac6c8099d363885dea917d4e2b Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 22 Aug 2026 17:12:43 +0200 Subject: [PATCH] fix(http): preserve SDK headers when injecting User-Agent The v1.76.0 User-Agent injection object-spread `fetchOptions.headers`, which silently drops all headers when the value is a Headers instance. The Immich SDK passes a Headers object with the merged x-api-key header, so requests lost their API key and returned 401 (issue #6672). Build a real Headers instance instead, keeping case-insensitive user-agent handling. --- .../core/src/infrastructure/http/index.ts | 1 + .../core/src/infrastructure/http/request.ts | 32 ++++++------ .../test/infrastructure/http/request.spec.ts | 50 +++++++++++++++++++ 3 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/test/infrastructure/http/request.spec.ts diff --git a/packages/core/src/infrastructure/http/index.ts b/packages/core/src/infrastructure/http/index.ts index 572388f2a..116cf5971 100644 --- a/packages/core/src/infrastructure/http/index.ts +++ b/packages/core/src/infrastructure/http/index.ts @@ -5,4 +5,5 @@ export { createCustomCheckServerIdentity, createHttpsAgentAsync, fetchWithTrustedCertificatesAsync, + mergeHeadersWithUserAgent, } from "./request"; diff --git a/packages/core/src/infrastructure/http/request.ts b/packages/core/src/infrastructure/http/request.ts index 20af8232f..0b06b2725 100644 --- a/packages/core/src/infrastructure/http/request.ts +++ b/packages/core/src/infrastructure/http/request.ts @@ -18,6 +18,14 @@ import packageJson from "../../../../../package.json"; export const getDefaultUserAgent = () => `Homarr/${packageJson.version} (+https://homarr.dev)`; +export const mergeHeadersWithUserAgent = (headers: unknown): Headers => { + const mergedHeaders = new Headers(headers as HeadersInit); + if (!mergedHeaders.has("user-agent")) { + mergedHeaders.set("user-agent", getDefaultUserAgent()); + } + return mergedHeaders; +}; + export const createCustomCheckServerIdentity = ( trustedHostnames: TrustedCertificateHostname[], ): typeof checkServerIdentity => { @@ -77,30 +85,18 @@ export const fetchWithTrustedCertificatesAsync = async ( undefined, options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined, )); + const buildFetchOptions = (fetchOptions: Omit) => ({ + ...fetchOptions, + headers: mergeHeadersWithUserAgent(fetchOptions.headers) as unknown as RequestInit["headers"], + }); if (options?.timeout) { const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options; return await withTimeoutAsync( - async (signal) => - fetch(url, { - ...fetchOptions, - headers: { - "User-Agent": getDefaultUserAgent(), - ...fetchOptions.headers, - }, - signal, - dispatcher: agent, - }), + async (signal) => fetch(url, { ...buildFetchOptions(fetchOptions), signal, dispatcher: agent }), options.timeout, ); } const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {}; - return fetch(url, { - ...fetchOptions, - headers: { - "User-Agent": getDefaultUserAgent(), - ...fetchOptions.headers, - }, - dispatcher: agent, - }); + return fetch(url, { ...buildFetchOptions(fetchOptions), dispatcher: agent }); }; diff --git a/packages/core/src/test/infrastructure/http/request.spec.ts b/packages/core/src/test/infrastructure/http/request.spec.ts new file mode 100644 index 000000000..4d1f8527a --- /dev/null +++ b/packages/core/src/test/infrastructure/http/request.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; + +import { mergeHeadersWithUserAgent } from "@homarr/core/infrastructure/http"; + +describe("mergeHeadersWithUserAgent", () => { + test("should keep existing headers when given a Headers instance", () => { + // Arrange + const headers = new Headers({ "x-api-key": "some-api-key", Accept: "application/json" }); + + // Act + const merged = mergeHeadersWithUserAgent(headers); + + // Assert + expect(merged.get("x-api-key")).toBe("some-api-key"); + expect(merged.get("accept")).toBe("application/json"); + expect(merged.get("user-agent")).toContain("Homarr/"); + }); + + test("should keep existing headers when given a record", () => { + // Arrange + const headers = { "x-api-key": "some-api-key", Accept: "application/json" }; + + // Act + const merged = mergeHeadersWithUserAgent(headers); + + // Assert + expect(merged.get("x-api-key")).toBe("some-api-key"); + expect(merged.get("accept")).toBe("application/json"); + expect(merged.get("user-agent")).toContain("Homarr/"); + }); + + test("should not override an existing user-agent in any casing", () => { + // Arrange + const headers = new Headers({ "USER-AGENT": "custom-agent" }); + + // Act + const merged = mergeHeadersWithUserAgent(headers); + + // Assert + expect(merged.get("user-agent")).toBe("custom-agent"); + }); + + test("should fall back to the default user agent when no headers are provided", () => { + // Act + const merged = mergeHeadersWithUserAgent(undefined); + + // Assert + expect(merged.get("user-agent")).toContain("Homarr/"); + }); +});