Support special characters in passwords, redacted logs & debug config (#4057)

* Consts for regex

* Add regex for camera username and password

* Redact user:pass from ffmpeg logs

* Redact ffmpeg commands

* Move common function to util

* Add tests

* Formatting

* Remove unused imports

* Fix test

* Add port to test

* Support special characters in passwords

* Add tests for special character handling

* Remove docs about not supporting special characters
This commit is contained in:
Nicolas Mowen 2022-11-02 06:00:54 -06:00 committed by GitHub
parent 11624d4759
commit 1bc9efd529
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 83 additions and 20 deletions

View File

@ -45,12 +45,6 @@ More details on available detectors can be found [here](/configuration/detectors
Now let's add the first camera: Now let's add the first camera:
:::caution
Note that passwords that contain special characters often cause issues with ffmpeg connecting to the camera. If receiving `end-of-file` or `unauthorized` errors with a verified correct password, try changing the password to something simple to rule out the possibility that the password is the issue.
:::
```yaml ```yaml
mqtt: mqtt:
host: <ip of your mqtt server> host: <ip of your mqtt server>

View File

@ -12,8 +12,18 @@ import yaml
from pydantic import BaseModel, Extra, Field, validator from pydantic import BaseModel, Extra, Field, validator
from pydantic.fields import PrivateAttr from pydantic.fields import PrivateAttr
from frigate.const import BASE_DIR, CACHE_DIR, YAML_EXT from frigate.const import (
from frigate.util import create_mask, deep_merge, load_labels BASE_DIR,
CACHE_DIR,
REGEX_CAMERA_NAME,
YAML_EXT,
)
from frigate.util import (
create_mask,
deep_merge,
escape_special_characters,
load_labels,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -540,7 +550,7 @@ class CameraUiConfig(FrigateBaseModel):
class CameraConfig(FrigateBaseModel): class CameraConfig(FrigateBaseModel):
name: Optional[str] = Field(title="Camera name.", regex="^[a-zA-Z0-9_-]+$") name: Optional[str] = Field(title="Camera name.", regex=REGEX_CAMERA_NAME)
enabled: bool = Field(default=True, title="Enable camera.") enabled: bool = Field(default=True, title="Enable camera.")
ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.") ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.")
best_image_timeout: int = Field( best_image_timeout: int = Field(
@ -695,7 +705,7 @@ class CameraConfig(FrigateBaseModel):
+ global_args + global_args
+ hwaccel_args + hwaccel_args
+ input_args + input_args
+ ["-i", ffmpeg_input.path] + ["-i", escape_special_characters(ffmpeg_input.path)]
+ ffmpeg_output_args + ffmpeg_output_args
) )

View File

@ -5,3 +5,8 @@ CACHE_DIR = "/tmp/cache"
YAML_EXT = (".yaml", ".yml") YAML_EXT = (".yaml", ".yml")
PLUS_ENV_VAR = "PLUS_API_KEY" PLUS_ENV_VAR = "PLUS_API_KEY"
PLUS_API_HOST = "https://api.frigate.video" PLUS_API_HOST = "https://api.frigate.video"
# Regex Consts
REGEX_CAMERA_NAME = "^[a-zA-Z0-9_-]+$"
REGEX_CAMERA_USER_PASS = "[a-zA-Z0-9_-]+:[a-zA-Z0-9!*'();:@&=+$,?%#_-]+@"

View File

@ -30,6 +30,7 @@ from frigate.const import CLIPS_DIR
from frigate.models import Event, Recordings from frigate.models import Event, Recordings
from frigate.object_processing import TrackedObject, TrackedObjectProcessor from frigate.object_processing import TrackedObject, TrackedObjectProcessor
from frigate.stats import stats_snapshot from frigate.stats import stats_snapshot
from frigate.util import clean_camera_user_pass
from frigate.version import VERSION from frigate.version import VERSION
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -581,7 +582,7 @@ def config():
camera_dict = config["cameras"][camera_name] camera_dict = config["cameras"][camera_name]
camera_dict["ffmpeg_cmds"] = copy.deepcopy(camera.ffmpeg_cmds) camera_dict["ffmpeg_cmds"] = copy.deepcopy(camera.ffmpeg_cmds)
for cmd in camera_dict["ffmpeg_cmds"]: for cmd in camera_dict["ffmpeg_cmds"]:
cmd["cmd"] = " ".join(cmd["cmd"]) cmd["cmd"] = clean_camera_user_pass(" ".join(cmd["cmd"]))
config["plus"] = {"enabled": current_app.plus_api.is_active()} config["plus"] = {"enabled": current_app.plus_api.is_active()}

View File

@ -2,7 +2,6 @@
import logging import logging
import threading import threading
import os import os
import signal
import queue import queue
from multiprocessing.queues import Queue from multiprocessing.queues import Queue
from logging import handlers from logging import handlers
@ -10,6 +9,8 @@ from setproctitle import setproctitle
from typing import Deque from typing import Deque
from collections import deque from collections import deque
from frigate.util import clean_camera_user_pass
def listener_configurer() -> None: def listener_configurer() -> None:
root = logging.getLogger() root = logging.getLogger()
@ -55,6 +56,11 @@ class LogPipe(threading.Thread):
self.pipeReader = os.fdopen(self.fdRead) self.pipeReader = os.fdopen(self.fdRead)
self.start() self.start()
def cleanup_log(self, log: str) -> str:
"""Cleanup the log line to remove sensitive info and string tokens."""
log = clean_camera_user_pass(log).strip("\n")
return log
def fileno(self) -> int: def fileno(self) -> int:
"""Return the write file descriptor of the pipe""" """Return the write file descriptor of the pipe"""
return self.fdWrite return self.fdWrite
@ -62,7 +68,7 @@ class LogPipe(threading.Thread):
def run(self) -> None: def run(self) -> None:
"""Run the thread, logging everything.""" """Run the thread, logging everything."""
for line in iter(self.pipeReader.readline, ""): for line in iter(self.pipeReader.readline, ""):
self.deque.append(line.strip("\n")) self.deque.append(self.cleanup_log(line))
self.pipeReader.close() self.pipeReader.close()

View File

@ -0,0 +1,33 @@
"""Test camera user and password cleanup."""
import unittest
from frigate.util import clean_camera_user_pass, escape_special_characters
class TestUserPassCleanup(unittest.TestCase):
def setUp(self) -> None:
self.rtsp_with_pass = "rtsp://user:password@192.168.0.2:554/live"
self.rtsp_with_special_pass = "rtsp://user:password#$@@192.168.0.2:554/live"
self.rtsp_no_pass = "rtsp://192.168.0.3:554/live"
def test_cleanup(self):
"""Test that user / pass are cleaned up."""
clean = clean_camera_user_pass(self.rtsp_with_pass)
assert clean != self.rtsp_with_pass
assert "user:password" not in clean
def test_no_cleanup(self):
"""Test that nothing changes when no user / pass are defined."""
clean = clean_camera_user_pass(self.rtsp_no_pass)
assert clean == self.rtsp_no_pass
def test_special_char_password(self):
"""Test that special characters in pw are escaped, but not others."""
escaped = escape_special_characters(self.rtsp_with_special_pass)
assert escaped == "rtsp://user:password%23%24%40@192.168.0.2:554/live"
def test_no_special_char_password(self):
"""Test that no change is made to path with no special characters."""
escaped = escape_special_characters(self.rtsp_with_pass)
assert escaped == self.rtsp_with_pass

View File

@ -1,25 +1,22 @@
import copy import copy
import datetime import datetime
import hashlib
import json
import logging import logging
import math import re
import signal import signal
import subprocess as sp
import threading
import time
import traceback import traceback
import urllib.parse
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Mapping from collections.abc import Mapping
from multiprocessing import shared_memory from multiprocessing import shared_memory
from typing import AnyStr from typing import AnyStr
import cv2 import cv2
import matplotlib.pyplot as plt
import numpy as np import numpy as np
import os import os
import psutil import psutil
from frigate.const import REGEX_CAMERA_USER_PASS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -625,6 +622,23 @@ def load_labels(path, encoding="utf-8"):
return {index: line.strip() for index, line in enumerate(lines)} return {index: line.strip() for index, line in enumerate(lines)}
def clean_camera_user_pass(line: str) -> str:
"""Removes user and password from line."""
# todo also remove http password like reolink
return re.sub(REGEX_CAMERA_USER_PASS, "*:*@", line)
def escape_special_characters(path: str) -> str:
"""Cleans reserved characters to encodings for ffmpeg."""
try:
found = re.search(REGEX_CAMERA_USER_PASS, path).group(0)[:-1]
pw = found[(found.index(":") + 1) :]
return path.replace(pw, urllib.parse.quote_plus(pw))
except AttributeError:
# path does not have user:pass
return path
class FrameManager(ABC): class FrameManager(ABC):
@abstractmethod @abstractmethod
def create(self, name, size) -> AnyStr: def create(self, name, size) -> AnyStr: