v1.1 security release: PBKDF2 passwords, session expiry, rate limiting, SSE streaming

- store.py: passwords now PBKDF2-HMAC-SHA256 (390k iters, random salt,
  constant-time compare). Legacy unsalted-SHA256 hashes upgrade transparently
  on successful login — zero impact on existing users.
- Sessions expire after 30 days (lazy cleanup on token lookup).
- Rate limiting on register (5/hr/IP), login (10/15min per IP AND per
  username), chat (30/hr/IP) — SQLite-backed, shared across gunicorn workers.
- Registration now requires 8+ char passwords.
- New /api/chat/stream SSE endpoint: citations first ('meta'), streamed
  answer deltas, 'done'. Client auto-falls back to non-streaming.
- nginx: proxy_buffering off for SSE.
This commit is contained in:
drjones
2026-09-07 19:03:37 -07:00
parent 9f9d812320
commit f02124c9de
4 changed files with 281 additions and 15 deletions

175
app.py
View File

@@ -24,6 +24,29 @@ from agents import AGENTS, AGENT_BY_ID, OLLAMA_URL, RAG_MODEL, GENERAL_MODEL
app = Flask(__name__)
# ── Rate limiting (SQLite-backed sliding window; shared across gunicorn workers) ──
def _rate_limit(key, max_events, window_seconds):
"""True if allowed, False if the caller exceeded max_events in window.
Counters live in the astraea.db `rate_limits` table so all workers share them."""
now = time.time()
try:
c = store._conn()
c.execute("CREATE TABLE IF NOT EXISTS rate_limits (key TEXT, ts REAL)")
c.execute("DELETE FROM rate_limits WHERE ts < ?", (now - 3600,))
row = c.execute("SELECT COUNT(*) FROM rate_limits WHERE key=? AND ts >= ?",
(key, now - window_seconds)).fetchone()
if row[0] >= max_events:
c.commit(); c.close(); return False
c.execute("INSERT INTO rate_limits (key, ts) VALUES (?,?)", (key, now))
c.commit(); c.close()
return True
except Exception:
return True # fail open — availability over strictness
def _client_ip():
return request.headers.get("X-Real-IP") or request.remote_addr or "unknown"
# ── Professional avatars (gradient + glyph, rendered client-side) ──
AVATARS = [
{"id": 0, "glyph": "\u2696", "c1": "#6ea8ff", "c2": "#a78bfa", "label": "Scales"},
@@ -131,6 +154,32 @@ def _ollama_chat(model, messages, tools=None, num_ctx=24000, num_predict=1400,
return json.loads(r.read().decode("utf-8"))
def _ollama_chat_stream(model, messages, num_ctx=24000, num_predict=1400,
temperature=0.2, timeout=180):
"""Yield content deltas from Ollama's streaming /api/chat (no tools — the
streaming path is for final answers only; tool-calling stays non-streaming)."""
payload = {"model": model, "messages": messages, "stream": True, "think": False,
"options": {"temperature": temperature, "num_predict": num_predict, "num_ctx": num_ctx}}
req = urllib.request.Request(f"{OLLAMA_URL}/api/chat", data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
op = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with op.open(req, timeout=timeout) as r:
for line in r:
line = line.strip()
if not line:
continue
try:
chunk = json.loads(line)
except Exception:
continue
if chunk.get("done"):
break
msg = chunk.get("message", {})
delta = msg.get("content") or ""
if delta:
yield delta
def _web_search(query, limit=5):
"""Search the live web via local SearXNG (separate service, not the LLM)."""
try:
@@ -383,13 +432,16 @@ def app_page():
# ── Auth API ──
@app.route("/api/register", methods=["POST"])
def api_register():
ip = _client_ip()
if not _rate_limit(f"register:{ip}", 5, 3600):
return jsonify({"error": "Too many attempts. Try again later."}), 429
d = request.get_json(force=True, silent=True) or {}
u = (d.get("username") or "").strip().lower()
p = (d.get("password") or "").strip()
if len(u) < 2 or len(u) > 30 or not u.isalnum():
return jsonify({"error": "Username must be 2-30 alphanumeric chars"}), 400
if len(p) < 4:
return jsonify({"error": "Password must be 4+ chars"}), 400
if len(p) < 8:
return jsonify({"error": "Password must be 8+ chars"}), 400
c = store._conn()
if c.execute("SELECT id FROM users WHERE username=?", (u,)).fetchone():
c.close()
@@ -401,8 +453,13 @@ def api_register():
@app.route("/api/login", methods=["POST"])
def api_login():
ip = _client_ip()
# 10 attempts / 15 min / IP, and 10 / 15 min per username (brute-force guard)
d = request.get_json(force=True, silent=True) or {}
u = (d.get("username") or "").strip().lower()
if not _rate_limit(f"login-ip:{ip}", 10, 900) or \
(u and not _rate_limit(f"login-user:{u}", 10, 900)):
return jsonify({"error": "Too many attempts. Try again later."}), 429
p = (d.get("password") or "").strip()
uid = store.authenticate(u, p)
if not uid:
@@ -633,6 +690,120 @@ def api_chat():
return jsonify(result)
# ── Streaming chat (SSE) — same retrieval + safety context, streamed answer ──
def _chat_context(agent_id, message):
"""Shared retrieval/context assembly for /api/chat and /api/chat/stream."""
agent = AGENT_BY_ID.get(agent_id)
if not agent:
return None, None, None, None
message = message[:4000]
uid = _uid()
profile = store.get_profile(uid) if uid else {"about_me": "", "display_name": ""}
settings = store.get_settings(uid) if uid else {}
return agent, uid, profile, settings
@app.route("/api/chat/stream", methods=["POST"])
def api_chat_stream():
"""SSE stream: 'meta' event first (citations), then 'delta' events with the
answer text, then 'done'. Falls back to a single 'delta' with an error if
the engine fails. Persists the conversation like /api/chat does."""
d = request.get_json(force=True, silent=True) or {}
agent_id = (d.get("agent_id") or "navigator").strip()
message = (d.get("message") or "").strip()
client_history = d.get("history") or []
ip = _client_ip()
if not _rate_limit(f"chat:{ip}", 30, 3600):
return jsonify({"error": "Too many requests. Try again later."}), 429
agent, uid, profile, settings = _chat_context(agent_id, message)
if not agent:
return jsonify({"error": "Unknown agent"}), 400
if not message:
return jsonify({"error": "Message required"}), 400
history = store.get_conversation(uid, agent_id) if uid else client_history
# reuse the exact same context assembly as /api/chat by refactoring _build_answer's front half
top = rag.retrieve(message, agent["id"], top_k=5)
context_blocks, citations, seen = [], [], set()
for i, (score, c) in enumerate(top, 1):
key = (c["source"], c["title"])
if key in seen:
continue
seen.add(key)
context_blocks.append(f"[{i}] ({c['source']} — {c['title']})\n{c['text']}")
citations.append({"source": c["source"], "title": c["title"],
"score": round(score, 3), "snippet": c["text"][:260]})
if uid:
try:
for dd in userdocs.retrieve(uid, message, top_k=4):
context_blocks.append(f"[USER DOC — {dd['source']}]\n{dd['text']}")
citations.append({"source": dd["source"], "title": "your document",
"score": dd["score"], "snippet": dd["text"][:260]})
except Exception:
pass
system = (
f"{agent['system']}\n\n"
"Rules:\n"
"- Give REAL, practical legal advice: state the rule, apply it to the user's facts, "
"tell them what to do next.\n"
"- Answer DIRECTLY. Do not restate the question or narrate your reasoning.\n"
"- Ground every legal claim in the reference documents. Cite the RCW section AND the "
"controlling case law by name.\n"
"- Be plain-English, specific to Washington State.\n"
"- Items marked '[USER DOC]' are the user's own uploaded files — reference them by name "
"to ground your answer in their actual case.\n"
+ about_me_block(profile.get("about_me", ""))
+ safety_block(settings)
)
user = ("REFERENCE DOCUMENTS:\n\n" + "\n\n".join(context_blocks)
+ f"\n\nUSER QUESTION: {message}")
messages = [{"role": "system", "content": system}]
if history:
for turn in history[-6:]:
if turn.get("role") in ("user", "assistant"):
messages.append({"role": turn["role"], "content": turn["content"]})
messages.append({"role": "user", "content": user})
def generate():
full = []
try:
yield f"event: meta\ndata: {json.dumps({'citations': citations, 'agent': agent_id})}\n\n"
try:
for delta in _ollama_chat_stream(RAG_MODEL, messages):
full.append(delta)
yield f"event: delta\ndata: {json.dumps({'t': delta})}\n\n"
except Exception:
# model failure — fall back to the reliable general model
try:
for delta in _ollama_chat_stream(GENERAL_MODEL, messages):
full.append(delta)
yield f"event: delta\ndata: {json.dumps({'t': delta})}\n\n"
except Exception:
yield "event: delta\ndata: {\"t\": \"The legal engine is temporarily unavailable. Try again shortly.\"}\n\n"
answer = _strip_sources("".join(full).strip())
if uid and answer:
store.save_message(uid, agent_id, "user", message)
store.save_message(uid, agent_id, "assistant", answer, citations)
yield f"event: done\ndata: {json.dumps({'citations': citations, 'grounded': bool(context_blocks)})}\n\n"
except GeneratorExit:
# client disconnected mid-stream
try:
answer = _strip_sources("".join(full).strip())
if uid and answer:
store.save_message(uid, agent_id, "user", message)
store.save_message(uid, agent_id, "assistant", answer, citations)
except Exception:
pass
raise
return Response(generate(), mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
@app.route("/api/conversation/<agent_id>", methods=["GET", "DELETE"])
def api_conversation(agent_id):
uid = _uid()