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.*
Flask == 3.0.*
Flask_Limiter == 3.8.*
# FastAPI
starlette-context == 0.3.6
fastapi == 0.115.0
imutils == 0.5.*
joserfc == 1.0.*
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
# 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).
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 threading
from flask import cli
from frigate.app import FrigateApp
faulthandler.enable()
threading.current_thread().name = "frigate"
cli.show_server_banner = lambda *x: None
if __name__ == "__main__":
frigate_app = FrigateApp()

View File

@ -14,25 +14,15 @@ from fastapi import APIRouter, Path, Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi.params import Depends
from fastapi.responses import JSONResponse
from flask import Flask, jsonify, request
from markupsafe import escape
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_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig
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.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 (
clean_camera_user_pass,
get_tz_modifiers,
@ -47,56 +37,6 @@ logger = logging.getLogger(__name__)
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("/")
def is_healthy():
return "Frigate is running. Alive and healthy!"
@ -306,7 +246,7 @@ def config_save(save_option: str, body: dict):
@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")
# 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
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
# 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
fail_response = Response(content={}, status_code=401)
fail_response = Response("", status_code=401)
# ensure the proxy secret matches if configured
if (
proxy_config.auth_secret is not None
and request.headers.get("x-proxy-secret", "", type=str)
!= proxy_config.auth_secret
and request.headers.get("x-proxy-secret", "") != proxy_config.auth_secret
):
logger.debug("X-Proxy-Secret header does not match configured secret value")
return fail_response
@ -207,7 +206,6 @@ def auth(request: Request):
if proxy_config.header_map.user is not None:
upstream_user_header_value = request.headers.get(
proxy_config.header_map.user,
type=str,
default="anonymous",
)
success_response.headers["remote-user"] = upstream_user_header_value

View File

@ -1,7 +1,10 @@
import logging
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 auth, event, export, media, notification, preview, review
@ -16,21 +19,62 @@ from frigate.storage import StorageMaintainer
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(
frigate_config,
database: SqliteQueueDatabase,
embeddings: Optional[EmbeddingsContext],
detected_frames_processor,
storage_maintainer: StorageMaintainer,
onvif: OnvifController,
external_processor: ExternalEventProcessor,
plus_api: PlusApi,
stats_emitter: StatsEmitter,
external_processor: ExternalEventProcessor,
):
logger.info("Starting FastAPI app")
app = FastAPI(
debug=False,
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
app.include_router(main_app.router)
app.include_router(media.router)

View File

@ -15,13 +15,11 @@ from typing import Optional
import psutil
import uvicorn
from fastapi.middleware.wsgi import WSGIMiddleware
from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
from pydantic import ValidationError
from frigate.api.app import create_app
from frigate.api.auth import hash_password
from frigate.api.fastapi_app import create_fastapi_app
from frigate.comms.config_updater import ConfigPublisher
@ -388,7 +386,7 @@ class FrigateApp:
self.inter_zmq_proxy = ZmqProxy()
def init_web_server(self) -> None:
self.flask_app = create_app(
self.fastapi_app = create_fastapi_app(
self.config,
self.db,
self.embeddings,
@ -400,17 +398,6 @@ class FrigateApp:
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:
self.onvif_controller = OnvifController(self.config, self.ptz_metrics)
@ -761,6 +748,7 @@ class FrigateApp:
self.start_watchdog()
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
def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None:
os.kill(os.getpid(), signal.SIGINT)
@ -768,8 +756,6 @@ class FrigateApp:
signal.signal(signal.SIGTERM, receiveSignal)
try:
# Run the flask app inside fastapi: https://fastapi.tiangolo.com/advanced/sub-applications/
self.fastapi_app.mount("", WSGIMiddleware(self.flask_app))
uvicorn.run(
self.fastapi_app,
host="127.0.0.1",
@ -778,7 +764,7 @@ class FrigateApp:
except KeyboardInterrupt:
pass
logger.info("FastAPI/Flask has exited...")
logger.info("FastAPI has exited...")
self.stop()

View File

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