Skip to content

Commit d3ba79f

Browse files
committed
Security hotfix v0.5.1: enforce must_change_password + 2FA temp token + API key gates
Three high-confidence auth gaps fixed after a focused review following a user report that admin/admin on the public demo returned a fully usable admin JWT. - get_current_user now enforces must_change_password server-side. Only /api/auth/me and /api/auth/change-password are allowed while the flag is set; every other endpoint returns 403. Previously the flag was only surfaced by the UI modal, which a direct API caller could ignore. - Pre-2FA 'temp_token' was broken: create_access_token silently ignored the 'exp_minutes' key and minted a full 24h session JWT, and no endpoint looked at the pending_2fa claim, so the temp token bypassed 2FA entirely. Fixed by adding an 'expires_minutes' parameter (now 2 minutes) and rejecting pending_2fa=True tokens everywhere except /api/auth/login itself. - API key auth now honors must_change_password (403 if the owning user is in forced-change state). Prevents working around the lockout by minting a key before changing the password. - /api/recordings/{id}/play HTML now sends Referrer-Policy: no-referrer and Cache-Control: private, no-store, and the asciinema-player CDN assets are fetched with referrerpolicy='no-referrer', so the ?token= in the URL no longer leaks to third parties. Verified end-to-end: admin/admin JWT now 403s on /api/auth/users and /api/servers but works on /api/auth/me; after POST /api/auth/change- password the same account receives a normal token that passes every check. Temp token from the pre-2FA step returns 403 'Pending 2FA' on every endpoint. Docs: README screenshot sub-sections renamed from release-pack labels to functional titles (jump host / snippets / shared terminal / session recording / demo mode).
1 parent 02c354d commit d3ba79f

6 files changed

Lines changed: 164 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
# Changelog
22

3+
## v0.5.1 (2026-04-16) — Security hotfix
4+
5+
### Security (please upgrade)
6+
7+
Three authentication gaps fixed after a focused security review.
8+
9+
- **`must_change_password` is now enforced server-side**. Previously the flag was only surfaced by the UI; a valid JWT from a `admin/admin` first-run login let attackers hit every admin endpoint (`/api/auth/users`, `/api/servers`, audit log, ...) without ever changing the password. `get_current_user` now returns `403 Password change required` for any path other than `/api/auth/me` and `/api/auth/change-password` while the flag is `True`. *(Credit: user report on the public demo.)*
10+
- **Pre-2FA "temp token" is now truly short-lived and scoped.** The `create_access_token({"pending_2fa": True, "exp_minutes": 2})` call ignored `exp_minutes` — the token got the full 24-hour session TTL, and no endpoint checked the `pending_2fa` claim, so the "temp" token bypassed 2FA completely. Fixed by: adding `expires_minutes` parameter to `create_access_token`, setting it to 2, and rejecting any token with `pending_2fa=True` on every endpoint except `/api/auth/login` itself.
11+
- **API keys cannot bypass forced password change.** An account with `must_change_password=True` can no longer create or use an API key until the password has been rotated. Prevents workaround paths when an admin issues a temporary password.
12+
- **Recording replay page no longer leaks the session token via Referer.** `GET /api/recordings/{id}/play` now sends `Referrer-Policy: no-referrer` and `Cache-Control: private, no-store`, and all third-party assets on the page (asciinema-player CDN) are fetched with `referrerpolicy="no-referrer"`.
13+
14+
### Docs
15+
16+
- README: screenshot sub-sections renamed to functional titles (jump host / snippets / shared terminal / session recording / demo mode) instead of release-pack labels, so the README always describes what the current version ships.
17+
18+
---
19+
320
## v0.5.0 (2026-04-15)
421

522
### Features

