From c74dada8b3ba4eed992816afb88304e0eea267f1 Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 15 Jun 2026 23:46:14 +0000 Subject: [PATCH] fix: treat enabled-but-passwordless portal as inactive (no dead form / self-lockout) --- backend/routers/portal.py | 17 +++++++++++++---- tests/test_portal_gate.py | 13 +++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/backend/routers/portal.py b/backend/routers/portal.py index 49a4732..980ca81 100644 --- a/backend/routers/portal.py +++ b/backend/routers/portal.py @@ -124,7 +124,10 @@ def portal_open(project_id: str, request: Request, db: Session = Depends(get_db) "portal/access_required.html", {"request": request, "reason": "required"}, status_code=404) project = db.query(Project).filter_by(id=project_id).first() - if not project: + if not project or not project.portal_password_hash: + # unknown token, disabled portal, or enabled-but-no-password-set — all look + # identical to a client (no existence/config leak, no self-lockout on a + # passwordless project). return templates.TemplateResponse( "portal/access_required.html", {"request": request, "reason": "invalid"}, status_code=404) @@ -155,7 +158,10 @@ def portal_password_prompt(link_token: str, request: Request, db: Session = Depe """Secure per-project link: resolve the project from the token, prompt for the shared password. Generic page if the token is unknown/disabled (no leak).""" project = resolve_project_by_link_token(link_token, db) - if not project: + if not project or not project.portal_password_hash: + # unknown token, disabled portal, or enabled-but-no-password-set — all look + # identical to a client (no existence/config leak, no self-lockout on a + # passwordless project). return templates.TemplateResponse( "portal/access_required.html", {"request": request, "reason": "invalid"}, status_code=404) @@ -169,7 +175,10 @@ def portal_password_submit(link_token: str, request: Request, password: str = Form(...), db: Session = Depends(get_db)): """Verify the shared password; on success mint a project-scoped session cookie.""" project = resolve_project_by_link_token(link_token, db) - if not project: + if not project or not project.portal_password_hash: + # unknown token, disabled portal, or enabled-but-no-password-set — all look + # identical to a client (no existence/config leak, no self-lockout on a + # passwordless project). return templates.TemplateResponse( "portal/access_required.html", {"request": request, "reason": "invalid"}, status_code=404) @@ -180,7 +189,7 @@ def portal_password_submit(link_token: str, request: Request, "request": request, "link_token": link_token, "project_name": project.name, "error": "Too many attempts. Try again in 15 minutes."}, status_code=200) - if not project.portal_password_hash or not verify_password(password, project.portal_password_hash): + if not verify_password(password, project.portal_password_hash): register_failure(lock_key) return templates.TemplateResponse("portal/password.html", { "request": request, "link_token": link_token, "project_name": project.name, diff --git a/tests/test_portal_gate.py b/tests/test_portal_gate.py index 3ebd054..a5de7db 100644 --- a/tests/test_portal_gate.py +++ b/tests/test_portal_gate.py @@ -45,3 +45,16 @@ def test_lockout_after_five_wrong(client, db_session): assert r.status_code == 200 assert "portal_session=" not in r.headers.get("set-cookie", "") assert "too many" in r.text.lower() + + +def test_enabled_without_password_is_not_accessible(client, db_session): + # enabled portal but no password set yet (operator enabled before generating one) + # must NOT show a usable form — looks like an invalid link, no self-lockout. + make_project(db_session, portal_enabled=True, portal_link_token="tok-nopw") + r = client.get("/portal/p/tok-nopw") + assert r.status_code == 404 + assert "isn't valid" in r.text.lower() + # and a POST can't succeed or set a cookie either + r2 = client.post("/portal/p/tok-nopw", data={"password": "anything"}, follow_redirects=False) + assert r2.status_code == 404 + assert "portal_session=" not in r2.headers.get("set-cookie", "")