#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>
59 lines
2.3 KiB
Python
59 lines
2.3 KiB
Python
"""What a dashboard share link opens.
|
|
|
|
A share link's page (/share/<token>) loads each of its dashboard's widgets
|
|
from a fragment URL with `&s=<token>` appended. The token opens exactly those
|
|
URLs, read-only: the same path and the same configured parameters (which host,
|
|
which container). Only the time window a viewer can switch on the widget
|
|
itself (VIEW_PARAMS) may differ. Every other page, and the same widget aimed
|
|
at a different host, still needs a login.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from datetime import datetime, timezone
|
|
from urllib.parse import parse_qsl
|
|
|
|
from sqlalchemy import select
|
|
|
|
from steward.models.dashboard import DashboardShareToken
|
|
|
|
# Parameters a shared widget may change in place: they pick a time window,
|
|
# never which thing is shown.
|
|
VIEW_PARAMS = frozenset({"hours", "range"})
|
|
|
|
|
|
def share_allows(widget_urls: list[str], path: str, args) -> bool:
|
|
"""True when `path` + `args` (minus the token) is one of `widget_urls`,
|
|
allowing only VIEW_PARAMS to differ."""
|
|
got: dict[str, str] = {}
|
|
for key in args:
|
|
if key == "s":
|
|
continue
|
|
values = args.getlist(key)
|
|
if len(values) != 1:
|
|
return False
|
|
got[key] = values[0]
|
|
for url in widget_urls:
|
|
wpath, _, query = url.partition("?")
|
|
if wpath != path:
|
|
continue
|
|
want = {k: v for k, v in parse_qsl(query, keep_blank_values=True) if k not in VIEW_PARAMS}
|
|
if all(got.get(k) == v for k, v in want.items()) and set(got) <= set(want) | VIEW_PARAMS:
|
|
return True
|
|
return False
|
|
|
|
|
|
async def share_grants(db, raw_token: str, path: str, args) -> bool:
|
|
"""True when `raw_token` is a live share token whose dashboard loads this URL."""
|
|
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
|
share = (await db.execute(
|
|
select(DashboardShareToken).where(DashboardShareToken.token_hash == token_hash)
|
|
)).scalar_one_or_none()
|
|
if share is None:
|
|
return False
|
|
if share.expires_at is not None and share.expires_at <= datetime.now(timezone.utc):
|
|
return False
|
|
from .routes import _get_widgets, _resolve_widgets
|
|
widgets = _resolve_widgets(await _get_widgets(db, share.dashboard_id))
|
|
return share_allows([w["hx_url"] for w in widgets], path, args)
|