README.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -155,25 +155,36 @@ flowchart TB
155155
| ![Access Control](docs/screenshots/access-control.png) | ![Users](docs/screenshots/users.png) |
156156
| ![Audit](docs/screenshots/audit.png) | ![Light Theme](docs/screenshots/light-theme.png) |
157157
158-
### Operations Pack — jump host, snippets, webhooks
158+
### Jump host, snippets, webhooks
159159
160160
| | |
161161
|---|---|
162-
| ![Demo banner](docs/screenshots/v0.3/01-login-demo-banner.png) | ![Dashboard with jump host](docs/screenshots/v0.3/02-dashboard-jump-host.png) |
162+
| ![Dashboard with jump host](docs/screenshots/v0.3/02-dashboard-jump-host.png) | ![Add Server with Jump Via](docs/screenshots/v0.3/08-add-server-jump-via.png) |
163163
| ![Terminal snippets](docs/screenshots/v0.3/03-terminal-snippets-jump.png) | ![Snippet executed](docs/screenshots/v0.3/04-snippet-executed.png) |
164-
| ![SFTP via jump](docs/screenshots/v0.3/05-sftp-via-jump.png) | ![Add Server with Jump Via](docs/screenshots/v0.3/08-add-server-jump-via.png) |
165-
| ![Webhooks modal](docs/screenshots/v0.3/06-webhooks-modal.png) | |
164+
| ![SFTP via jump](docs/screenshots/v0.3/05-sftp-via-jump.png) | ![Webhooks modal](docs/screenshots/v0.3/06-webhooks-modal.png) |
166165
167-
### Shared terminal & session recording
166+
### Shared terminal sessions
168167
169-
| Owner sees | Joiner sees |
168+
Click **🔗 Share** in the terminal toolbar to get a URL; anyone who opens it joins the same live SSH session (broadcast output, multiplexed input).
169+
170+
| Owner | Joiner |
170171
|---|---|
171172
| ![Shared owner](docs/screenshots/v0.4/01-shared-terminal-owner.png) | ![Shared joiner](docs/screenshots/v0.4/02-shared-terminal-joiner.png) |
172173
174+
### Session recording
175+
176+
Enable `WEBGATE_RECORD_SESSIONS=true` and every SSH session is captured to an asciinema cast file with built-in browser replay.
177+
173178
| Recordings list | Browser replay |
174179
|---|---|
175180
| ![Recordings](docs/screenshots/v0.4/04-recordings-modal.png) | ![Replay](docs/screenshots/v0.4/03-recording-replay.png) |
176181
182+
### Public read-only demo mode
183+
184+
`WEBGATE_DEMO_MODE=true` turns the app into a sandbox: banner, seeded `demo`/`demo` user, all writes blocked. Used by the live demo at [webgate-demo.fly.dev](https://webgate-demo.fly.dev/).
185+
186+
![Demo banner](docs/screenshots/v0.3/01-login-demo-banner.png)
187+
177188
---
178189
179190
## Architecture
@@ -551,7 +562,7 @@ curl -s http://localhost:8443/api/health # shows instance_id + monitor_role
551562

552563
[`compose.ha.yml`](compose.ha.yml) spins up 2 webgate replicas + Postgres + nginx with `ip_hash` sticky sessions. On leader loss, the lease expires within 90 seconds and another replica picks it up automatically.
553564

554-
> **Known limitation**: live shared-terminal sessions still need owner and joiner on the same worker. Sticky sessions mitigate it for same-browser joins; true cross-worker fan-out needs Redis pub/sub — planned for a later v0.5.x.
565+
> **Known limitation**: live shared-terminal sessions still need owner and joiner on the same worker. Sticky sessions mitigate it for same-browser joins; true cross-worker fan-out requires a Redis pub/sub layer (not yet implemented).
555566
556567
### Public read-only demo (Fly.io)
557568

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "webgate"
3-
version = "0.5.0"
3+
version = "0.5.1"
44
description = "Self-hosted web application for remote server management via SSH terminal and SFTP file browser"
55
readme = "README.md"
66
license = "MIT"

src/webgate/auth/routes.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,25 @@
5656
AuthDep = Annotated[HTTPAuthorizationCredentials, Depends(security)]
5757

5858

59-
async def get_current_user(credentials: AuthDep, session: SessionDep) -> UserOut:
59+
async def get_current_user(
60+
request: Request, credentials: AuthDep, session: SessionDep
61+
) -> UserOut:
62+
"""Resolve the user from JWT or API key and enforce account-level gates
63+
(pending 2FA, forced password change). Only a short allowlist of endpoints
64+
can be hit while a user is in one of those states."""
6065
token = credentials.credentials
6166

6267
# Check if it's an API key (starts with "wg_")
6368
if token.startswith("wg_"):
6469
user = await authenticate_api_key(session, token)
6570
if not user:
6671
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
72+
if user.must_change_password:
73+
# API keys cannot bypass a forced password change.
74+
raise HTTPException(
75+
status_code=status.HTTP_403_FORBIDDEN,
76+
detail="Password change required before using API keys",
77+
)
6778
return UserOut.model_validate(user)
6879

6980
# Otherwise treat as JWT
@@ -76,6 +87,23 @@ async def get_current_user(credentials: AuthDep, session: SessionDep) -> UserOut
7687
user = await get_user_by_id(session, int(user_id))
7788
if user is None:
7889
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
90+
91+
path = request.url.path
92+
# Pre-2FA temp token: only /api/auth/login is allowed (for the code step).
93+
if payload.get("pending_2fa"):
94+
if path != "/api/auth/login":
95+
raise HTTPException(
96+
status_code=status.HTTP_403_FORBIDDEN,
97+
detail="Pending 2FA: submit totp_code via /api/auth/login to obtain a session token",
98+
)
99+
# Forced password change: only /api/auth/me and /api/auth/change-password are allowed.
100+
if user.must_change_password and path not in {
101+
"/api/auth/me", "/api/auth/change-password",
102+
}:
103+
raise HTTPException(
104+
status_code=status.HTTP_403_FORBIDDEN,
105+
detail="Password change required",
106+
)
79107
return UserOut.model_validate(user)
80108

81109

@@ -120,9 +148,11 @@ async def login(request: Request, body: UserLogin, session: SessionDep) -> Login
120148
# Check if 2FA is enabled
121149
if user.totp_enabled and user.totp_secret:
122150
if not body.totp_code:
123-
# Issue a short-lived temp token for 2FA verification
151+
# Short-lived pre-2FA token: only accepted by /api/auth/login
152+
# itself (gated in get_current_user) and expires in 2 minutes.
124153
temp_token = create_access_token(
125-
{"sub": str(user.id), "pending_2fa": True, "exp_minutes": 2}
154+
{"sub": str(user.id), "pending_2fa": True},
155+
expires_minutes=2,
126156
)
127157
return LoginOut(requires_2fa=True, temp_token=temp_token)
128158
# Verify the TOTP code

src/webgate/auth/service.py

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import logging
5+
import secrets
56
from datetime import UTC, datetime, timedelta
67
from typing import Any
78

@@ -10,7 +11,7 @@
1011
from sqlalchemy import func, select
1112
from sqlalchemy.ext.asyncio import AsyncSession
1213

13-
from webgate.auth.models import User
14+
from webgate.auth.models import ApiKey, User
1415
from webgate.config import settings
1516

1617
logger = logging.getLogger(__name__)
@@ -24,9 +25,12 @@ def verify_password(plain: str, hashed: str) -> bool:
2425
return bcrypt.checkpw(plain.encode(), hashed.encode())
2526

2627

27-
def create_access_token(data: dict[str, Any]) -> str:
28+
def create_access_token(data: dict[str, Any], expires_minutes: int | None = None) -> str:
29+
"""Mint a signed JWT. `expires_minutes` overrides the default session TTL
30+
for short-lived tokens (e.g. the 2-minute pre-2FA token)."""
2831
to_encode = data.copy()
29-
expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_expire_minutes)
32+
minutes = expires_minutes if expires_minutes is not None else settings.jwt_expire_minutes
33+
expire = datetime.now(UTC) + timedelta(minutes=minutes)
3034
to_encode["exp"] = expire
3135
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm)
3236

