fix: security and reliability improvements

- Switch HTTPServer to ThreadingHTTPServer (concurrent request handling)
- Replace SHA-256 password hashing with bcrypt (auth.py, database.py)
- Add bcrypt to Dockerfile
- Move qBittorrent env vars to config.py
- Move _booklore_token state out of config into booklore.py
- Remove dead fitness_token variable in command.py
- Fix OpenAI call to use default SSL context instead of no-verify ctx
- Log swallowed budget fetch error in dashboard.py
This commit is contained in:
Yusuf Suleman
2026-03-29 07:02:09 -05:00
parent 7cd81181ed
commit d9768547be
9 changed files with 39 additions and 31 deletions

View File

@@ -1,5 +1,6 @@
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN pip install bcrypt
COPY server.py config.py database.py sessions.py proxy.py responses.py auth.py dashboard.py command.py ./ COPY server.py config.py database.py sessions.py proxy.py responses.py auth.py dashboard.py command.py ./
COPY integrations/ ./integrations/ COPY integrations/ ./integrations/
EXPOSE 8100 EXPOSE 8100

View File

@@ -2,10 +2,16 @@
Platform Gateway — Auth handlers (login, logout, register). Platform Gateway — Auth handlers (login, logout, register).
""" """
"""
NOTE: Passwords are hashed with bcrypt. Any existing SHA-256 hashed passwords
in the database will no longer work. The admin user is re-seeded on first boot
if no users exist. Other users need manual password reset.
"""
import json import json
import hashlib
import sqlite3 import sqlite3
import bcrypt
from database import get_db from database import get_db
from sessions import create_session, delete_session from sessions import create_session, delete_session
@@ -24,14 +30,12 @@ def handle_login(handler, body):
handler._send_json({"error": "Username and password required"}, 400) handler._send_json({"error": "Username and password required"}, 400)
return return
pw_hash = hashlib.sha256(password.encode()).hexdigest()
conn = get_db() conn = get_db()
user = conn.execute("SELECT * FROM users WHERE username = ? AND password_hash = ?", user = conn.execute("SELECT * FROM users WHERE username = ?",
(username, pw_hash)).fetchone() (username,)).fetchone()
conn.close() conn.close()
if not user: if not user or not bcrypt.checkpw(password.encode(), user["password_hash"].encode()):
handler._send_json({"error": "Invalid credentials"}, 401) handler._send_json({"error": "Invalid credentials"}, 401)
return return
@@ -76,7 +80,7 @@ def handle_register(handler, body):
handler._send_json({"error": "Username and password required"}, 400) handler._send_json({"error": "Username and password required"}, 400)
return return
pw_hash = hashlib.sha256(password.encode()).hexdigest() pw_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
conn = get_db() conn = get_db()
try: try:

View File

@@ -6,9 +6,7 @@ import json
import urllib.request import urllib.request
from datetime import datetime from datetime import datetime
from config import ( from config import OPENAI_API_KEY, OPENAI_MODEL, TRIPS_URL
OPENAI_API_KEY, OPENAI_MODEL, TRIPS_URL, _ssl_ctx,
)
from sessions import get_service_token from sessions import get_service_token
import proxy as _proxy_module import proxy as _proxy_module
from proxy import proxy_request from proxy import proxy_request
@@ -33,7 +31,6 @@ def handle_command(handler, user, body):
# Get context: user's trips list and today's date # Get context: user's trips list and today's date
trips_token = get_service_token(user["id"], "trips") trips_token = get_service_token(user["id"], "trips")
fitness_token = get_service_token(user["id"], "fitness")
trips_context = "" trips_context = ""
if trips_token: if trips_token:
@@ -116,7 +113,7 @@ Guidelines:
method="POST" method="POST"
) )
with urllib.request.urlopen(req, context=_ssl_ctx, timeout=30) as resp: with urllib.request.urlopen(req, timeout=30) as resp:
ai_result = json.loads(resp.read().decode()) ai_result = json.loads(resp.read().decode())
ai_text = ai_result["choices"][0]["message"]["content"] ai_text = ai_result["choices"][0]["message"]["content"]

View File

@@ -42,6 +42,12 @@ KINDLE_LABELS = os.environ.get("KINDLE_LABELS", "Kindle 1,Kindle 2")
KARAKEEP_URL = os.environ.get("KARAKEEP_URL", "http://192.168.1.42:3005") KARAKEEP_URL = os.environ.get("KARAKEEP_URL", "http://192.168.1.42:3005")
KARAKEEP_API_KEY = os.environ.get("KARAKEEP_API_KEY", "") KARAKEEP_API_KEY = os.environ.get("KARAKEEP_API_KEY", "")
# ── qBittorrent ──
QBITTORRENT_HOST = os.environ.get("QBITTORRENT_HOST", "192.168.1.42")
QBITTORRENT_PORT = os.environ.get("QBITTORRENT_PORT", "8080")
QBITTORRENT_USERNAME = os.environ.get("QBITTORRENT_USERNAME", "admin")
QBITTORRENT_PASSWORD = os.environ.get("QBITTORRENT_PASSWORD", "")
# ── AI ── # ── AI ──
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.2") OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.2")
@@ -49,9 +55,6 @@ OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.2")
# ── Session config ── # ── Session config ──
SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", 30 * 86400)) # 30 days SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", 30 * 86400)) # 30 days
# ── Shared state ──
_booklore_token = {"access": "", "refresh": "", "expires": 0}
# ── Ensure data dir exists ── # ── Ensure data dir exists ──
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)

View File

