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