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:
+11
-17
@@ -137,6 +137,9 @@ def create_app(
|
||||
app.jinja_env.globals["csrf_token"] = _csrf.csrf_token
|
||||
app.jinja_env.globals["csrf_field"] = _csrf.csrf_field
|
||||
app.before_request(_csrf.check_request)
|
||||
# Default-deny: a view with neither require_role nor public_route is refused.
|
||||
from .auth.middleware import check_view_is_marked, public_route
|
||||
app.before_request(check_view_is_marked)
|
||||
|
||||
# ── 5. Plugin migrations ───────────────────────────────────────────────────
|
||||
# Step 3 already applied all plugin migrations via discover_all_plugin_migration_dirs,
|
||||
@@ -233,33 +236,24 @@ def create_app(
|
||||
# ── 11. Share-token middleware ─────────────────────────────────────────────
|
||||
@app.before_request
|
||||
async def _validate_share_token():
|
||||
"""If ?s=<token> is present, validate it and set g.share_viewer."""
|
||||
"""A share link's token (?s=) opens, read-only, exactly the widget
|
||||
fragments its own dashboard loads (steward/dashboard/share.py)."""
|
||||
from quart import request, g
|
||||
raw = request.args.get("s")
|
||||
if not raw:
|
||||
if not raw or request.method != "GET":
|
||||
return
|
||||
try:
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import select
|
||||
from .models.dashboard import DashboardShareToken
|
||||
token_hash = hashlib.sha256(raw.encode()).hexdigest()
|
||||
from .dashboard.share import share_grants
|
||||
async with app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(DashboardShareToken)
|
||||
.where(DashboardShareToken.token_hash == token_hash)
|
||||
)
|
||||
share = result.scalar_one_or_none()
|
||||
if share:
|
||||
now = datetime.now(timezone.utc)
|
||||
if share.expires_at is None or share.expires_at > now:
|
||||
if await share_grants(db, raw, request.path, request.args):
|
||||
g.share_viewer = True
|
||||
g.share_dashboard_id = share.dashboard_id
|
||||
except Exception:
|
||||
pass # never block a request due to token validation failure
|
||||
# A failed check grants nothing; the request falls through to login.
|
||||
logging.getLogger(__name__).exception("Share token check failed")
|
||||
|
||||
# ── 11. Health check ───────────────────────────────────────────────────────
|
||||
@app.get("/health")
|
||||
@public_route("liveness probe for the container")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user