mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 17:18:58 +03:00
Add a deny option for the proxy default role (#24145)
* backend * frontend * docs * fix none default role casing and name reserved roles in the error * reserve every casing of none as a role name
This commit is contained in:
committed by
Nicolas Mowen
parent
41bc1a5844
commit
e2da7aae99
+18
-3
@@ -497,6 +497,7 @@ def resolve_role(
|
||||
Admin matches short-circuit to admin.
|
||||
- If no role_map is configured, treat the header as role names directly.
|
||||
2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'.
|
||||
The literal value 'none' is a valid default and means access should be denied.
|
||||
|
||||
Args:
|
||||
headers (dict): Incoming request headers (case-insensitive).
|
||||
@@ -509,10 +510,17 @@ def resolve_role(
|
||||
default_role = proxy_config.default_role
|
||||
role_header = proxy_config.header_map.role
|
||||
|
||||
# Validate default_role against config; fallback to 'viewer' if invalid
|
||||
validated_default = default_role if default_role in config_roles else "viewer"
|
||||
# Validate default_role against config; fallback to 'viewer' if invalid.
|
||||
# "none" is a sentinel meaning "deny access when no mapping matches"; it is
|
||||
# reserved in AuthConfig.validate_roles so it is never a configured role.
|
||||
validated_default = (
|
||||
default_role
|
||||
if default_role in config_roles or default_role == "none"
|
||||
else "viewer"
|
||||
)
|
||||
if not config_roles:
|
||||
validated_default = "viewer" # Edge case: no roles defined
|
||||
# Edge case: no roles defined
|
||||
validated_default = "none" if default_role == "none" else "viewer"
|
||||
|
||||
if not role_header:
|
||||
logger.debug(
|
||||
@@ -617,6 +625,9 @@ def resolve_role(
|
||||
},
|
||||
},
|
||||
401: {"description": "Authentication Failed"},
|
||||
403: {
|
||||
"description": "Access Denied (proxy user resolved to a default role of 'none')"
|
||||
},
|
||||
},
|
||||
)
|
||||
def auth(request: Request):
|
||||
@@ -666,6 +677,10 @@ def auth(request: Request):
|
||||
config_roles_set = set(auth_config.roles.keys())
|
||||
role = resolve_role(request.headers, proxy_config, config_roles_set)
|
||||
|
||||
if role == "none":
|
||||
logger.debug("Resolved role is 'none', denying access")
|
||||
return Response("", status_code=403)
|
||||
|
||||
success_response.headers["remote-role"] = role
|
||||
|
||||
deny_status = deny_response_for_media_uri(original_url, role, frigate_config)
|
||||
|
||||
@@ -78,11 +78,14 @@ class AuthConfig(FrigateBaseModel):
|
||||
f"Invalid role name '{role}'. Must be alphanumeric with underscores."
|
||||
)
|
||||
|
||||
# Ensure 'admin' and 'viewer' are not used as custom role names
|
||||
reserved_roles = {"admin", "viewer"}
|
||||
if v.keys() & reserved_roles:
|
||||
# 'none' is the deny sentinel for proxy.default_role, where it is matched
|
||||
# case-insensitively, so every casing of it has to be reserved here
|
||||
used_reserved = sorted(
|
||||
r for r in v if r in ("admin", "viewer") or r.lower() == "none"
|
||||
)
|
||||
if used_reserved:
|
||||
raise ValueError(
|
||||
f"Reserved roles {reserved_roles} cannot be used as custom roles."
|
||||
f"Reserved role name(s) {', '.join(used_reserved)} cannot be used as custom roles."
|
||||
)
|
||||
|
||||
# Ensure no role has an empty camera list
|
||||
|
||||
+11
-1
@@ -43,7 +43,7 @@ class ProxyConfig(FrigateBaseModel):
|
||||
default_role: str | None = Field(
|
||||
default="viewer",
|
||||
title="Default role",
|
||||
description="Default role assigned to proxy-authenticated users when no role mapping applies.",
|
||||
description="Default role assigned to proxy-authenticated users when no role mapping applies. Set to 'none' to deny access to unmapped users.",
|
||||
)
|
||||
separator: str | None = Field(
|
||||
default=",",
|
||||
@@ -51,6 +51,16 @@ class ProxyConfig(FrigateBaseModel):
|
||||
description="Character used to split multiple values provided in proxy headers.",
|
||||
)
|
||||
|
||||
@field_validator("default_role", mode="before")
|
||||
@classmethod
|
||||
def normalize_deny_sentinel(cls, v):
|
||||
# Fail closed on capitalization: an unnormalized "None" would miss the
|
||||
# sentinel and fall back to viewer, granting the access it was meant to
|
||||
# deny. Other role names stay case-sensitive.
|
||||
if isinstance(v, str) and v.strip().lower() == "none":
|
||||
return "none"
|
||||
return v
|
||||
|
||||
@field_validator("separator", mode="before")
|
||||
@classmethod
|
||||
def validate_separator_length(cls, v):
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import unittest
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.api.auth import resolve_role
|
||||
from frigate.config import HeaderMappingConfig, ProxyConfig
|
||||
from frigate.config import AuthConfig, HeaderMappingConfig, ProxyConfig
|
||||
from frigate.config.env import FRIGATE_ENV_VARS
|
||||
|
||||
|
||||
@@ -94,6 +96,137 @@ class TestProxyRoleResolution(unittest.TestCase):
|
||||
self.assertEqual(role, self.proxy_config.default_role)
|
||||
|
||||
|
||||
class TestDefaultRoleNone(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.proxy_config = ProxyConfig(
|
||||
auth_secret=None,
|
||||
default_role="none",
|
||||
separator="|",
|
||||
header_map=HeaderMappingConfig(
|
||||
user="x-remote-user",
|
||||
role="x-remote-role",
|
||||
role_map={
|
||||
"admin": ["group_admin"],
|
||||
"viewer": ["group_viewer"],
|
||||
},
|
||||
),
|
||||
)
|
||||
self.config_roles = list(["admin", "viewer"])
|
||||
|
||||
def test_default_role_none_no_match(self):
|
||||
"""Unmatched groups resolve to 'none' when default_role is 'none'."""
|
||||
headers = {"x-remote-role": "group_unknown"}
|
||||
role = resolve_role(headers, self.proxy_config, self.config_roles)
|
||||
self.assertEqual(role, "none")
|
||||
|
||||
def test_default_role_none_with_match(self):
|
||||
"""Matched groups still resolve normally when default_role is 'none'."""
|
||||
headers = {"x-remote-role": "group_admin"}
|
||||
role = resolve_role(headers, self.proxy_config, self.config_roles)
|
||||
self.assertEqual(role, "admin")
|
||||
|
||||
def test_default_role_none_missing_header(self):
|
||||
"""A missing role header resolves to 'none'."""
|
||||
headers = {}
|
||||
role = resolve_role(headers, self.proxy_config, self.config_roles)
|
||||
self.assertEqual(role, "none")
|
||||
|
||||
def test_default_role_none_empty_header(self):
|
||||
"""An empty role header resolves to 'none'."""
|
||||
headers = {"x-remote-role": ""}
|
||||
role = resolve_role(headers, self.proxy_config, self.config_roles)
|
||||
self.assertEqual(role, "none")
|
||||
|
||||
def test_default_role_none_no_role_map(self):
|
||||
"""An invalid direct role name resolves to 'none' without a role_map."""
|
||||
config = ProxyConfig(
|
||||
auth_secret=None,
|
||||
default_role="none",
|
||||
separator="|",
|
||||
header_map=HeaderMappingConfig(
|
||||
user="x-remote-user",
|
||||
role="x-remote-role",
|
||||
role_map=None,
|
||||
),
|
||||
)
|
||||
headers = {"x-remote-role": "notarole"}
|
||||
role = resolve_role(headers, config, self.config_roles)
|
||||
self.assertEqual(role, "none")
|
||||
|
||||
def test_default_role_none_no_role_header_configured(self):
|
||||
"""Proxy configs without a role header resolve to 'none'."""
|
||||
config = ProxyConfig(
|
||||
auth_secret=None,
|
||||
default_role="none",
|
||||
separator="|",
|
||||
header_map=HeaderMappingConfig(user="x-remote-user"),
|
||||
)
|
||||
role = resolve_role({}, config, self.config_roles)
|
||||
self.assertEqual(role, "none")
|
||||
|
||||
def test_default_role_none_no_roles_configured(self):
|
||||
"""'none' survives the empty config_roles edge case."""
|
||||
headers = {"x-remote-role": "group_admin"}
|
||||
role = resolve_role(headers, self.proxy_config, set())
|
||||
self.assertEqual(role, "none")
|
||||
|
||||
def test_default_role_none_is_case_insensitive(self):
|
||||
"""Capitalized spellings must deny, not fall back to viewer."""
|
||||
for spelling in ("None", "NONE", "nOnE", " none "):
|
||||
with self.subTest(default_role=spelling):
|
||||
config = ProxyConfig(
|
||||
auth_secret=None,
|
||||
default_role=spelling,
|
||||
separator="|",
|
||||
header_map=HeaderMappingConfig(
|
||||
user="x-remote-user",
|
||||
role="x-remote-role",
|
||||
role_map={"admin": ["group_admin"]},
|
||||
),
|
||||
)
|
||||
self.assertEqual(config.default_role, "none")
|
||||
role = resolve_role(
|
||||
{"x-remote-role": "group_unknown"}, config, self.config_roles
|
||||
)
|
||||
self.assertEqual(role, "none")
|
||||
|
||||
def test_other_role_names_stay_case_sensitive(self):
|
||||
"""Only the deny sentinel is normalized; role names are untouched."""
|
||||
config = ProxyConfig(default_role="Operator")
|
||||
self.assertEqual(config.default_role, "Operator")
|
||||
|
||||
|
||||
class TestReservedRoleNames(unittest.TestCase):
|
||||
def test_reserved_names_rejected(self):
|
||||
"""admin, viewer, and the 'none' deny sentinel cannot be custom roles."""
|
||||
for name in ("admin", "viewer", "none"):
|
||||
with self.subTest(role=name):
|
||||
with self.assertRaises(ValidationError):
|
||||
AuthConfig(roles={name: ["front_door"]})
|
||||
|
||||
def test_custom_role_still_allowed(self):
|
||||
config = AuthConfig(roles={"operator": ["front_door"]})
|
||||
self.assertEqual(config.roles["operator"], ["front_door"])
|
||||
|
||||
def test_error_names_the_offending_role(self):
|
||||
"""The message must say which name to rename, in a stable order."""
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
AuthConfig(roles={"none": ["front_door"], "admin": ["front_door"]})
|
||||
self.assertIn("admin, none", str(ctx.exception))
|
||||
|
||||
def test_none_reserved_in_every_casing(self):
|
||||
"""proxy.default_role folds case, so a 'None' role would be unreachable."""
|
||||
for name in ("None", "NONE", "nOnE"):
|
||||
with self.subTest(role=name):
|
||||
with self.assertRaises(ValidationError):
|
||||
AuthConfig(roles={name: ["front_door"]})
|
||||
|
||||
def test_case_variant_of_a_normal_role_still_allowed(self):
|
||||
"""Only 'none' folds case; other role names are untouched."""
|
||||
config = AuthConfig(roles={"Operator": ["front_door"]})
|
||||
self.assertEqual(config.roles["Operator"], ["front_door"])
|
||||
|
||||
|
||||
class TestProxyAuthSecretEnvString(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._original_env_vars = dict(FRIGATE_ENV_VARS)
|
||||
|
||||
Reference in New Issue
Block a user