From 20f62a5c0a03011edbc90503f2a2bf73d91bfd75 Mon Sep 17 00:00:00 2001 From: serversdown Date: Tue, 16 Jun 2026 00:16:54 +0000 Subject: [PATCH] feat: env-driven Secure flag on portal session cookie Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 4 ++-- backend/portal_auth.py | 3 +++ backend/routers/portal.py | 4 ++-- tests/test_cookie_secure.py | 16 ++++++++++++++++ 4 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 tests/test_cookie_secure.py diff --git a/backend/main.py b/backend/main.py index d48dad7..ebf1cbe 100644 --- a/backend/main.py +++ b/backend/main.py @@ -417,14 +417,14 @@ async def project_detail_page(request: Request, project_id: str): async def project_portal_preview(project_id: str, db: Session = Depends(get_db)): """Operator testing shortcut: open this project's client portal (no CLI).""" from backend.models import Project - from backend.portal_auth import mint_portal_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE + from backend.portal_auth import mint_portal_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE, COOKIE_SECURE project = db.query(Project).filter_by(id=project_id).first() if not project: return JSONResponse(status_code=404, content={"detail": "Project not found"}) token_id = mint_portal_session(project, db) resp = RedirectResponse(url="/portal", status_code=303) resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id), - max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax") + max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE) return resp diff --git a/backend/portal_auth.py b/backend/portal_auth.py index 2caf15a..dfabdf1 100644 --- a/backend/portal_auth.py +++ b/backend/portal_auth.py @@ -39,6 +39,9 @@ if SECRET_KEY == "dev-insecure-change-me": COOKIE_NAME = "portal_session" COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days +# Set COOKIE_SECURE=true once the portal is served over HTTPS (TLS terminates at +# the Synology reverse proxy). Default false so plain-HTTP dev still works. +COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes") class PortalAuthError(Exception): diff --git a/backend/routers/portal.py b/backend/routers/portal.py index b7ae6b2..8217d44 100644 --- a/backend/routers/portal.py +++ b/backend/routers/portal.py @@ -25,7 +25,7 @@ from backend.models import Client, MonitoringLocation, Project, UnitAssignment from backend.templates_config import templates from backend.portal_auth import ( get_current_client, client_from_cookie, make_session_cookie, - COOKIE_NAME, COOKIE_MAX_AGE, + COOKIE_NAME, COOKIE_MAX_AGE, COOKIE_SECURE, resolve_project_by_link_token, mint_portal_session, is_locked, register_failure, clear_failures, ) @@ -156,7 +156,7 @@ def portal_password_submit(link_token: str, request: Request, token_id = mint_portal_session(project, db) resp = RedirectResponse(url="/portal", status_code=303) resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id), - max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax") + max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE) logger.info(f"[PORTAL] password ok for project {project.id[:8]} → session opened") return resp diff --git a/tests/test_cookie_secure.py b/tests/test_cookie_secure.py new file mode 100644 index 0000000..d4e72ed --- /dev/null +++ b/tests/test_cookie_secure.py @@ -0,0 +1,16 @@ +import importlib +from tests.conftest import make_project +from backend.auth_passwords import hash_password + + +def test_cookie_secure_flag_is_applied(monkeypatch, client, db_session): + import backend.portal_auth as pa + monkeypatch.setattr(pa, "COOKIE_SECURE", True, raising=False) + # also patch the name imported into the router module + import backend.routers.portal as pr + monkeypatch.setattr(pr, "COOKIE_SECURE", True, raising=False) + + make_project(db_session, portal_enabled=True, portal_link_token="ts", + portal_password_hash=hash_password("pw")) + r = client.post("/portal/p/ts", data={"password": "pw"}, follow_redirects=False) + assert "secure" in r.headers.get("set-cookie", "").lower()