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:
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,
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user