@@ -222,7 +222,7 @@ def handle_dashboard(handler, user):
data = json.loads(b) data = json.loads(b)
return {"count": data.get("transactionCount", 0), "totalBalance": data.get("totalBalanceDollars", 0), "spending": data.get("spendingDollars", 0), "income": data.get("incomeDollars", 0), "topCategories": data.get("topCategories", [])[:5], "month": data.get("month", "")} return {"count": data.get("transactionCount", 0), "totalBalance": data.get("totalBalanceDollars", 0), "spending": data.get("spendingDollars", 0), "income": data.get("incomeDollars", 0), "topCategories": data.get("topCategories", [])[:5], "month": data.get("month", "")}
except Exception as e: except Exception as e:
pass print(f"[Dashboard] Budget fetch error: {e}")
return None return None
def fetch_reader(app): def fetch_reader(app):

View File

@@ -2,9 +2,10 @@
Platform Gateway — Database initialization and access. Platform Gateway — Database initialization and access.
""" """
import hashlib
import sqlite3 import sqlite3
import bcrypt
from config import ( from config import (
DB_PATH, TRIPS_URL, FITNESS_URL, INVENTORY_URL, DB_PATH, TRIPS_URL, FITNESS_URL, INVENTORY_URL,
MINIFLUX_URL, SHELFMARK_URL, SPOTIZERR_URL, BUDGET_URL, MINIFLUX_URL, SHELFMARK_URL, SPOTIZERR_URL, BUDGET_URL,
@@ -124,7 +125,7 @@ def init_db():
# Seed default admin user if empty # Seed default admin user if empty
user_count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0] user_count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if user_count == 0: if user_count == 0:
pw_hash = hashlib.sha256("admin".encode()).hexdigest() pw_hash = bcrypt.hashpw("admin".encode(), bcrypt.gensalt()).decode()
c.execute("INSERT INTO users (username, password_hash, display_name) VALUES (?, ?, ?)", c.execute("INSERT INTO users (username, password_hash, display_name) VALUES (?, ?, ?)",
("admin", pw_hash, "Yusuf")) ("admin", pw_hash, "Yusuf"))
conn.commit() conn.commit()

View File

@@ -5,12 +5,12 @@ Platform Gateway — Booklore integration (book library manager).
import json import json
import time import time
from config import ( from config import BOOKLORE_URL, BOOKLORE_USER, BOOKLORE_PASS, BOOKLORE_BOOKS_DIR
BOOKLORE_URL, BOOKLORE_USER, BOOKLORE_PASS,
BOOKLORE_BOOKS_DIR, _booklore_token,
)
from proxy import proxy_request from proxy import proxy_request
# Mutable auth token state (not in config — config is for immutable values)
_booklore_token = {"access": "", "refresh": "", "expires": 0}
def booklore_auth(): def booklore_auth():
"""Get a valid Booklore JWT token, refreshing if needed.""" """Get a valid Booklore JWT token, refreshing if needed."""

View File

@@ -3,21 +3,18 @@ Platform Gateway — qBittorrent integration (download status).
""" """
import json import json
import os
import urllib.request import urllib.request
import urllib.parse import urllib.parse
from config import QBITTORRENT_HOST, QBITTORRENT_PORT, QBITTORRENT_USERNAME, QBITTORRENT_PASSWORD
def handle_downloads_status(handler): def handle_downloads_status(handler):
"""Get active downloads from qBittorrent.""" """Get active downloads from qBittorrent."""
qbt_host = os.environ.get("QBITTORRENT_HOST", "192.168.1.42") base = f"http://{QBITTORRENT_HOST}:{QBITTORRENT_PORT}"
qbt_port = os.environ.get("QBITTORRENT_PORT", "8080")
qbt_user = os.environ.get("QBITTORRENT_USERNAME", "admin")
qbt_pass = os.environ.get("QBITTORRENT_PASSWORD", "")
base = f"http://{qbt_host}:{qbt_port}"
try: try:
# Login # Login
login_data = urllib.parse.urlencode({"username": qbt_user, "password": qbt_pass}).encode() login_data = urllib.parse.urlencode({"username": QBITTORRENT_USERNAME, "password": QBITTORRENT_PASSWORD}).encode()
req = urllib.request.Request(f"{base}/api/v2/auth/login", data=login_data) req = urllib.request.Request(f"{base}/api/v2/auth/login", data=login_data)
with urllib.request.urlopen(req, timeout=5) as resp: with urllib.request.urlopen(req, timeout=5) as resp:
cookie = resp.headers.get("Set-Cookie", "").split(";")[0] cookie = resp.headers.get("Set-Cookie", "").split(";")[0]

View File

@@ -8,7 +8,9 @@ This file is thin routing only. All logic lives in submodules.
import json import json
from datetime import datetime from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from http.server import HTTPServer
from config import ( from config import (
PORT, TRIPS_API_TOKEN, KINDLE_EMAIL_1, KINDLE_EMAIL_2, PORT, TRIPS_API_TOKEN, KINDLE_EMAIL_1, KINDLE_EMAIL_2,
@@ -337,7 +339,10 @@ def main():
print(f"[Gateway] Services: {_proxy_module.SERVICE_MAP}") print(f"[Gateway] Services: {_proxy_module.SERVICE_MAP}")
print(f"[Gateway] Listening on port {PORT}") print(f"[Gateway] Listening on port {PORT}")
server = HTTPServer(("0.0.0.0", PORT), GatewayHandler) class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
server = ThreadingHTTPServer(("0.0.0.0", PORT), GatewayHandler)
server.serve_forever() server.serve_forever()