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; }