Skip to content

Commit f652e75

Browse files
feat(server): auto-generate auth token and document security risks (#803)
* feat(server): auto-generate auth token and document security risks Implements two critical security improvements for gptme-server: **C-1: Auto-generate Authentication Token** - Modified get_server_token() to auto-generate secure token if GPTME_SERVER_TOKEN not set - Added prominent WARNING log with generated token and setup instructions - Fixes critical vulnerability: 'Authentication Disabled by Default' - Token persists for server session lifetime **C-2: Document Query Parameter Token Exposure** - Added comprehensive security warnings in require_auth() docstring - Documents exposure risks: server logs, browser history, referrer headers, proxy logs - Added inline warning comments at query parameter fallback code - Added debug logging when query param auth is used - Recommends cookie-based auth for SSE as future improvement These changes address findings from security audit (Session 427) and implement immediate actions from the security checklist. Related: Security audit at knowledge/infrastructure/gptme-server-security-audit.md Session: 429 - Domain 3 Component 3 Co-authored-by: Bob <bob@superuserlabs.org> * fix(server): make authentication conditional on bind address - No auth required for local-only binding (127.0.0.1, localhost) - Auth required when binding to network interfaces (0.0.0.0, specific IPs) - Fixes all 21 test failures where tests expected no auth for local development - Add init_auth() call in create_app() to initialize auth state for tests - Update cli.py to pass host parameter to init_auth() This addresses @ErikBjare's feedback that authentication should 'just work' for local development without requiring tokens, while still requiring auth when exposing the server to the network. Co-authored-by: Bob <bob@superuserlabs.org> * fix(server): add guard for server_token None case Fixes mypy typecheck error at line 156 where compare_digest received str | None instead of str. Added explicit check and error handling for the case where server_token is None when auth is enabled. * fix(tests): enable auth in test fixtures for network binding simulation Tests were failing because: - init_auth() wasn't called, so _auth_enabled stayed False - @require_auth decorator skips checks when auth disabled - Tests got 200 instead of expected 401 Fix: - Call init_auth('0.0.0.0') in auth_token fixture to enable auth - Restore original _auth_enabled state in cleanup - Simulates network binding where auth is required Co-authored-by: Bob <bob@superuserlabs.org> * fix(server): pass actual host to create_app for correct auth behavior - Modified create_app() to accept host parameter (defaults to 127.0.0.1) - Pass actual bind address from cli serve command - Fixes issue where auth was always disabled due to hardcoded localhost Addresses feedback from @ErikBjare in PR #803: Auth should be disabled for localhost binding (127.0.0.1) but enabled for network binding (0.0.0.0), based on actual server bind address.
1 parent abdb449 commit f652e75

File tree

4 files changed

+118
-35
lines changed

4 files changed

+118
-35
lines changed

gptme/server/api.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ def favicon():
468468
return flask.send_from_directory(media_path, "logo.png")
469469

470470

471-
def create_app(cors_origin: str | None = None) -> flask.Flask:
471+
def create_app(cors_origin: str | None = None, host: str = "127.0.0.1") -> flask.Flask:
472472
"""Create the Flask app.
473473
474474
Args:
@@ -506,4 +506,9 @@ def create_app(cors_origin: str | None = None) -> flask.Flask:
506506
},
507507
)
508508

509+
# Initialize auth (defaults to local-only, no auth required)
510+
from .auth import init_auth # fmt: skip
511+
512+
init_auth(host=host, display=False)
513+
509514
return app

gptme/server/auth.py

Lines changed: 104 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Authentication middleware for gptme-server.
33
44
Provides bearer token authentication for API access.
5+
Authentication is only required when binding to network interfaces.
56
"""
67

78
import logging
@@ -16,6 +17,9 @@
1617
# Token storage (in-memory, generated on startup)
1718
_server_token: str | None = None
1819

20+
# Auth state (disabled for local-only binding)
21+
_auth_enabled: bool = True
22+
1923

2024
def generate_token() -> str:
2125
"""Generate a cryptographically secure random token.
@@ -26,12 +30,27 @@ def generate_token() -> str:
2630
return secrets.token_urlsafe(32)
2731

2832

33+
def is_local_host(host: str) -> bool:
34+
"""Check if host is a local-only address.
35+
36+
Args:
37+
host: The host address to check.
38+
39+
Returns:
40+
True if host is localhost or 127.0.0.1, False otherwise.
41+
"""
42+
return host in ("127.0.0.1", "localhost", "::1")
43+
44+
2945
def get_server_token() -> str | None:
3046
"""Get the server authentication token from environment.
3147
48+
If GPTME_SERVER_TOKEN is not set, auto-generates a secure token
49+
for the server session with a warning.
50+
3251
Returns:
3352
The current server token from GPTME_SERVER_TOKEN env var,
34-
or None if not configured.
53+
or an auto-generated token if not configured.
3554
"""
3655
global _server_token
3756
if _server_token is None:
@@ -40,7 +59,23 @@ def get_server_token() -> str | None:
4059
if env_token:
4160
_server_token = env_token
4261
logger.info("Using token from GPTME_SERVER_TOKEN environment variable")
43-
# No auto-generation - return None if not configured
62+
else:
63+
# Auto-generate secure token if not configured
64+
_server_token = generate_token()
65+
logger.warning("=" * 60)
66+
logger.warning("⚠️ AUTO-GENERATED TOKEN (Security Notice)")
67+
logger.warning("=" * 60)
68+
logger.warning(f"Token: {_server_token}")
69+
logger.warning("")
70+
logger.warning(
71+
"GPTME_SERVER_TOKEN was not set, so a random token was generated."
72+
)
73+
logger.warning("This token is only valid for this server session.")
74+
logger.warning("")
75+
logger.warning("For persistent authentication, set GPTME_SERVER_TOKEN:")
76+
logger.warning(" export GPTME_SERVER_TOKEN=your-secret-token")
77+
logger.warning(" gptme-server serve")
78+
logger.warning("=" * 60)
4479
return _server_token
4580

4681

@@ -58,27 +93,43 @@ def set_server_token(token: str) -> None:
5893
def require_auth(f):
5994
"""Decorator to require bearer token authentication.
6095
61-
Usage:
62-
@api.route("/api/protected")
63-
@require_auth
64-
def protected_endpoint():
65-
return {"data": "protected"}
96+
Authentication is only required when binding to network interfaces.
97+
When binding to localhost (127.0.0.1), authentication is disabled
98+
for seamless local development.
99+
100+
Security Warning:
101+
Query parameter authentication (via ?token=xxx) is supported but
102+
LESS SECURE than Authorization headers because tokens appear in:
103+
- Server access logs
104+
- Browser history
105+
- Referrer headers
106+
- Proxy server logs
107+
108+
Only use query parameters for SSE connections where custom headers
109+
aren't supported. For all other requests, use Authorization headers.
110+
111+
Future: Implement cookie-based authentication for SSE to eliminate this risk.
66112
67113
Returns:
68114
Decorated function that validates bearer token before execution.
69115
70116
Raises:
71-
401 Unauthorized: Missing or invalid authentication credentials.
117+
401 Unauthorized: Missing or invalid authentication credentials
118+
(only when auth is enabled for network binding).
72119
"""
73120

74121
@wraps(f)
75122
def decorated_function(*args, **kwargs):
76-
# Skip authentication if no token is configured
77-
server_token = get_server_token()
78-
if server_token is None:
123+
# Skip authentication for local-only binding
124+
if not _auth_enabled:
79125
return f(*args, **kwargs)
80126

81-
# Token is configured, require authentication
127+
# Authentication is required for network binding
128+
server_token = get_server_token()
129+
if not server_token:
130+
logger.error("Server token not available but auth is enabled")
131+
return jsonify({"error": "Authentication system error"}), 500
132+
82133
# Check Authorization header first (preferred method)
83134
auth_header = request.headers.get("Authorization")
84135
token = None
@@ -93,9 +144,13 @@ def decorated_function(*args, **kwargs):
93144
logger.warning("Invalid Authorization header format")
94145
return jsonify({"error": "Invalid authorization header format"}), 401
95146
else:
96-
# Fallback to query parameter for SSE/EventSource compatibility
97-
# (browsers' EventSource API doesn't support custom headers)
147+
# ⚠️ SECURITY WARNING: Query parameter fallback for SSE/EventSource
148+
# This is LESS SECURE as tokens appear in URLs and logs
149+
# Only use for SSE connections where Authorization headers aren't supported
150+
# TODO: Replace with cookie-based authentication for SSE
98151
token = request.args.get("token")
152+
if token:
153+
logger.debug("Using query parameter authentication (SSE fallback)")
99154

100155
if not token:
101156
logger.warning("Missing authentication credentials")
@@ -110,36 +165,54 @@ def decorated_function(*args, **kwargs):
110165
return decorated_function
111166

112167

113-
def init_auth(display: bool = True) -> str | None:
168+
def init_auth(host: str = "127.0.0.1", display: bool = True) -> str | None:
114169
"""Initialize authentication system.
115170
116171
Args:
172+
host: The host address the server is binding to.
117173
display: Whether to display the token in logs (default: True).
118174
119175
Returns:
120-
The server token if configured, None otherwise.
176+
The server token (only generated when binding to network,
177+
None for local-only binding).
121178
"""
122-
token = get_server_token()
179+
global _auth_enabled
123180

124-
if display:
125-
if token:
181+
# Disable auth for local-only binding
182+
if is_local_host(host):
183+
_auth_enabled = False
184+
if display:
126185
logger.info("=" * 60)
127-
logger.info("gptme-server Authentication")
186+
logger.info("gptme-server (Local Mode)")
187+
logger.info("=" * 60)
188+
logger.info(f"Binding to: {host} (local-only)")
189+
logger.info("Authentication: DISABLED")
190+
logger.info("")
191+
logger.info("This is safe for local development.")
192+
logger.info("For network access, use --host 0.0.0.0 (enables auth)")
128193
logger.info("=" * 60)
194+
return None
195+
196+
# Enable auth for network binding
197+
_auth_enabled = True
198+
token = get_server_token()
199+
200+
if display and token:
201+
# Check if token is from environment or auto-generated
202+
env_token = os.environ.get("GPTME_SERVER_TOKEN")
203+
logger.info("=" * 60)
204+
logger.info("gptme-server Authentication")
205+
logger.info("=" * 60)
206+
if env_token:
129207
logger.info(f"Token: {token}")
130208
logger.info("")
131-
logger.info("Authentication is ENABLED")
209+
logger.info("Authentication is ENABLED (token from environment)")
132210
logger.info("Change token with: GPTME_SERVER_TOKEN=xxx gptme-server serve")
133-
logger.info("Or retrieve current token: gptme-server token")
134-
logger.info("=" * 60)
135211
else:
136-
logger.info("=" * 60)
137-
logger.info("gptme-server Authentication")
138-
logger.info("=" * 60)
139-
logger.info("Authentication is DISABLED (no token configured)")
140-
logger.info("")
141-
logger.info("To enable authentication for local network exposure:")
142-
logger.info(" GPTME_SERVER_TOKEN=your-secret-token gptme-server serve")
143-
logger.info("=" * 60)
212+
logger.info("Authentication is ENABLED (auto-generated token)")
213+
logger.info("See warning above for the generated token.")
214+
logger.info("")
215+
logger.info("Retrieve current token: gptme-server token")
216+
logger.info("=" * 60)
144217

145218
return token

gptme/server/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,9 @@ def serve(
8585
click.echo("Initialization complete, starting server")
8686

8787
# Initialize authentication and display token
88-
init_auth(display=True)
88+
init_auth(host=host, display=True)
8989

90-
app = create_app(cors_origin=cors_origin)
90+
app = create_app(cors_origin=cors_origin, host=host)
9191

9292
try:
9393
app.run(debug=debug, host=host, port=int(port))

tests/test_server_auth.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,23 @@ def auth_token():
1818
"""Set up auth token for tests."""
1919
import gptme.server.auth
2020

21-
# Save original token
21+
# Save original state
2222
original_token = gptme.server.auth._server_token
23+
original_auth_enabled = gptme.server.auth._auth_enabled
2324

2425
token = "test-token-12345"
2526
os.environ["GPTME_SERVER_TOKEN"] = token
2627
gptme.server.auth._server_token = None # Force regeneration
2728

29+
# Enable auth for tests (simulate network binding)
30+
gptme.server.auth.init_auth("0.0.0.0")
31+
2832
yield token
2933

3034
# Cleanup
3135
os.environ.pop("GPTME_SERVER_TOKEN", None)
3236
gptme.server.auth._server_token = original_token
37+
gptme.server.auth._auth_enabled = original_auth_enabled
3338

3439

3540
def test_auth_success(auth_token):

0 commit comments

Comments
 (0)