diff --git a/apps/docs/docs/integrations/frigate/index.mdx b/apps/docs/docs/integrations/frigate/index.mdx
index 2c91cb12d..f41d82e1e 100644
--- a/apps/docs/docs/integrations/frigate/index.mdx
+++ b/apps/docs/docs/integrations/frigate/index.mdx
@@ -22,4 +22,6 @@ The Frigate statistics use `/api/stats` and report camera count, service uptime,
-Use Frigate's internal HTTP API (port **5000**), reachable from Homarr. This endpoint does not require credentials; the authenticated web interface on port 8971 is not supported by this integration. Keep the internal API on a trusted network.
+Use Frigate's authenticated interface (port **8971**) with an admin account's username and password. Homarr exchanges these for a session token and verifies the account before requesting statistics. Frigate's default `frigate_token` cookie name is required. The integration URL must use `https://`; Homarr rejects `http://` URLs so credentials are never sent unencrypted, and certificates must be trusted by Homarr.
+
+Existing integrations using the unauthenticated port **5000** must update their URL and add credentials. Homarr does not fall back to the unauthenticated API; keep that listener private.
diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts
index 027cdd35f..2d0c5df2c 100644
--- a/packages/definitions/src/integration.ts
+++ b/packages/definitions/src/integration.ts
@@ -119,11 +119,11 @@ export const integrationDefs = {
frigate: {
httpAuth: { type: "adapter" },
name: "Frigate",
- secretKinds: [[]],
+ secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/frigate.svg",
category: ["miscellaneous"],
documentationSlug: "frigate",
- defaultPort: 5000,
+ defaultPort: 8971,
},
komga: {
httpAuth: { type: "apiKeyHeader", name: "X-API-Key" },
diff --git a/packages/integrations/src/stats/frigate-auth.spec.ts b/packages/integrations/src/stats/frigate-auth.spec.ts
new file mode 100644
index 000000000..d0c792236
--- /dev/null
+++ b/packages/integrations/src/stats/frigate-auth.spec.ts
@@ -0,0 +1,205 @@
+// @vitest-environment node
+
+import { inspect } from "node:util";
+
+import { Headers, Response } from "undici";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+import { integrationDefs } from "@homarr/definitions";
+
+import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
+import { frigateStatsProvider } from "./providers/frigate";
+import { StatsIntegration } from "./stats-integration";
+
+const logger = vi.hoisted(() => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }));
+
+vi.mock("@homarr/core/infrastructure/http", () => ({
+ fetchWithTrustedCertificatesAsync: vi.fn(),
+ createAxiosCertificateInstanceAsync: vi.fn(),
+ createCertificateAgentAsync: vi.fn(),
+}));
+vi.mock("@homarr/core/infrastructure/certificates", () => ({
+ getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
+}));
+vi.mock("@homarr/core/infrastructure/logs", () => ({
+ createLogger: () => logger,
+}));
+
+const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.test_signature";
+const credentials = [
+ { kind: "username" as const, value: "operator" },
+ { kind: "password" as const, value: "private-test-password" },
+];
+const fetchAsync = vi.mocked(fetchWithTrustedCertificatesAsync);
+const createIntegration = (
+ decryptedSecrets: IntegrationInput["decryptedSecrets"] = credentials,
+ url = "https://frigate.test:8971",
+) =>
+ new StatsIntegration(
+ {
+ id: "frigate-test",
+ kind: "frigate",
+ name: "Frigate",
+ url,
+ externalUrl: null,
+ decryptedSecrets,
+ },
+ frigateStatsProvider,
+ );
+
+const loginResponse = (cookie = `frigate_token=${token}; HttpOnly; Path=/; SameSite=Lax`) =>
+ new Response(null, { status: 200, headers: { "set-cookie": cookie } });
+const profileResponse = (username = "operator", role = "admin") =>
+ Response.json({ username, role, allowed_cameras: [] });
+const statsResponse = () => Response.json({ cameras: { front: {} }, service: { uptime: 42, version: "0.18.0" } });
+
+beforeEach(() => fetchAsync.mockReset());
+
+describe("authenticated Frigate", () => {
+ test("uses a bodyless cookie login and the same verified session for native stats", async () => {
+ fetchAsync
+ .mockResolvedValueOnce(loginResponse())
+ .mockResolvedValueOnce(profileResponse())
+ .mockResolvedValueOnce(statsResponse());
+ expect(await createIntegration().getStatsAsync()).toEqual({ cameras: 1, uptime: 42, version: "0.18.0" });
+ expect(fetchAsync.mock.calls.map(([url]) => String(url))).toEqual([
+ "https://frigate.test:8971/api/login",
+ "https://frigate.test:8971/api/profile",
+ "https://frigate.test:8971/api/stats",
+ ]);
+ const login = fetchAsync.mock.calls[0]?.[1];
+ expect(login).toMatchObject({
+ method: "POST",
+ body: JSON.stringify({ user: "operator", password: credentials[1]?.value }),
+ redirect: "error",
+ });
+ expect(new Headers(login?.headers).get("origin")).toBeNull();
+ expect(login?.signal).toBeInstanceOf(AbortSignal);
+ for (const [, init] of fetchAsync.mock.calls.slice(1)) {
+ expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${token}`);
+ expect(init?.redirect).toBe("error");
+ }
+ });
+
+ test("supplies verified bearer credentials and explicit token redaction for generic requests", async () => {
+ fetchAsync.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(profileResponse());
+ expect(await createIntegration().getHttpAuthenticationAsync()).toEqual({
+ headers: { Authorization: `Bearer ${token}` },
+ redactValues: [token],
+ });
+ expect(fetchAsync).toHaveBeenCalledTimes(2);
+ });
+
+ test("uses the injected trusted transport for native connectivity testing", async () => {
+ const injectedFetch = vi
+ .fn()
+ .mockResolvedValueOnce(loginResponse())
+ .mockResolvedValueOnce(profileResponse())
+ .mockResolvedValueOnce(statsResponse());
+ // Only fetch is used by this provider; the other transports must never be invoked.
+ const testing = { fetchAsync: injectedFetch } as unknown as IntegrationTestingInput;
+ expect(await createIntegration().getStatsAsync(AbortSignal.timeout(1000), testing)).toMatchObject({ uptime: 42 });
+ expect(injectedFetch).toHaveBeenCalledTimes(3);
+ expect(fetchAsync).not.toHaveBeenCalled();
+ });
+
+ test.each([
+ { secrets: [] },
+ { secrets: [{ kind: "username" as const, value: "operator" }] },
+ {
+ secrets: [
+ { kind: "username" as const, value: "" },
+ { kind: "password" as const, value: "private-test-password" },
+ ],
+ },
+ ])("rejects missing credentials without attempting an unauthenticated request (%j)", async ({ secrets }) => {
+ await expect(createIntegration(secrets).getStatsAsync()).rejects.toThrow();
+ expect(fetchAsync).not.toHaveBeenCalled();
+ });
+
+ test.each([401, 403, 404, 302])("rejects login status %i before profile or stats", async (status) => {
+ fetchAsync.mockResolvedValueOnce(new Response("private login error", { status }));
+ await expect(createIntegration().getStatsAsync()).rejects.toThrow();
+ expect(fetchAsync).toHaveBeenCalledTimes(1);
+ });
+
+ test.each(["other_cookie=value", "frigate_token=not-a-jwt", `frigate_token=${"x".repeat(4097)}.b.c`])(
+ "rejects missing or invalid cookies",
+ async (cookie) => {
+ fetchAsync.mockResolvedValueOnce(loginResponse(cookie));
+ await expect(createIntegration().getHttpAuthenticationAsync()).rejects.toThrow();
+ expect(fetchAsync).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ test("rejects duplicate session cookies", async () => {
+ const headers = new Headers();
+ headers.append("set-cookie", `frigate_token=${token}; Path=/`);
+ headers.append("set-cookie", `frigate_token=${token}; Path=/api`);
+ fetchAsync.mockResolvedValueOnce(new Response(null, { headers }));
+ await expect(createIntegration().getStatsAsync()).rejects.toThrow();
+ expect(fetchAsync).toHaveBeenCalledTimes(1);
+ });
+
+ test.each([
+ ["operator", "viewer"],
+ ["another-admin", "admin"],
+ ])("rejects a profile for %s with role %s before stats", async (username, role) => {
+ fetchAsync.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(profileResponse(username, role));
+ await expect(createIntegration().getStatsAsync()).rejects.toThrow();
+ expect(fetchAsync).toHaveBeenCalledTimes(2);
+ });
+
+ test("bounds private response headers and discards the login body without parsing it", async () => {
+ const response = new Response("not JSON", { headers: { "set-cookie": `other=${"x".repeat(16_384)}` } });
+ fetchAsync.mockResolvedValueOnce(response);
+ await expect(createIntegration().getHttpAuthenticationAsync()).rejects.toThrow();
+ expect(response.body?.locked).toBe(false);
+ expect(fetchAsync).toHaveBeenCalledTimes(1);
+ });
+
+ test("discards a successful login body without parsing or exposing its contents", async () => {
+ const response = new Response(`private body ${"x".repeat(65_536)}`, {
+ headers: { "set-cookie": `frigate_token=${token}; Path=/` },
+ });
+ const parse = vi.spyOn(response, "json");
+ if (!response.body) throw new Error("Fixture must have a body");
+ const cancel = vi.spyOn(response.body, "cancel");
+ fetchAsync.mockResolvedValueOnce(response).mockResolvedValueOnce(profileResponse());
+ expect(await createIntegration().getHttpAuthenticationAsync()).toMatchObject({ redactValues: [token] });
+ expect(cancel).toHaveBeenCalledOnce();
+ expect(parse).not.toHaveBeenCalled();
+ });
+
+ test.each(["login", "profile", "stats"])("does not expose transport secrets from %s", async (stage) => {
+ if (stage !== "login") fetchAsync.mockResolvedValueOnce(loginResponse());
+ if (stage === "stats") fetchAsync.mockResolvedValueOnce(profileResponse());
+ fetchAsync.mockRejectedValueOnce(
+ new Error(`secret ${credentials[1]?.value} ${token}`, { cause: new Error(token) }),
+ );
+ const failure: unknown = await createIntegration()
+ .getStatsAsync()
+ .catch((error: unknown) => error);
+ expect(String(failure)).not.toContain(token);
+ expect(String(failure)).not.toContain(credentials[1]?.value);
+ expect(JSON.stringify(failure)).not.toContain(token);
+ expect(JSON.stringify(failure)).not.toContain(credentials[1]?.value);
+ expect(inspect(failure, { depth: 10 })).not.toContain(token);
+ expect(inspect(failure, { depth: 10 })).not.toContain(credentials[1]?.value);
+ expect(inspect(logger.warn.mock.calls, { depth: 10 })).not.toContain(token);
+ expect(inspect(logger.warn.mock.calls, { depth: 10 })).not.toContain(credentials[1]?.value);
+ });
+
+ test("rejects HTTP integration URLs without transmitting credentials", async () => {
+ const integration = createIntegration(credentials, "http://frigate.test:8971");
+ await expect(integration.getHttpAuthenticationAsync()).rejects.toThrow();
+ await expect(integration.getStatsAsync()).rejects.toThrow();
+ expect(fetchAsync).not.toHaveBeenCalled();
+ });
+
+ test("requires saved credentials and defaults to the authenticated port", () => {
+ expect(integrationDefs.frigate.secretKinds).toEqual([["username", "password"]]);
+ expect(integrationDefs.frigate.defaultPort).toBe(8971);
+ });
+});
diff --git a/packages/integrations/src/stats/providers/frigate.ts b/packages/integrations/src/stats/providers/frigate.ts
index a742eb991..aa5428004 100644
--- a/packages/integrations/src/stats/providers/frigate.ts
+++ b/packages/integrations/src/stats/providers/frigate.ts
@@ -1,6 +1,6 @@
import { z } from "zod";
-import type { StatsProvider } from "../types";
+import type { StatsFetchContext, StatsProvider } from "../types";
const statsSchema = z
.object({
@@ -10,14 +10,56 @@ const statsSchema = z
.passthrough();
export const frigateStatsProvider = {
- getHttpAuthentication: () => ({ headers: {} }),
+ getHttpAuthenticationAsync,
metrics: [
{ key: "cameras", label: "Cameras", unit: "count" },
{ key: "uptime", label: "Uptime", unit: "seconds" },
{ key: "version", label: "Version", unit: "text" },
],
async fetchAsync(context) {
- const stats = statsSchema.parse(await context.requestAsync("/api/stats", { signal: context.signal }));
- return { cameras: Object.keys(stats.cameras).length, uptime: stats.service.uptime, version: stats.service.version };
+ const authentication = await getHttpAuthenticationAsync(context);
+ try {
+ const stats = statsSchema.parse(
+ await context.requestAsync("/api/stats", { headers: authentication.headers, signal: context.signal }),
+ );
+ return {
+ cameras: Object.keys(stats.cameras).length,
+ uptime: stats.service.uptime,
+ version: stats.service.version,
+ };
+ } catch {
+ throw new Error("Frigate stats request failed");
+ }
},
} satisfies StatsProvider;
+
+async function getHttpAuthenticationAsync(context: StatsFetchContext) {
+ if (context.url.protocol !== "https:") {
+ throw new Error("Frigate authentication requires an HTTPS integration URL to protect credentials");
+ }
+ try {
+ const username = context.secret("username");
+ const password = context.secret("password");
+ if (!username || !password) throw new Error("Missing credentials");
+ const response = await context.requestResponseAsync("/api/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ user: username, password }),
+ signal: context.signal,
+ });
+ if (response.status !== 200) throw new Error("Login rejected");
+ const cookies = response.headers.getSetCookie().filter((cookie) => cookie.startsWith("frigate_token="));
+ if (cookies.length !== 1) throw new Error("Missing or ambiguous session cookie");
+ const token = cookies[0]?.split(";", 1)[0]?.slice("frigate_token=".length);
+ if (!token || token.length > 4096 || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token)) {
+ throw new Error("Invalid session cookie");
+ }
+ const headers = { Authorization: `Bearer ${token}` };
+ z.object({ username: z.literal(username), role: z.literal("admin"), allowed_cameras: z.array(z.string()) }).parse(
+ await context.requestAsync("/api/profile", { headers, signal: context.signal }),
+ );
+ return { headers, redactValues: [token] };
+ } catch {
+ throw new Error("Frigate authentication failed");
+ }
+}
diff --git a/packages/integrations/src/stats/stats-integration.ts b/packages/integrations/src/stats/stats-integration.ts
index c6405f5df..057adfb3c 100644
--- a/packages/integrations/src/stats/stats-integration.ts
+++ b/packages/integrations/src/stats/stats-integration.ts
@@ -29,9 +29,11 @@ export class StatsIntegration extends Integration {
const signal = AbortSignal.timeout(15_000);
return await this.provider.getHttpAuthenticationAsync({
signal,
+ url: new URL(this.integration.url),
secret: (kind) => this.getSecretValue(kind),
hasSecret: (kind) => this.hasSecretValue(kind),
requestAsync: this.createRequestAsync(signal, undefined, undefined, true),
+ requestResponseAsync: this.createResponseRequestAsync(signal),
});
}
if (!this.provider.getHttpAuthentication) return await super.getHttpAuthenticationAsync();
@@ -49,12 +51,37 @@ export class StatsIntegration extends Integration {
}
return await this.provider.fetchAsync({
signal,
+ url: new URL(this.integration.url),
secret: (kind) => this.getSecretValue(kind),
hasSecret: (kind) => this.hasSecretValue(kind),
requestAsync: this.createRequestAsync(signal, testing, authentication),
+ requestResponseAsync: this.createResponseRequestAsync(signal, testing),
});
}
+ private createResponseRequestAsync(
+ signal: AbortSignal,
+ testing?: IntegrationTestingInput,
+ ): StatsFetchContext["requestResponseAsync"] {
+ return async (path, init) => {
+ try {
+ const fetchAsync = testing?.fetchAsync ?? fetchWithTrustedCertificatesAsync;
+ const response = await fetchAsync(this.url(path), { ...init, signal, redirect: "error" });
+ // Login bodies can be empty or contain sensitive error details. Do not read them.
+ await response.body?.cancel();
+ let headerBytes = 0;
+ for (const [name, value] of response.headers) {
+ headerBytes += Buffer.byteLength(name) + Buffer.byteLength(value);
+ if (headerBytes > 16_384) throw new Error("Response headers are too large");
+ }
+ return { status: response.status, headers: response.headers };
+ } catch {
+ // Fetch errors may retain the password body or session cookies in their cause.
+ throw new Error("Stats authentication request failed");
+ }
+ };
+ }
+
private createRequestAsync(
signal: AbortSignal,
testing?: IntegrationTestingInput,
diff --git a/packages/integrations/src/stats/types.ts b/packages/integrations/src/stats/types.ts
index 60f045ef4..c72606034 100644
--- a/packages/integrations/src/stats/types.ts
+++ b/packages/integrations/src/stats/types.ts
@@ -1,4 +1,4 @@
-import type { RequestInit } from "undici";
+import type { Headers, RequestInit } from "undici";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { IntegrationHttpAuthentication } from "../http-auth";
@@ -26,8 +26,12 @@ export interface StatsAuthenticationContext {
}
export interface StatsFetchContext extends StatsAuthenticationContext {
+ /** Base integration URL. Credential exchanges must verify its protocol before reading or sending secrets. */
+ url: URL;
/** Relative service path. The shared client applies trusted certificates and a deadline. */
requestAsync: (path: `/${string}`, init?: RequestInit) => Promise;
+ /** Private response metadata for cookie-based exchanges; the body is discarded, never parsed or exposed. */
+ requestResponseAsync: (path: `/${string}`, init?: RequestInit) => Promise<{ status: number; headers: Headers }>;
signal: AbortSignal;
}