Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions surfsense_backend/app/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,10 @@ def is_cloud(cls) -> bool:
os.getenv("SMB_DEFAULT_WORKSPACE_NAME") or os.getenv("SMB_NAME") or ""
).strip()

# Corporate tenant ID for cross-tenant isolation. When set, the proxy-auth
# middleware rejects access tokens whose custom:corporate_id does not match.
SMB_CORPORATE_ID = (os.getenv("SMB_CORPORATE_ID") or "").strip()

# Google OAuth
GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID")
GOOGLE_OAUTH_CLIENT_SECRET = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET")
Expand Down
29 changes: 28 additions & 1 deletion surfsense_backend/app/middleware/proxy_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
import secrets
import unicodedata

import jwt
from fastapi_users.db import SQLAlchemyUserDatabase
from fastapi_users.password import PasswordHelper # singleton below
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from starlette.responses import JSONResponse, Response

from app.config import config
from app.db import User, async_session_maker
Expand All @@ -33,6 +34,29 @@ def _coerce_bypass_paths(setting) -> list[str]:
return list(setting)


def _check_corporate_id(request) -> bool:
"""Verify that the caller's access token belongs to this deployment's tenant.

When ``SMB_CORPORATE_ID`` is configured, only corporate tokens whose
``custom:corporate_id`` claim matches the expected value are allowed.
Individual (non-corporate) tokens are rejected. When the setting is
empty the check is skipped entirely for backward compatibility.
"""
expected = getattr(config, "SMB_CORPORATE_ID", "")
if not expected:
return True
access_token = request.headers.get("x-auth-request-access-token")
if not access_token:
return False
try:
claims = jwt.decode(access_token, options={"verify_signature": False})
except Exception:
return False
if claims.get("custom:is_corporate") != "true":
return False
return claims.get("custom:corporate_id") == expected


def _is_bypass_path(path: str, bypass_paths: list[str]) -> bool:
# Match exact path OR a true subpath (e.g. /health/ready) but NOT a path that
# merely starts with the same characters (e.g. /healthz must NOT bypass /health).
Expand Down Expand Up @@ -98,6 +122,9 @@ async def dispatch(
)
return await call_next(request)

if not _check_corporate_id(request):
return JSONResponse(status_code=403, content={"error": "access_denied"})

user = await self._resolve_user(_normalise_email(raw_email), request)

# Respect deactivated accounts — mPass authentication does not
Expand Down