feat(auth): share links open only their own widgets; unmarked routes are closed (#5033)
#5033 "Share links open only their own dashboard; every route without a role is on an explicit allow-list". Any live share token in ?s= made the caller a viewer on every viewer route — host pages, container logs, any dashboard — and the host-agent widget routes had no role check at all. - steward/dashboard/share.py: a share token opens, read-only, exactly the widget fragment URLs its own dashboard loads — same path, same configured parameters; only the time window (hours/range) may change. - require_role marks its view; public_route marks the deliberately open ones (login, setup, health, the share page, agent ingest, install.sh, agent.py) with the reason. A before_request refuses any view with neither, so a route that forgets its decorator is closed, not open. - /plugins/host_agent/widget and /widget/history now require viewer. - The history widget's range buttons keep the rest of the query (and so a share link's token) instead of rebuilding it; the uptime widget's "Full SLA" link shows to logged-in users, not to share viewers. - tests/test_route_access.py: a source scan that fails on any unmarked route in core or bundled plugins, the default-deny refusal, and the share matcher's cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -77,10 +77,37 @@ def require_role(minimum_role: UserRole):
|
||||
if _ROLE_ORDER.index(user_role) < _ROLE_ORDER.index(minimum_role):
|
||||
abort(403)
|
||||
return await f(*args, **kwargs)
|
||||
wrapper.steward_role = minimum_role
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def public_route(reason: str):
|
||||
"""Mark a view that is deliberately reachable without a role — login,
|
||||
the health probe, a bearer-authenticated endpoint. Every other view must
|
||||
carry require_role; check_view_is_marked refuses one that has neither, so
|
||||
a route that forgets its decorator is closed rather than open."""
|
||||
def decorator(f):
|
||||
f.steward_public = reason
|
||||
return f
|
||||
return decorator
|
||||
|
||||
|
||||
async def check_view_is_marked() -> Response | None:
|
||||
"""before_request hook: default-deny for views with neither require_role
|
||||
nor public_route. Static files and unmatched paths (404) pass through."""
|
||||
from quart import current_app
|
||||
endpoint = request.endpoint
|
||||
if endpoint is None or endpoint == "static" or endpoint.endswith(".static"):
|
||||
return None
|
||||
view = current_app.view_functions.get(endpoint)
|
||||
if view is None or hasattr(view, "steward_role") or hasattr(view, "steward_public"):
|
||||
return None
|
||||
current_app.logger.error("Refused %s: view %r has no require_role or public_route",
|
||||
request.path, endpoint)
|
||||
abort(403)
|
||||
|
||||
|
||||
def login_user(user) -> None:
|
||||
"""Write user identity into the session, with a fresh CSRF token."""
|
||||
from steward.auth.csrf import rotate
|
||||
|
||||
Reference in New Issue
Block a user