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.
This commit is contained in:
ajnart committed 2026-08-22 17:12:43 +02:00
1 parent aca3b5d861
commit 31e218295d
3 files changed
+65 -18

No files matched your search

@@ -5,4 +5,5 @@ export {
createCustomCheckServerIdentity,
createHttpsAgentAsync,
fetchWithTrustedCertificatesAsync,
mergeHeadersWithUserAgent,
} from "./request";
@@ -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<RequestInit, "dispatcher" | "bodyTimeout" | "timeout">) => ({
...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 });
};
@@ -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/");
});
});