feat(middleware): harden routing with fail-closed policy, custom subdomain management, and perf fixes
- Fix IPv6 host parsing with _strip_port() utility - Remove dangerous StorePlatform→Store.subdomain silent fallback - Close storefront gate bypass when frontend_type is None - Add custom subdomain management UI and API for stores - Add domain health diagnostic tool - Convert db.add() in loops to db.add_all() (24 PERF-006 fixes) - Add tests for all new functionality (18 subdomain service tests) - Add .github templates for validator compliance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -82,7 +82,7 @@ class FrontendTypeMiddleware(BaseHTTPMiddleware):
|
||||
return response
|
||||
|
||||
|
||||
def get_frontend_type(request: Request) -> FrontendType:
|
||||
def get_frontend_type(request: Request) -> FrontendType | None:
|
||||
"""
|
||||
Helper function to get current frontend type from request.
|
||||
|
||||
@@ -90,6 +90,7 @@ def get_frontend_type(request: Request) -> FrontendType:
|
||||
request: FastAPI request object
|
||||
|
||||
Returns:
|
||||
FrontendType enum value (defaults to PLATFORM if not set)
|
||||
FrontendType enum value, or None if the middleware hasn't run yet.
|
||||
Callers should handle None explicitly where context is clear.
|
||||
"""
|
||||
return getattr(request.state, "frontend_type", FrontendType.PLATFORM)
|
||||
return getattr(request.state, "frontend_type", None)
|
||||
|
||||
@@ -36,6 +36,31 @@ MAIN_PLATFORM_CODE = "main"
|
||||
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "testserver"}
|
||||
|
||||
|
||||
def _strip_port(host: str) -> str:
|
||||
"""
|
||||
Remove port from host, handling IPv6 bracket notation.
|
||||
|
||||
Examples:
|
||||
"localhost:8000" → "localhost"
|
||||
"[::1]:8000" → "::1"
|
||||
"[::1]" → "::1"
|
||||
"example.com" → "example.com"
|
||||
"192.168.1.1:80" → "192.168.1.1"
|
||||
"""
|
||||
if not host:
|
||||
return host
|
||||
# IPv6 with brackets: [::1]:8000 or [::1]
|
||||
if host.startswith("["):
|
||||
bracket_end = host.find("]")
|
||||
if bracket_end != -1:
|
||||
return host[1:bracket_end]
|
||||
return host # malformed, return as-is
|
||||
# Regular host:port
|
||||
if ":" in host:
|
||||
return host.split(":")[0]
|
||||
return host
|
||||
|
||||
|
||||
class PlatformContextManager:
|
||||
"""Manages platform context detection for multi-platform routing."""
|
||||
|
||||
@@ -62,7 +87,7 @@ class PlatformContextManager:
|
||||
path = request.url.path
|
||||
|
||||
# Remove port from host if present (e.g., localhost:9999 -> localhost)
|
||||
host_without_port = host.split(":")[0] if ":" in host else host
|
||||
host_without_port = _strip_port(host)
|
||||
|
||||
# Skip platform detection for admin routes - admin is global
|
||||
if FrontendDetector.is_admin(host, path):
|
||||
@@ -359,7 +384,7 @@ class PlatformContextMiddleware:
|
||||
# For storefront API requests on localhost, the path doesn't contain
|
||||
# /platforms/{code}/, so extract platform from the Referer header instead.
|
||||
# e.g., Referer: http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/...
|
||||
host_without_port = host.split(":")[0] if ":" in host else host
|
||||
host_without_port = _strip_port(host)
|
||||
if (
|
||||
host_without_port in _LOCAL_HOSTS
|
||||
and path.startswith("/api/v1/storefront/")
|
||||
@@ -450,7 +475,7 @@ class PlatformContextMiddleware:
|
||||
- /platforms/oms/pricing → OMS platform pricing
|
||||
- /platforms/loyalty/ → Loyalty platform homepage
|
||||
"""
|
||||
host_without_port = host.split(":")[0] if ":" in host else host
|
||||
host_without_port = _strip_port(host)
|
||||
|
||||
# Method 1: Domain-based (production)
|
||||
if host_without_port and host_without_port not in _LOCAL_HOSTS:
|
||||
|
||||
@@ -245,7 +245,7 @@ class StoreContextManager:
|
||||
)
|
||||
return store
|
||||
|
||||
# 2b. Fallback to Store.subdomain (global default)
|
||||
# 2b. Fallback to Store.subdomain with platform membership check
|
||||
store = (
|
||||
db.query(Store)
|
||||
.filter(func.lower(Store.subdomain) == subdomain.lower())
|
||||
@@ -254,6 +254,33 @@ class StoreContextManager:
|
||||
)
|
||||
|
||||
if store:
|
||||
# When a platform context exists and detection is "subdomain",
|
||||
# verify the store actually has an active membership on this
|
||||
# platform. Without this check, a subdomain like
|
||||
# "other-tenant.omsflow.lu" could resolve a store that only
|
||||
# belongs to the loyalty platform — a cross-tenant leak.
|
||||
if platform and context.get("detection_method") == "subdomain":
|
||||
from app.modules.tenancy.models.store_platform import (
|
||||
StorePlatform as SP,
|
||||
)
|
||||
|
||||
has_membership = (
|
||||
db.query(SP)
|
||||
.filter(
|
||||
SP.store_id == store.id,
|
||||
SP.platform_id == platform.id,
|
||||
SP.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not has_membership:
|
||||
logger.warning(
|
||||
f"[FAIL-CLOSED] Store '{subdomain}' exists but has no "
|
||||
f"active membership on platform {platform.code} — "
|
||||
f"blocking cross-tenant resolution"
|
||||
)
|
||||
return None
|
||||
|
||||
method = context.get("detection_method", "unknown")
|
||||
logger.info(
|
||||
f"[OK] Store found via {method}: {subdomain} → {store.name}"
|
||||
|
||||
@@ -83,6 +83,11 @@ def _is_static_request(path: str) -> bool:
|
||||
return "favicon.ico" in lower
|
||||
|
||||
|
||||
def _looks_like_storefront(path: str) -> bool:
|
||||
"""Return True if path belongs to the storefront surface."""
|
||||
return path.startswith(("/storefront/", "/api/v1/storefront/"))
|
||||
|
||||
|
||||
class StorefrontAccessMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Gate storefront requests behind an active subscription.
|
||||
@@ -94,6 +99,25 @@ class StorefrontAccessMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
frontend_type = getattr(request.state, "frontend_type", None)
|
||||
|
||||
# Safety net: if frontend_type is None (upstream middleware failed) but
|
||||
# the path looks like a storefront path, block instead of letting it
|
||||
# through — a None frontend_type must never bypass the gate.
|
||||
if frontend_type is None and _looks_like_storefront(request.url.path):
|
||||
logger.error(
|
||||
"[STOREFRONT_ACCESS] frontend_type is None on storefront path "
|
||||
f"'{request.url.path}' — blocking (fail-closed). "
|
||||
"Check middleware chain ordering."
|
||||
)
|
||||
if request.url.path.startswith("/api/"):
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "storefront_not_available",
|
||||
"reason": "middleware_misconfigured",
|
||||
},
|
||||
)
|
||||
return self._render_unavailable(request, "not_found")
|
||||
|
||||
# Only gate storefront requests
|
||||
if frontend_type != FrontendType.STOREFRONT:
|
||||
return await call_next(request)
|
||||
|
||||
Reference in New Issue
Block a user