@@ -113,3 +117,81 @@ async def update_user_password(
113117
async def delete_user(session: AsyncSession, user: User) -> None:
114118
await session.delete(user)
115119
await session.commit()
120+
121+
122+
def generate_api_key() -> str:
123+
"""Generate a random API key like 'wg_xxxxxxxxxxxxxxxxxxxxxxxxxxxx'."""
124+
return "wg_" + secrets.token_hex(24)
125+
126+
127+
async def create_api_key(session: AsyncSession, user_id: int, name: str) -> tuple[ApiKey, str]:
128+
"""Create an API key. Returns (model, plaintext_key)."""
129+
key = generate_api_key()
130+
key_obj = ApiKey(
131+
user_id=user_id,
132+
name=name,
133+
key_hash=hash_password(key),
134+
key_prefix=key[:10],
135+
)
136+
session.add(key_obj)
137+
await session.commit()
138+
await session.refresh(key_obj)
139+
return key_obj, key
140+
141+
142+
async def get_api_keys(session: AsyncSession, user_id: int) -> list[ApiKey]:
143+
"""List all API keys for a user."""
144+
result = await session.execute(
145+
select(ApiKey).where(ApiKey.user_id == user_id).order_by(ApiKey.created_at.desc())
146+
)
147+
return list(result.scalars().all())
148+
149+
150+
async def delete_api_key(session: AsyncSession, key_id: int, user_id: int) -> bool:
151+
"""Delete an API key. Returns True if found and deleted."""
152+
result = await session.execute(
153+
select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user_id)
154+
)
155+
key_obj = result.scalar_one_or_none()
156+
if not key_obj:
157+
return False
158+
await session.delete(key_obj)
159+
await session.commit()
160+
return True
161+
162+
163+
async def authenticate_api_key(session: AsyncSession, key: str) -> User | None:
164+
"""Look up API key by prefix, then verify hash. Update last_used_at."""
165+
prefix = key[:10]
166+
result = await session.execute(
167+
select(ApiKey).where(ApiKey.key_prefix == prefix)
168+
)
169+
for api_key in result.scalars().all():
170+
if verify_password(key, api_key.key_hash):
171+
api_key.last_used_at = datetime.now(UTC)
172+
user_result = await session.execute(
173+
select(User).where(User.id == api_key.user_id)
174+
)
175+
user = user_result.scalar_one_or_none()
176+
await session.commit()
177+
return user
178+
return None
179+
180+
181+
def generate_totp_secret() -> str:
182+
import pyotp
183+
184+
return pyotp.random_base32()
185+
186+
187+
def get_totp_uri(secret: str, username: str) -> str:
188+
import pyotp
189+
190+
return pyotp.totp.TOTP(secret).provisioning_uri(name=username, issuer_name="webgate")
191+
192+
193+
def verify_totp(secret: str, code: str) -> bool:
194+
import pyotp
195+
196+
totp = pyotp.TOTP(secret)
197+
return totp.verify(code, valid_window=1)

