Removed flask app in favour of FastAPI app. Implemented FastAPI middleware to check CSRF, connect and disconnect from DB. Added middleware x-forwared-for headers

This commit is contained in:
Rui Alves 2024-09-19 10:17:14 +01:00
parent 655d24a653
commit 8f8d8e1e4c
8 changed files with 69 additions and 103 deletions

View File

@ -1,6 +1,7 @@
click == 8.1.* click == 8.1.*
Flask == 3.0.* # FastAPI
Flask_Limiter == 3.8.* starlette-context == 0.3.6
fastapi == 0.115.0
imutils == 0.5.* imutils == 0.5.*
joserfc == 1.0.* joserfc == 1.0.*
markupsafe == 2.1.* markupsafe == 2.1.*

View File

@ -26,6 +26,8 @@ In the event that you are locked out of your instance, you can tell Frigate to r
## Login failure rate limiting ## Login failure rate limiting
# TODO: Rui Update to use FastAPI
In order to limit the risk of brute force attacks, rate limiting is available for login failures. This is implemented with Flask-Limiter, and the string notation for valid values is available in [the documentation](https://flask-limiter.readthedocs.io/en/stable/configuration.html#rate-limit-string-notation). In order to limit the risk of brute force attacks, rate limiting is available for login failures. This is implemented with Flask-Limiter, and the string notation for valid values is available in [the documentation](https://flask-limiter.readthedocs.io/en/stable/configuration.html#rate-limit-string-notation).
For example, `1/second;5/minute;20/hour` will rate limit the login endpoint when failures occur more than: For example, `1/second;5/minute;20/hour` will rate limit the login endpoint when failures occur more than:

View File

@ -1,16 +1,12 @@
import faulthandler import faulthandler
import threading import threading
from flask import cli
from frigate.app import FrigateApp from frigate.app import FrigateApp
faulthandler.enable() faulthandler.enable()
threading.current_thread().name = "frigate" threading.current_thread().name = "frigate"
cli.show_server_banner = lambda *x: None
if __name__ == "__main__": if __name__ == "__main__":
frigate_app = FrigateApp() frigate_app = FrigateApp()

View File

@ -14,25 +14,15 @@ from fastapi import APIRouter, Path, Request, Response
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from flask import Flask, jsonify, request
from markupsafe import escape from markupsafe import escape
from peewee import operator from peewee import operator
from playhouse.sqliteq import SqliteQueueDatabase
from werkzeug.middleware.proxy_fix import ProxyFix
from frigate.api.auth import get_jwt_secret, limiter
from frigate.api.defs.app_body import AppConfigSetBody from frigate.api.defs.app_body import AppConfigSetBody
from frigate.api.defs.app_query_parameters import AppTimelineHourlyQueryParameters from frigate.api.defs.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import CONFIG_DIR from frigate.const import CONFIG_DIR
from frigate.embeddings import EmbeddingsContext
from frigate.events.external import ExternalEventProcessor
from frigate.models import Event, Timeline from frigate.models import Event, Timeline
from frigate.plus import PlusApi
from frigate.ptz.onvif import OnvifController
from frigate.stats.emitter import StatsEmitter
from frigate.storage import StorageMaintainer
from frigate.util.builtin import ( from frigate.util.builtin import (
clean_camera_user_pass, clean_camera_user_pass,
get_tz_modifiers, get_tz_modifiers,
@ -47,56 +37,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.app]) router = APIRouter(tags=[Tags.app])
def create_app(
frigate_config,
database: SqliteQueueDatabase,
embeddings: Optional[EmbeddingsContext],
detected_frames_processor,
storage_maintainer: StorageMaintainer,
onvif: OnvifController,
external_processor: ExternalEventProcessor,
plus_api: PlusApi,
stats_emitter: StatsEmitter,
):
app = Flask(__name__)
@app.before_request
def check_csrf():
if request.method in ["GET", "HEAD", "OPTIONS", "TRACE"]:
pass
if "origin" in request.headers and "x-csrf-token" not in request.headers:
return jsonify({"success": False, "message": "Missing CSRF header"}), 401
@app.before_request
def _db_connect():
if database.is_closed():
database.connect()
@app.teardown_request
def _db_close(exc):
if not database.is_closed():
database.close()
app.frigate_config = frigate_config
app.embeddings = embeddings
app.detected_frames_processor = detected_frames_processor
app.storage_maintainer = storage_maintainer
app.onvif = onvif
app.external_processor = external_processor
app.plus_api = plus_api
app.camera_error_image = None
app.stats_emitter = stats_emitter
app.jwt_token = get_jwt_secret() if frigate_config.auth.enabled else None
# update the request_address with the x-forwarded-for header from nginx
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)
# initialize the rate limiter for the login endpoint
limiter.init_app(app)
if frigate_config.auth.failed_login_rate_limit is None:
limiter.enabled = False
return app
@router.get("/") @router.get("/")
def is_healthy(): def is_healthy():
return "Frigate is running. Alive and healthy!" return "Frigate is running. Alive and healthy!"
@ -306,7 +246,7 @@ def config_save(save_option: str, body: dict):
@router.put("/config/set") @router.put("/config/set")
def config_set(body: AppConfigSetBody): def config_set(request: Request, body: AppConfigSetBody):
config_file = os.environ.get("CONFIG_FILE", f"{CONFIG_DIR}/config.yml") config_file = os.environ.get("CONFIG_FILE", f"{CONFIG_DIR}/config.yml")
# Check if we can use .yaml instead of .yml # Check if we can use .yaml instead of .yml

