fix(integrations): authenticate Frigate stats and HTTP requests (#7019)

* fix(integrations): authenticate Frigate stats and HTTP requests [skip ci]

* fix(integrations): reject HTTP Frigate URLs before authentication
This commit is contained in:
Thomas authored and GitHub committed 2026-10-03 23:33:23 +02:00
1 parent 5a2f6cfa1b
commit f50d4ed134
6 files changed
+288 -8

No files matched your search

@@ -22,4 +22,6 @@ The Frigate statistics use `/api/stats` and report camera count, service uptime,
<AddingIntegration />
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.
+2 -2
View File
@@ -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" },
@@ -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<typeof fetchWithTrustedCertificatesAsync>()
.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);
});
});
@@ -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");
}
}
@@ -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,
+5 -1
View File
@@ -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<unknown>;
/** 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;
}