src/webgate/recordings/routes.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,15 @@ async def play_recording(
8181
# Embed asciinema-player from CDN. The cast file is fetched via the
8282
# download endpoint so JWT auth is honored.
8383
started = rec.started_at.isoformat()
84+
# Referrer-Policy no-referrer prevents leaking the ?token= to any
85+
# external asset (CDN, browser history peeks, etc.). The meta refresh
86+
# is a belt-and-braces for older browsers.
8487
html = f"""<!doctype html>
8588
<html><head>
8689
<meta charset="utf-8">
90+
<meta name="referrer" content="no-referrer">
8791
<title>webgate replay #{rec.id}{rec.server_name}</title>
88-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/asciinema-player@3.7.0/dist/bundle/asciinema-player.css">
92+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/asciinema-player@3.7.0/dist/bundle/asciinema-player.css" referrerpolicy="no-referrer">
8993
<style>
9094
body {{ background:#1a1b26; color:#c0caf5; font-family:system-ui,sans-serif; margin:0; padding:20px; }}
9195
.meta {{ display:flex; gap:24px; font-size:13px; margin-bottom:16px; color:#9ba3c5; }}
@@ -101,13 +105,16 @@ async def play_recording(
101105
<span>💾 {rec.size_bytes:,} bytes</span>
102106
</div>
103107
<div id=\"player\"></div>
104-
<script src=\"https://cdn.jsdelivr.net/npm/asciinema-player@3.7.0/dist/bundle/asciinema-player.min.js\"></script>
108+
<script src=\"https://cdn.jsdelivr.net/npm/asciinema-player@3.7.0/dist/bundle/asciinema-player.min.js\" referrerpolicy="no-referrer"></script>
105109
<script>
106110
AsciinemaPlayer.create('cast?token={token}', document.getElementById('player'),
107111
{{ idleTimeLimit: 2, theme: 'tango', fit: 'width' }});
108112
</script>
109113
</body></html>"""
110-
return HTMLResponse(content=html)
114+
return HTMLResponse(
115+
content=html,
116+
headers={"Referrer-Policy": "no-referrer", "Cache-Control": "private, no-store"},
117+
)
111118

112119

113120
@router.get("/{recording_id}/cast")

0 commit comments

Comments
 (0)