View File

@ -182,20 +182,19 @@ def auth(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth auth_config: AuthConfig = request.app.frigate_config.auth
proxy_config: ProxyConfig = request.app.frigate_config.proxy proxy_config: ProxyConfig = request.app.frigate_config.proxy
success_response = Response(content={}, status_code=202) success_response = Response("", status_code=202)
# dont require auth if the request is on the internal port # dont require auth if the request is on the internal port
# this header is set by Frigate's nginx proxy, so it cant be spoofed # this header is set by Frigate's nginx proxy, so it cant be spoofed
if request.headers.get("x-server-port", 0, type=int) == 5000: if int(request.headers.get("x-server-port", default=0)) == 5000:
return success_response return success_response
fail_response = Response(content={}, status_code=401) fail_response = Response("", status_code=401)
# ensure the proxy secret matches if configured # ensure the proxy secret matches if configured
if ( if (
proxy_config.auth_secret is not None proxy_config.auth_secret is not None
and request.headers.get("x-proxy-secret", "", type=str) and request.headers.get("x-proxy-secret", "") != proxy_config.auth_secret
!= proxy_config.auth_secret
): ):
logger.debug("X-Proxy-Secret header does not match configured secret value") logger.debug("X-Proxy-Secret header does not match configured secret value")
return fail_response return fail_response
@ -207,7 +206,6 @@ def auth(request: Request):
if proxy_config.header_map.user is not None: if proxy_config.header_map.user is not None:
upstream_user_header_value = request.headers.get( upstream_user_header_value = request.headers.get(
proxy_config.header_map.user, proxy_config.header_map.user,
type=str,
default="anonymous", default="anonymous",
) )
success_response.headers["remote-user"] = upstream_user_header_value success_response.headers["remote-user"] = upstream_user_header_value

View File

@ -1,7 +1,10 @@
import logging import logging
from typing import Optional from typing import Optional
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from playhouse.sqliteq import SqliteQueueDatabase
from starlette_context import middleware, plugins
from frigate.api import app as main_app from frigate.api import app as main_app
from frigate.api import auth, event, export, media, notification, preview, review from frigate.api import auth, event, export, media, notification, preview, review
@ -16,21 +19,62 @@ from frigate.storage import StorageMaintainer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def check_csrf(request: Request):
if request.method in ["GET", "HEAD", "OPTIONS", "TRACE"]:
pass
if "origin" in request.headers and "x-csrf-token" not in request.headers:
return JSONResponse(
content={"success": False, "message": "Missing CSRF header"},
status_code=401,
)
def create_fastapi_app( def create_fastapi_app(
frigate_config, frigate_config,
database: SqliteQueueDatabase,
embeddings: Optional[EmbeddingsContext], embeddings: Optional[EmbeddingsContext],
detected_frames_processor, detected_frames_processor,
storage_maintainer: StorageMaintainer, storage_maintainer: StorageMaintainer,
onvif: OnvifController, onvif: OnvifController,
external_processor: ExternalEventProcessor,
plus_api: PlusApi, plus_api: PlusApi,
stats_emitter: StatsEmitter, stats_emitter: StatsEmitter,
external_processor: ExternalEventProcessor,
): ):
logger.info("Starting FastAPI app") logger.info("Starting FastAPI app")
app = FastAPI( app = FastAPI(
debug=False, debug=False,
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"}, swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
) )
# update the request_address with the x-forwarded-for header from nginx
app.add_middleware(
middleware.ContextMiddleware,
plugins=(plugins.ForwardedForPlugin(),),
)
# Middleware to connect to DB before and close connection after request
# https://github.com/fastapi/full-stack-fastapi-template/issues/224#issuecomment-737423886
# https://fastapi.tiangolo.com/tutorial/middleware/#before-and-after-the-response
@app.middleware("http")
async def frigate_middleware(request: Request, call_next):
# Before request
check_csrf(request)
if database.is_closed():
database.connect()
response = await call_next(request)
# After request https://stackoverflow.com/a/75487519
if not database.is_closed():
database.close()
return response
# TODO: Rui
# initialize the rate limiter for the login endpoint
# limiter.init_app(app)
# if frigate_config.auth.failed_login_rate_limit is None:
# limiter.enabled = False
# Routes # Routes
app.include_router(main_app.router) app.include_router(main_app.router)
app.include_router(media.router) app.include_router(media.router)

View File

@ -15,13 +15,11 @@ from typing import Optional
import psutil import psutil
import uvicorn import uvicorn
from fastapi.middleware.wsgi import WSGIMiddleware
from peewee_migrate import Router from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
from pydantic import ValidationError from pydantic import ValidationError
from frigate.api.app import create_app
from frigate.api.auth import hash_password from frigate.api.auth import hash_password
from frigate.api.fastapi_app import create_fastapi_app from frigate.api.fastapi_app import create_fastapi_app
from frigate.comms.config_updater import ConfigPublisher from frigate.comms.config_updater import ConfigPublisher
@ -388,7 +386,7 @@ class FrigateApp:
self.inter_zmq_proxy = ZmqProxy() self.inter_zmq_proxy = ZmqProxy()
def init_web_server(self) -> None: def init_web_server(self) -> None:
self.flask_app = create_app( self.fastapi_app = create_fastapi_app(
self.config, self.config,
self.db, self.db,
self.embeddings, self.embeddings,
@ -400,17 +398,6 @@ class FrigateApp:
self.stats_emitter, self.stats_emitter,
) )
self.fastapi_app = create_fastapi_app(
self.config,
self.embeddings,
self.detected_frames_processor,
self.storage_maintainer,
self.onvif_controller,
self.plus_api,
self.stats_emitter,
self.external_event_processor,
)
def init_onvif(self) -> None: def init_onvif(self) -> None:
self.onvif_controller = OnvifController(self.config, self.ptz_metrics) self.onvif_controller = OnvifController(self.config, self.ptz_metrics)
@ -761,6 +748,7 @@ class FrigateApp:
self.start_watchdog() self.start_watchdog()
self.init_auth() self.init_auth()
# TODO: Rui. What to do in this case? Maybe https://github.com/encode/uvicorn/issues/1579#issuecomment-1419635974
# Flask only listens for SIGINT, so we need to catch SIGTERM and send SIGINT # Flask only listens for SIGINT, so we need to catch SIGTERM and send SIGINT
def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None: def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None:
os.kill(os.getpid(), signal.SIGINT) os.kill(os.getpid(), signal.SIGINT)
@ -768,8 +756,6 @@ class FrigateApp:
signal.signal(signal.SIGTERM, receiveSignal) signal.signal(signal.SIGTERM, receiveSignal)
try: try:
# Run the flask app inside fastapi: https://fastapi.tiangolo.com/advanced/sub-applications/
self.fastapi_app.mount("", WSGIMiddleware(self.flask_app))
uvicorn.run( uvicorn.run(
self.fastapi_app, self.fastapi_app,
host="127.0.0.1", host="127.0.0.1",
@ -778,7 +764,7 @@ class FrigateApp:
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
logger.info("FastAPI/Flask has exited...") logger.info("FastAPI has exited...")
self.stop() self.stop()

View File

@ -11,7 +11,6 @@ from playhouse.shortcuts import model_to_dict
from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
from frigate.api.app import create_app
from frigate.api.fastapi_app import create_fastapi_app from frigate.api.fastapi_app import create_fastapi_app
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.models import Event, Recordings from frigate.models import Event, Recordings
@ -115,7 +114,7 @@ class TestHttp(unittest.TestCase):
pass pass
def test_get_event_list(self): def test_get_event_list(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -152,7 +151,7 @@ class TestHttp(unittest.TestCase):
assert not events assert not events
def test_get_good_event(self): def test_get_good_event(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -174,7 +173,7 @@ class TestHttp(unittest.TestCase):
assert event == model_to_dict(Event.get(Event.id == id)) assert event == model_to_dict(Event.get(Event.id == id))
def test_get_bad_event(self): def test_get_bad_event(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -195,7 +194,7 @@ class TestHttp(unittest.TestCase):
assert not event assert not event
def test_delete_event(self): def test_delete_event(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -218,7 +217,7 @@ class TestHttp(unittest.TestCase):
assert not event assert not event
def test_event_retention(self): def test_event_retention(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -245,7 +244,7 @@ class TestHttp(unittest.TestCase):
assert event["retain_indefinitely"] is False assert event["retain_indefinitely"] is False
def test_event_time_filtering(self): def test_event_time_filtering(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -284,7 +283,7 @@ class TestHttp(unittest.TestCase):
assert len(events) == 1 assert len(events) == 1
def test_set_delete_sub_label(self): def test_set_delete_sub_label(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -320,7 +319,7 @@ class TestHttp(unittest.TestCase):
assert event["sub_label"] == "" assert event["sub_label"] == ""
def test_sub_label_list(self): def test_sub_label_list(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config), FrigateConfig(**self.minimal_config),
self.db, self.db,
None, None,
@ -346,7 +345,7 @@ class TestHttp(unittest.TestCase):
assert sub_labels == [sub_label] assert sub_labels == [sub_label]
def test_config(self): def test_config(self):
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config).runtime_config(), FrigateConfig(**self.minimal_config).runtime_config(),
self.db, self.db,
None, None,
@ -386,7 +385,7 @@ class TestHttp(unittest.TestCase):
def test_stats(self): def test_stats(self):
stats = Mock(spec=StatsEmitter) stats = Mock(spec=StatsEmitter)
stats.get_latest_stats.return_value = self.test_stats stats.get_latest_stats.return_value = self.test_stats
app = create_app( app = create_fastapi_app(
FrigateConfig(**self.minimal_config).runtime_config(), FrigateConfig(**self.minimal_config).runtime_config(),
self.db, self.db,
None, None,