Security fixes (#8081)

* use safeloader

* use json responses wherever possible

* remove CORS and add CSRF token

* formatting fixes

* add envjs back

* fix baseurl test
This commit is contained in:
Blake Blackshear
2023-10-06 22:20:30 -05:00
committed by GitHub
parent 9a4f970337
commit 14d2b79c72
24 changed files with 1357 additions and 488 deletions

View File

@@ -20,7 +20,6 @@
},
"ignorePatterns": ["*.d.ts"],
"rules": {
"indent": ["error", 2, { "SwitchCase": 1 }],
"comma-dangle": [
"error",
{

View File

@@ -1,4 +1,5 @@
{
"printWidth": 120,
"trailingComma": "es5",
"singleQuote": true
}

View File

@@ -1,8 +1,8 @@
import { rest } from 'msw';
import { API_HOST } from '../src/env';
// import { API_HOST } from '../src/env';
export const handlers = [
rest.get(`${API_HOST}api/config`, (req, res, ctx) => {
rest.get(`api/config`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
@@ -37,7 +37,7 @@ export const handlers = [
})
);
}),
rest.get(`${API_HOST}api/stats`, (req, res, ctx) => {
rest.get(`api/stats`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
@@ -58,7 +58,7 @@ export const handlers = [
})
);
}),
rest.get(`${API_HOST}api/events`, (req, res, ctx) => {
rest.get(`api/events`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json(
@@ -77,7 +77,7 @@ export const handlers = [
)
);
}),
rest.get(`${API_HOST}api/sub_labels`, (req, res, ctx) => {
rest.get(`api/sub_labels`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([

1290
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,7 @@
"eslint-config-preact": "^1.3.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jest": "^27.2.3",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vitest-globals": "^1.4.0",
"fake-indexeddb": "^4.0.1",
"jsdom": "^22.0.0",

View File

@@ -1,2 +1 @@
export const ENV = 'test';
export const API_HOST = 'http://base-url.local:5000/';

View File

@@ -18,6 +18,6 @@ describe('useApiHost', () => {
<Test />
</ApiProvider>
);
expect(screen.queryByText('http://base-url.local:5000/')).toBeInTheDocument();
expect(screen.queryByText('http://localhost:3000/')).toBeInTheDocument();
});
});

View File

@@ -1,2 +1 @@
import { API_HOST } from '../env';
export const baseUrl = API_HOST || `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;
export const baseUrl = `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;

View File

@@ -5,6 +5,9 @@ import { WsProvider } from './ws';
import axios from 'axios';
axios.defaults.baseURL = `${baseUrl}api/`;
axios.defaults.headers.common = {
'X-CSRF-TOKEN': 1,
};
export function ApiProvider({ children, options }) {
return (

View File

@@ -58,7 +58,7 @@ export default function CameraImage({ camera, onload, searchParams = '', stretch
if (!config || scaledHeight === 0 || !canvasRef.current) {
return;
}
img.src = `${apiHost}/api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
img.src = `${apiHost}api/${name}/latest.jpg?h=${scaledHeight}${searchParams ? `&${searchParams}` : ''}`;
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
return (

View File

@@ -56,10 +56,10 @@ export const HistoryVideo = ({
}
video.src({
src: `${apiHost}/vod/event/${id}/master.m3u8`,
src: `${apiHost}vod/event/${id}/master.m3u8`,
type: 'application/vnd.apple.mpegurl',
});
video.poster(`${apiHost}/api/events/${id}/snapshot.jpg`);
video.poster(`${apiHost}api/events/${id}/snapshot.jpg`);
if (videoIsPlaying) {
video.play();
}

View File

@@ -61,8 +61,7 @@ export default function MultiSelect({ className, title, options, selection, onTo
className="max-h-[35px] mx-2"
onClick={() => onSelectSingle(item)}
>
{ (title === "Labels" && config.audio.listen.includes(item)) ? ( <SpeakerIcon /> ) : ( <CameraIcon /> ) }
{title === 'Labels' && config.audio.listen.includes(item) ? <SpeakerIcon /> : <CameraIcon />}
</Button>
</div>
</div>

View File

@@ -153,7 +153,7 @@ export function EventCard({ camera, event }) {
<Link className="" href={`/recording/${camera}/${format(start, 'yyyy-MM-dd/HH/mm/ss')}`}>
<div className="flex flex-row mb-2">
<div className="w-28 mr-4">
<img className="antialiased" loading="lazy" src={`${apiHost}/api/events/${event.id}/thumbnail.jpg`} />
<img className="antialiased" loading="lazy" src={`${apiHost}api/events/${event.id}/thumbnail.jpg`} />
</div>
<div className="flex flex-row w-full border-b">
<div className="w-full text-gray-700 font-semibold relative pt-0">

View File

@@ -1,2 +1 @@
export const ENV = import.meta.env.MODE;
export const API_HOST = ENV === 'production' ? '' : 'http://localhost:5000/';
export const ENV = import.meta.env.MODE;

View File

@@ -212,7 +212,7 @@ export default function Camera({ camera }) {
key={objectType}
header={objectType}
href={`/events?cameras=${camera}&labels=${encodeURIComponent(objectType)}`}
media={<img src={`${apiHost}/api/${camera}/${encodeURIComponent(objectType)}/thumbnail.jpg`} />}
media={<img src={`${apiHost}api/${camera}/${encodeURIComponent(objectType)}/thumbnail.jpg`} />}
/>
))}
</div>

View File

@@ -30,8 +30,8 @@ export default function CameraMasks({ camera }) {
Array.isArray(motionMask)
? motionMask.map((mask) => getPolylinePoints(mask))
: motionMask
? [getPolylinePoints(motionMask)]
: []
? [getPolylinePoints(motionMask)]
: []
);
const [zonePoints, setZonePoints] = useState(
@@ -45,8 +45,8 @@ export default function CameraMasks({ camera }) {
[name]: Array.isArray(objectFilters[name].mask)
? objectFilters[name].mask.map((mask) => getPolylinePoints(mask))
: objectFilters[name].mask
? [getPolylinePoints(objectFilters[name].mask)]
: [],
? [getPolylinePoints(objectFilters[name].mask)]
: [],
}),
{}
)
@@ -146,7 +146,6 @@ export default function CameraMasks({ camera }) {
}
}, [camera, motionMaskPoints]);
// Zone methods
const handleEditZone = useCallback(
(key) => {
@@ -175,9 +174,11 @@ export default function CameraMasks({ camera }) {
const handleCopyZones = useCallback(async () => {
const textToCopy = ` zones:
${Object.keys(zonePoints)
.map(
(zoneName) => ` ${zoneName}:
coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`).join('\n')}`;
.map(
(zoneName) => ` ${zoneName}:
coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`
)
.join('\n')}`;
if (window.navigator.clipboard && window.navigator.clipboard.writeText) {
// Use Clipboard API if available
@@ -207,7 +208,10 @@ ${Object.keys(zonePoints)
const handleSaveZones = useCallback(async () => {
try {
const queryParameters = Object.keys(zonePoints)
.map((zoneName) => `cameras.${camera}.zones.${zoneName}.coordinates=${polylinePointsToPolyline(zonePoints[zoneName])}`)
.map(
(zoneName) =>
`cameras.${camera}.zones.${zoneName}.coordinates=${polylinePointsToPolyline(zonePoints[zoneName])}`
)
.join('&');
const endpoint = `config/set?${queryParameters}`;
const response = await axios.put(endpoint);
@@ -252,21 +256,26 @@ ${Object.keys(zonePoints)
await window.navigator.clipboard.writeText(` objects:
filters:
${Object.keys(objectMaskPoints)
.map((objectName) =>
objectMaskPoints[objectName].length
? ` ${objectName}:
.map((objectName) =>
objectMaskPoints[objectName].length
? ` ${objectName}:
mask: ${polylinePointsToPolyline(objectMaskPoints[objectName])}`
: ''
)
.filter(Boolean)
.join('\n')}`);
: ''
)
.filter(Boolean)
.join('\n')}`);
}, [objectMaskPoints]);
const handleSaveObjectMasks = useCallback(async () => {
try {
const queryParameters = Object.keys(objectMaskPoints)
.filter((objectName) => objectMaskPoints[objectName].length > 0)
.map((objectName, index) => `cameras.${camera}.objects.filters.${objectName}.mask.${index}=${polylinePointsToPolyline(objectMaskPoints[objectName])}`)
.map(
(objectName, index) =>
`cameras.${camera}.objects.filters.${objectName}.mask.${index}=${polylinePointsToPolyline(
objectMaskPoints[objectName]
)}`
)
.join('&');
const endpoint = `config/set?${queryParameters}`;
const response = await axios.put(endpoint);
@@ -324,8 +333,8 @@ ${Object.keys(objectMaskPoints)
<Card
content={
<p>
When done, copy each mask configuration into your <code className="font-mono">config.yml</code> file
restart your Frigate instance to save your changes.
When done, copy each mask configuration into your <code className="font-mono">config.yml</code> file restart
your Frigate instance to save your changes.
</p>
}
header="Warning"
@@ -336,7 +345,7 @@ ${Object.keys(objectMaskPoints)
<div className="space-y-4">
<div className="relative">
<img ref={imageRef} src={`${apiHost}/api/${camera}/latest.jpg`} />
<img ref={imageRef} src={`${apiHost}api/${camera}/latest.jpg`} />
<EditableMask
onChange={handleUpdateEditable}
points={'subkey' in editing ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]}
@@ -487,16 +496,16 @@ function EditableMask({ onChange, points, scale, snap, width, height }) {
{!scaledPoints
? null
: scaledPoints.map(([x, y], i) => (
<PolyPoint
key={i}
boundingRef={boundingRef}
index={i}
onMove={handleMovePoint}
onRemove={handleRemovePoint}
x={x + MaskInset}
y={y + MaskInset}
/>
))}
<PolyPoint
key={i}
boundingRef={boundingRef}
index={i}
onMove={handleMovePoint}
onRemove={handleRemovePoint}
x={x + MaskInset}
y={y + MaskInset}
/>
))}
<div className="absolute inset-0 right-0 bottom-0" onClick={handleAddPoint} ref={boundingRef} />
<svg
width="100%"
@@ -569,8 +578,6 @@ function MaskValues({
[onAdd]
);
return (
<div className="overflow-hidden" onMouseOver={handleMousein} onMouseOut={handleMouseout}>
<div className="flex space-x-4">

View File

@@ -34,7 +34,7 @@ export default function Config() {
})
.catch((error) => {
setSuccess('');
if (error.response) {
setError(error.response.data.message);
} else {
@@ -61,9 +61,9 @@ export default function Config() {
let yamlModel;
if (editor.getModels().length > 0) {
yamlModel = editor.getModel(modelUri)
yamlModel = editor.getModel(modelUri);
} else {
yamlModel = editor.createModel(config, 'yaml', modelUri)
yamlModel = editor.createModel(config, 'yaml', modelUri);
}
setDiagnosticsOptions({
@@ -74,7 +74,7 @@ export default function Config() {
format: true,
schemas: [
{
uri: `${apiHost}/api/config/schema.json`,
uri: `${apiHost}api/config/schema.json`,
fileMatch: [String(modelUri)],
},
],
@@ -100,10 +100,10 @@ export default function Config() {
<Button className="mx-2" onClick={(e) => handleCopyConfig(e)}>
Copy Config
</Button>
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, "restart")}>
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, 'restart')}>
Save & Restart
</Button>
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, "saveonly")}>
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, 'saveonly')}>
Save Only
</Button>
</div>

View File

@@ -402,7 +402,7 @@ export default function Events({ path, ...props }) {
icon={Snapshot}
label="Download Snapshot"
value="snapshot"
href={`${apiHost}/api/events/${downloadEvent.id}/snapshot.jpg?download=true`}
href={`${apiHost}api/events/${downloadEvent.id}/snapshot.jpg?download=true`}
download
/>
)}
@@ -411,7 +411,7 @@ export default function Events({ path, ...props }) {
icon={Clip}
label="Download Clip"
value="clip"
href={`${apiHost}/api/events/${downloadEvent.id}/clip.mp4?download=true`}
href={`${apiHost}api/events/${downloadEvent.id}/clip.mp4?download=true`}
download
/>
)}
@@ -419,13 +419,13 @@ export default function Events({ path, ...props }) {
downloadEvent.end_time &&
downloadEvent.has_snapshot &&
!downloadEvent.plus_id && (
<MenuItem
icon={UploadPlus}
label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'}
value="plus"
onSelect={() => showSubmitToPlus(downloadEvent.id, downloadEvent.label, downloadEvent.box)}
/>
)}
<MenuItem
icon={UploadPlus}
label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'}
value="plus"
onSelect={() => showSubmitToPlus(downloadEvent.id, downloadEvent.label, downloadEvent.box)}
/>
)}
{downloadEvent.plus_id && (
<MenuItem
icon={UploadPlus}
@@ -492,7 +492,7 @@ export default function Events({ path, ...props }) {
<img
className="flex-grow-0"
src={`${apiHost}/api/events/${plusSubmitEvent.id}/snapshot.jpg`}
src={`${apiHost}api/events/${plusSubmitEvent.id}/snapshot.jpg`}
alt={`${plusSubmitEvent.label}`}
/>
@@ -619,7 +619,7 @@ export default function Events({ path, ...props }) {
<div
className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
style={{
'background-image': `url(${apiHost}/api/events/${event.id}/thumbnail.jpg)`,
'background-image': `url(${apiHost}api/events/${event.id}/thumbnail.jpg)`,
}}
>
<StarRecording
@@ -776,8 +776,8 @@ export default function Events({ path, ...props }) {
className="flex-grow-0"
src={
event.has_snapshot
? `${apiHost}/api/events/${event.id}/snapshot.jpg`
: `${apiHost}/api/events/${event.id}/thumbnail.jpg`
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.jpg`
}
alt={`${event.label} at ${((event?.data?.top_score || event.top_score) * 100).toFixed(
0

View File

@@ -334,80 +334,86 @@ export default function System() {
<ActivityIndicator />
) : (
<div data-testid="cameras" className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
{cameraNames.map((camera) => ( config.cameras[camera]["enabled"] && (
<div key={camera} className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow">
<div className="capitalize text-lg flex justify-between p-4">
<Link href={`/cameras/${camera}`}>{camera.replaceAll('_', ' ')}</Link>
<Button onClick={(e) => onHandleFfprobe(camera, e)}>ffprobe</Button>
</div>
<div className="p-2">
<Table className="w-full">
<Thead>
<Tr>
<Th>Process</Th>
<Th>P-ID</Th>
<Th>FPS</Th>
<Th>CPU %</Th>
<Th>Memory %</Th>
{config.telemetry.network_bandwidth && <Th>Network Bandwidth</Th>}
</Tr>
</Thead>
<Tbody>
<Tr key="ffmpeg" index="0">
<Td>
ffmpeg
<Button
className="rounded-full"
type="text"
color="gray"
aria-label={cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cmdline']}
onClick={() => copy(cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cmdline'])}
>
<About className="w-3" />
</Button>
</Td>
<Td>{cameras[camera]['ffmpeg_pid'] || '- '}</Td>
<Td>{cameras[camera]['camera_fps'] || '- '}</Td>
<Td>{cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['ffmpeg_pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && (
<Td>{bandwidth_usages[cameras[camera]['ffmpeg_pid']]?.['bandwidth'] || '- '}KB/s</Td>
)}
</Tr>
<Tr key="capture" index="1">
<Td>Capture</Td>
<Td>{cameras[camera]['capture_pid'] || '- '}</Td>
<Td>{cameras[camera]['process_fps'] || '- '}</Td>
<Td>{cpu_usages[cameras[camera]['capture_pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['capture_pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && <Td>-</Td>}
</Tr>
<Tr key="detect" index="2">
<Td>Detect</Td>
<Td>{cameras[camera]['pid'] || '- '}</Td>
{cameraNames.map(
(camera) =>
config.cameras[camera]['enabled'] && (
<div
key={camera}
className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow"
>
<div className="capitalize text-lg flex justify-between p-4">
<Link href={`/cameras/${camera}`}>{camera.replaceAll('_', ' ')}</Link>
<Button onClick={(e) => onHandleFfprobe(camera, e)}>ffprobe</Button>
</div>
<div className="p-2">
<Table className="w-full">
<Thead>
<Tr>
<Th>Process</Th>
<Th>P-ID</Th>
<Th>FPS</Th>
<Th>CPU %</Th>
<Th>Memory %</Th>
{config.telemetry.network_bandwidth && <Th>Network Bandwidth</Th>}
</Tr>
</Thead>
<Tbody>
<Tr key="ffmpeg" index="0">
<Td>
ffmpeg
<Button
className="rounded-full"
type="text"
color="gray"
aria-label={cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cmdline']}
onClick={() => copy(cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cmdline'])}
>
<About className="w-3" />
</Button>
</Td>
<Td>{cameras[camera]['ffmpeg_pid'] || '- '}</Td>
<Td>{cameras[camera]['camera_fps'] || '- '}</Td>
<Td>{cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['ffmpeg_pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && (
<Td>{bandwidth_usages[cameras[camera]['ffmpeg_pid']]?.['bandwidth'] || '- '}KB/s</Td>
)}
</Tr>
<Tr key="capture" index="1">
<Td>Capture</Td>
<Td>{cameras[camera]['capture_pid'] || '- '}</Td>
<Td>{cameras[camera]['process_fps'] || '- '}</Td>
<Td>{cpu_usages[cameras[camera]['capture_pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['capture_pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && <Td>-</Td>}
</Tr>
<Tr key="detect" index="2">
<Td>Detect</Td>
<Td>{cameras[camera]['pid'] || '- '}</Td>
{(() => {
if (cameras[camera]['pid'] && cameras[camera]['detection_enabled'] == 1)
return (
<Td>
{cameras[camera]['detection_fps']} ({cameras[camera]['skipped_fps']} skipped)
</Td>
);
else if (cameras[camera]['pid'] && cameras[camera]['detection_enabled'] == 0)
return <Td>disabled</Td>;
{(() => {
if (cameras[camera]['pid'] && cameras[camera]['detection_enabled'] == 1)
return (
<Td>
{cameras[camera]['detection_fps']} ({cameras[camera]['skipped_fps']} skipped)
</Td>
);
else if (cameras[camera]['pid'] && cameras[camera]['detection_enabled'] == 0)
return <Td>disabled</Td>;
return <Td>- </Td>;
})()}
return <Td>- </Td>;
})()}
<Td>{cpu_usages[cameras[camera]['pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && <Td>-</Td>}
</Tr>
</Tbody>
</Table>
</div>
</div> )
))}
<Td>{cpu_usages[cameras[camera]['pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && <Td>-</Td>}
</Tr>
</Tbody>
</Table>
</div>
</div>
)
)}
</div>
)}

View File

@@ -9,6 +9,13 @@ export default defineConfig({
define: {
'import.meta.vitest': 'undefined',
},
server: {
proxy: {
'/api': {
target: 'http://localhost:5000'
}
}
},
plugins: [
preact(),
monacoEditorPlugin.default({