mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-20 13:42:47 +03:00
Merge dev into ws-origin-roles
# Conflicts: # docker/main/rootfs/usr/local/nginx/conf/auth_location.conf
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -218,6 +218,50 @@ test.describe("Chat — streaming @medium", () => {
|
||||
frigateApp.page.getByText(/searching for people/i),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("assistant markdown renders only same-origin images", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const reply =
|
||||
" " +
|
||||
" " +
|
||||
"[offsite](https://evil.example/page)";
|
||||
await installChatStreamOverride(frigateApp, [
|
||||
{ type: "content", delta: reply },
|
||||
{
|
||||
type: "messages",
|
||||
messages: [
|
||||
{ role: "system", content: "sys" },
|
||||
{ role: "user", content: "show me" },
|
||||
{ role: "assistant", content: reply },
|
||||
],
|
||||
},
|
||||
{ type: "done" },
|
||||
]);
|
||||
const offsiteRequests: string[] = [];
|
||||
frigateApp.page.on("request", (req) => {
|
||||
if (req.url().includes("evil.example")) offsiteRequests.push(req.url());
|
||||
});
|
||||
await frigateApp.goto("/chat");
|
||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill("show me");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(frigateApp.page.getByText("leaked alt")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
frigateApp.page.locator('img[src*="evil.example"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
frigateApp.page.locator('img[src="/api/events/abc/thumbnail.webp"]'),
|
||||
).toHaveCount(1);
|
||||
const link = frigateApp.page.getByRole("link", { name: "offsite" });
|
||||
await expect(link).toHaveAttribute("target", "_blank");
|
||||
await expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(offsiteRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Chat — stop @medium", () => {
|
||||
|
||||
@@ -32,7 +32,11 @@ type CameraInput = {
|
||||
input_args?: string;
|
||||
};
|
||||
|
||||
async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) {
|
||||
async function installRoutes(
|
||||
page: Page,
|
||||
frontDoorInputs: CameraInput[],
|
||||
rawInputs: CameraInput[] = frontDoorInputs,
|
||||
) {
|
||||
const config = configFactory({
|
||||
go2rtc: { streams: GO2RTC_STREAMS },
|
||||
cameras: {
|
||||
@@ -56,7 +60,7 @@ async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) {
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs } } },
|
||||
cameras: { front_door: { ffmpeg: { inputs: rawInputs } } },
|
||||
go2rtc: { streams: GO2RTC_STREAMS },
|
||||
},
|
||||
}),
|
||||
@@ -200,4 +204,64 @@ test.describe("camera ffmpeg input source toggle @medium", () => {
|
||||
payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0];
|
||||
expect(input?.input_args).not.toBe("preset-rtsp-restream");
|
||||
});
|
||||
|
||||
test("a masked path reveals its own saved path after an earlier input is deleted", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const main = "rtsp://admin:mainpw@10.0.0.1:554/main";
|
||||
const sub = "rtsp://admin:subpw@10.0.0.1:554/sub";
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
[
|
||||
{ path: "rtsp://*:*@10.0.0.1:554/main", roles: ["record"] },
|
||||
{ path: "rtsp://*:*@10.0.0.1:554/sub", roles: ["detect"] },
|
||||
],
|
||||
[
|
||||
{ path: main, roles: ["record"] },
|
||||
{ path: sub, roles: ["detect"] },
|
||||
],
|
||||
);
|
||||
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
|
||||
|
||||
const paths = frigateApp.page.getByRole("textbox", { name: "Input path" });
|
||||
await expect(paths).toHaveCount(2);
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.first()
|
||||
.click();
|
||||
await expect(paths).toHaveCount(1);
|
||||
|
||||
await paths.focus();
|
||||
await expect(paths).toHaveValue(sub);
|
||||
});
|
||||
|
||||
test("inputs with the same masked path each reveal their own path by role", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const record = "rtsp://admin:one@10.0.0.1:554/live";
|
||||
const detect = "rtsp://viewer:two@10.0.0.1:554/live";
|
||||
const masked = "rtsp://*:*@10.0.0.1:554/live";
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
[
|
||||
{ path: masked, roles: ["record"] },
|
||||
{ path: masked, roles: ["detect"] },
|
||||
],
|
||||
[
|
||||
{ path: record, roles: ["record"] },
|
||||
{ path: detect, roles: ["detect"] },
|
||||
],
|
||||
);
|
||||
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
|
||||
|
||||
const paths = frigateApp.page.getByRole("textbox", { name: "Input path" });
|
||||
await expect(paths).toHaveCount(2);
|
||||
|
||||
await paths.nth(0).focus();
|
||||
await expect(paths.nth(0)).toHaveValue(record);
|
||||
|
||||
await paths.nth(1).focus();
|
||||
await expect(paths.nth(1)).toHaveValue(detect);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+304
-425
File diff suppressed because it is too large
Load Diff
+22
-23
@@ -23,28 +23,28 @@
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.24",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.16",
|
||||
"@radix-ui/react-checkbox": "^1.3.12",
|
||||
"@radix-ui/react-collapsible": "^1.1.21",
|
||||
"@radix-ui/react-context-menu": "^2.3.8",
|
||||
"@radix-ui/react-alert-dialog": "^1.2.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
||||
"@radix-ui/react-checkbox": "^1.4.1",
|
||||
"@radix-ui/react-collapsible": "^1.2.1",
|
||||
"@radix-ui/react-context-menu": "^2.4.1",
|
||||
"@radix-ui/react-dialog": "^1.2.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.25",
|
||||
"@radix-ui/react-hover-card": "^1.1.24",
|
||||
"@radix-ui/react-label": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.2.0",
|
||||
"@radix-ui/react-progress": "^1.1.17",
|
||||
"@radix-ui/react-radio-group": "^1.4.8",
|
||||
"@radix-ui/react-scroll-area": "^1.3.0",
|
||||
"@radix-ui/react-select": "^2.3.8",
|
||||
"@radix-ui/react-separator": "^1.1.16",
|
||||
"@radix-ui/react-slider": "^1.5.0",
|
||||
"@radix-ui/react-slot": "1.4.0",
|
||||
"@radix-ui/react-switch": "^1.3.8",
|
||||
"@radix-ui/react-tabs": "^1.1.22",
|
||||
"@radix-ui/react-toggle": "^1.1.19",
|
||||
"@radix-ui/react-toggle-group": "^1.1.20",
|
||||
"@radix-ui/react-tooltip": "^1.3.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.2.1",
|
||||
"@radix-ui/react-hover-card": "^1.2.1",
|
||||
"@radix-ui/react-label": "^2.1.17",
|
||||
"@radix-ui/react-popover": "^1.3.1",
|
||||
"@radix-ui/react-progress": "^1.2.1",
|
||||
"@radix-ui/react-radio-group": "^1.5.1",
|
||||
"@radix-ui/react-scroll-area": "^1.4.0",
|
||||
"@radix-ui/react-select": "^2.4.1",
|
||||
"@radix-ui/react-separator": "^1.2.0",
|
||||
"@radix-ui/react-slider": "^1.6.1",
|
||||
"@radix-ui/react-slot": "1.5.0",
|
||||
"@radix-ui/react-switch": "^1.4.1",
|
||||
"@radix-ui/react-tabs": "^1.2.1",
|
||||
"@radix-ui/react-toggle": "^1.2.1",
|
||||
"@radix-ui/react-toggle-group": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.4.1",
|
||||
"@rjsf/core": "^6.10.1",
|
||||
"@rjsf/shadcn": "^6.10.1",
|
||||
"@rjsf/utils": "^6.10.1",
|
||||
@@ -127,7 +127,6 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-slot": "1.4.0"
|
||||
"@radix-ui/react-slot": "1.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
},
|
||||
"cookie_secure": {
|
||||
"label": "Secure cookie flag",
|
||||
"description": "Set the secure flag on the auth cookie; should be true when using TLS."
|
||||
"description": "Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS."
|
||||
},
|
||||
"session_length": {
|
||||
"label": "Session length",
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
"noROI": "Please draw a region of interest",
|
||||
"noTimeRange": "Please select a time range",
|
||||
"invalidTimeRange": "End time must be after start time",
|
||||
"alreadyRunning": "You already have a motion search running. Wait for it to finish or cancel it.",
|
||||
"tooManySearches": "Too many motion searches are running. Try again when one finishes.",
|
||||
"searchFailed": "Search failed: {{message}}",
|
||||
"polygonTooSmall": "Polygon must have at least 3 points",
|
||||
"unknown": "Unknown error"
|
||||
|
||||
@@ -18,6 +18,18 @@ import { cn } from "@/lib/utils";
|
||||
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
|
||||
import { parseAttachedEvent } from "@/utils/chatUtil";
|
||||
import type { ChatStats, ShowStatsMode } from "@/types/chat";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
|
||||
// Assistant output can carry prompt-injected URLs, and the browser fetches
|
||||
// images without a click, so only same-origin images render.
|
||||
function isSameOrigin(url: string | undefined): boolean {
|
||||
if (!url) return false;
|
||||
try {
|
||||
return new URL(url, baseUrl).origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type MessageBubbleProps = {
|
||||
role: "user" | "assistant";
|
||||
@@ -223,6 +235,23 @@ export function MessageBubble({
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
img: ({ node: _n, src, alt, ...props }) =>
|
||||
typeof src === "string" && isSameOrigin(src) ? (
|
||||
<img src={src} alt={alt} {...props} />
|
||||
) : (
|
||||
<>{alt}</>
|
||||
),
|
||||
a: ({ node: _n, href, ...props }) =>
|
||||
isSameOrigin(href) ? (
|
||||
<a href={href} {...props} />
|
||||
) : (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import useSWR from "swr";
|
||||
import get from "lodash/get";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { useMemo, useState, type FocusEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
@@ -21,6 +23,7 @@ type RawPathsResponse = {
|
||||
ffmpeg?: {
|
||||
inputs?: Array<{
|
||||
path?: string;
|
||||
roles?: string[];
|
||||
}>;
|
||||
};
|
||||
}
|
||||
@@ -66,17 +69,35 @@ export function CameraPathWidget(props: WidgetProps) {
|
||||
shouldFetchRawPaths ? "config/raw_paths" : null,
|
||||
);
|
||||
|
||||
const rawValue = typeof value === "string" ? value : "";
|
||||
|
||||
const roles =
|
||||
inputIndex === undefined
|
||||
? undefined
|
||||
: get(formContext?.formData, ["inputs", inputIndex, "roles"]);
|
||||
|
||||
// Match by masked value with roles breaking a tie, as the backend restores
|
||||
// on save, so deleting or reordering inputs never reveals another input's path
|
||||
const rawPath = useMemo(() => {
|
||||
if (!cameraName || inputIndex === undefined) {
|
||||
if (!cameraName || !isMaskedPath(rawValue)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const path =
|
||||
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path;
|
||||
return typeof path === "string" ? path : undefined;
|
||||
}, [cameraName, inputIndex, rawPaths]);
|
||||
let matches = (
|
||||
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? []
|
||||
).filter(
|
||||
(input): input is { path: string; roles?: string[] } =>
|
||||
typeof input.path === "string" &&
|
||||
maskCredentials(input.path) === rawValue,
|
||||
);
|
||||
if (new Set(matches.map((input) => input.path)).size > 1) {
|
||||
matches = matches.filter((input) => isEqual(input.roles, roles));
|
||||
}
|
||||
|
||||
const paths = new Set(matches.map((input) => input.path));
|
||||
return paths.size === 1 ? [...paths][0] : undefined;
|
||||
}, [cameraName, rawPaths, rawValue, roles]);
|
||||
|
||||
const rawValue = typeof value === "string" ? value : "";
|
||||
const resolvedValue =
|
||||
isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");
|
||||
const canReveal =
|
||||
|
||||
@@ -210,7 +210,7 @@ export function VideoPreview({
|
||||
}, [playerRef]);
|
||||
|
||||
const onProgressHover = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!sliderRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -401,7 +401,7 @@ export function InProgressPreview({
|
||||
);
|
||||
|
||||
const onProgressHover = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!sliderRef.current || !previewFrames) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,37 @@
|
||||
const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i;
|
||||
const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i;
|
||||
// Mirrors clean_camera_user_pass and REGEX_MASKED_CREDENTIAL in the backend.
|
||||
const CREDENTIAL_QUERY_KEYS =
|
||||
"(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)";
|
||||
const URL_USERINFO_PATTERN =
|
||||
/:\/\/((?:[^\s/?#:[\]]*:(?:(?!:\/\/)\S)*|[^\s/?#]+))@((?:(?!:\/\/)\S)*)/g;
|
||||
const CREDENTIAL_QUERY_KEY_PATTERN = new RegExp(
|
||||
`[?&]${CREDENTIAL_QUERY_KEYS}=`,
|
||||
"i",
|
||||
);
|
||||
const CREDENTIAL_QUERY_PATTERN = new RegExp(
|
||||
`([?&]${CREDENTIAL_QUERY_KEYS}=)[^&\\s]*`,
|
||||
"gi",
|
||||
);
|
||||
const MASKED_PATTERN = new RegExp(
|
||||
`://\\*:\\*@|[?&]${CREDENTIAL_QUERY_KEYS}=\\*(?![^&\\s])`,
|
||||
"i",
|
||||
);
|
||||
|
||||
export const isMaskedPath = (value: string): boolean =>
|
||||
MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value);
|
||||
|
||||
export const hasCredentials = (value: string): boolean => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isMaskedPath(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.username || parsed.password) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
parsed.searchParams.has("user") && parsed.searchParams.has("password")
|
||||
);
|
||||
} catch {
|
||||
return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value);
|
||||
}
|
||||
};
|
||||
MASKED_PATTERN.test(value);
|
||||
|
||||
export const maskCredentials = (value: string): string => {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@");
|
||||
|
||||
return maskedAuth
|
||||
.replace(/([?&]user=)[^&]*/gi, "$1*")
|
||||
.replace(/([?&]password=)[^&]*/gi, "$1*");
|
||||
return value
|
||||
.replace(URL_USERINFO_PATTERN, (_match, userinfo: string, rest: string) =>
|
||||
CREDENTIAL_QUERY_KEY_PATTERN.test(userinfo)
|
||||
? "://*:*@"
|
||||
: `://*:*@${rest}`,
|
||||
)
|
||||
.replace(CREDENTIAL_QUERY_PATTERN, "$1*");
|
||||
};
|
||||
|
||||
export const hasCredentials = (value: string): boolean =>
|
||||
!!value && (isMaskedPath(value) || maskCredentials(value) !== value);
|
||||
|
||||
@@ -892,6 +892,20 @@ export default function MotionSearchView({
|
||||
setIsSearching(false);
|
||||
}
|
||||
} catch (error) {
|
||||
const status = axios.isAxiosError(error)
|
||||
? error.response?.status
|
||||
: undefined;
|
||||
|
||||
if (status === 409 || status === 429) {
|
||||
toast.error(
|
||||
t(
|
||||
status === 409 ? "errors.alreadyRunning" : "errors.tooManySearches",
|
||||
),
|
||||
);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let errorMessage = t("errors.unknown");
|
||||
|
||||
if (axios.isAxiosError<{ message?: string; detail?: string }>(error)) {
|
||||
|
||||
Reference in New Issue
Block a user