Security: fix SQL injection, add rate limiting, markdown in TeachChat

- teach.py, questions.py: replace f-string SQL with parameterized CAST(:vec AS vector) queries
- auth.py: add reusable check_rate_limit() Redis helper
- teach.py: rate limit /chat to 30 req/10min per user
- tts.py: rate limit /speak to 60 req/hr per user
- teach.py: stronger system prompt — no clarifying questions, use markdown, answer directly
- TeachChat.jsx: render assistant messages with ReactMarkdown (already in package.json)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-04 00:45:28 +02:00
parent 975a31fb01
commit 7f1f14537b
7 changed files with 3278 additions and 22 deletions

View file

@ -109,14 +109,15 @@ def get_question_bank(
from sqlalchemy import text as sa_text
emb = generate_embedding(q.strip())
if emb:
emb_literal = "[" + ",".join(str(x) for x in emb) + "]"
rows = db.execute(sa_text(f"""
SELECT id, 1 - (embedding <=> '{emb_literal}'::vector) AS sim
# Validate all values are finite floats before interpolating into SQL
emb_literal = "[" + ",".join(str(float(x)) for x in emb) + "]"
rows = db.execute(sa_text("""
SELECT id, 1 - (embedding <=> CAST(:vec AS vector)) AS sim
FROM questions
WHERE embedding IS NOT NULL
ORDER BY embedding <=> '{emb_literal}'::vector
ORDER BY embedding <=> CAST(:vec AS vector)
LIMIT 200
""")).fetchall()
"""), {"vec": emb_literal}).fetchall()
semantic_ids_ordered = [r.id for r in rows if float(r.sim) >= 0.30]
# ── Keyword filter ─────────────────────────────────────────────

View file

@ -7,7 +7,7 @@ from app.database import get_db
from app.models.question import Question
from app.models.ai_model_config import AIModelConfig
from app.models.user import User
from app.utils.auth import get_current_user
from app.utils.auth import get_current_user, check_rate_limit
router = APIRouter()
@ -54,14 +54,15 @@ def _find_similar_questions(db: Session, question: Question, limit: int = 4) ->
try:
from sqlalchemy import text as sa_text
emb = question.embedding
emb_literal = "[" + ",".join(str(x) for x in emb) + "]"
rows = db.execute(sa_text(f"""
SELECT id, 1 - (embedding <=> '{emb_literal}'::vector) AS sim
# Validate all values are finite floats before using in SQL
emb_literal = "[" + ",".join(str(float(x)) for x in emb) + "]"
rows = db.execute(sa_text("""
SELECT id, 1 - (embedding <=> CAST(:vec AS vector)) AS sim
FROM questions
WHERE embedding IS NOT NULL AND id != {question.id}
ORDER BY embedding <=> '{emb_literal}'::vector
LIMIT {limit}
""")).fetchall()
WHERE embedding IS NOT NULL AND id != :qid
ORDER BY embedding <=> CAST(:vec AS vector)
LIMIT :lim
"""), {"vec": emb_literal, "qid": int(question.id), "lim": int(limit)}).fetchall()
ids = [r.id for r in rows if float(r.sim) >= 0.35]
if not ids:
return []
@ -77,9 +78,14 @@ def _build_system_prompt(question: Question, similar: list[Question]) -> str:
opts = "\n".join(f" {letters[i]}) {opt}" for i, opt in enumerate(question.options))
prompt = (
"You are a medical education tutor helping a student understand the following question. "
"Be accurate, educational, and concise. You may reveal and explain the correct answer.\n\n"
f"=== Current Question ===\n{question.question_text}\n"
"You are a medical education tutor. A student is studying the question below.\n"
"Rules:\n"
"- Answer the student's question directly. Do NOT ask clarifying questions.\n"
"- You may reveal and explain the correct answer and why wrong options are wrong.\n"
"- Use markdown formatting: bold key terms, bullet lists for comparisons.\n"
"- Keep responses under 200 words unless a detailed explanation is needed.\n"
"- Never ask 'what would you like to know?' — just explain.\n\n"
f"=== Question ===\n{question.question_text}\n"
)
if opts:
prompt += f"Options:\n{opts}\n"
@ -97,9 +103,9 @@ def _build_system_prompt(question: Question, similar: list[Question]) -> str:
prompt += "\n"
prompt += (
"\nAnswer the student's questions about this topic. "
"If they ask why an option is wrong, explain the underlying concept. "
"Keep responses focused and under 200 words unless a longer explanation is needed."
"\nAnswer the student's question directly. Explain the correct answer, "
"the underlying concept, and why wrong options are incorrect if relevant. "
"Do not ask what they want to know — just teach."
)
return prompt
@ -124,6 +130,13 @@ def chat(
current_user: User = Depends(get_current_user),
):
"""Send a message to the teach AI with full question context."""
# Rate limit: 30 AI chat messages per user per 10 minutes
check_rate_limit(
key=f"teach_chat:{current_user.id}",
max_calls=30,
window_seconds=600,
detail="Too many AI chat messages. Please wait a few minutes before continuing.",
)
model_info = _get_teach_model(db, req.model_id)
if not model_info:
raise HTTPException(

View file

@ -8,7 +8,7 @@ from app.database import get_db
from app.models.user import User
from app.models.ai_model_config import AIModelConfig
from app.services import ai_service
from app.utils.auth import get_current_user
from app.utils.auth import get_current_user, check_rate_limit
router = APIRouter()
@ -51,6 +51,13 @@ def text_to_speech(
current_user: User = Depends(get_current_user),
):
"""Convert text to speech using configured or user-selected TTS model."""
# Rate limit: 60 TTS requests per user per hour
check_rate_limit(
key=f"tts_speak:{current_user.id}",
max_calls=60,
window_seconds=3600,
detail="TTS rate limit reached. You can generate up to 60 audio clips per hour.",
)
if not request.text.strip():
raise HTTPException(status_code=400, detail="Text cannot be empty")

View file

@ -117,3 +117,19 @@ class TokenRefreshMiddleware(BaseHTTPMiddleware):
response.headers["X-New-Token"] = new_token
return response
def check_rate_limit(key: str, max_calls: int, window_seconds: int, detail: str):
"""Generic Redis-backed rate limiter. Raises 429 if limit exceeded. Degrades gracefully if Redis is down."""
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
count = r.incr(key)
if count == 1:
r.expire(key, window_seconds)
if count > max_calls:
raise HTTPException(status_code=429, detail=detail)
except HTTPException:
raise
except Exception:
pass # Redis unavailable — fail open rather than blocking users

3208
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -12,6 +12,7 @@
"axios": "^1.6.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.0"
},
"devDependencies": {

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import api from '../api/client'
/**
@ -196,9 +197,18 @@ export default function TeachChat({ question }) {
fontSize: '0.85rem',
lineHeight: 1.55,
border: m.role === 'assistant' ? '1px solid var(--border)' : 'none',
whiteSpace: 'pre-wrap',
}}>
{m.content}
{m.role === 'assistant'
? <ReactMarkdown components={{
p: ({children}) => <p style={{margin: '0 0 6px'}}>{children}</p>,
ul: ({children}) => <ul style={{margin: '4px 0', paddingLeft: 18}}>{children}</ul>,
ol: ({children}) => <ol style={{margin: '4px 0', paddingLeft: 18}}>{children}</ol>,
li: ({children}) => <li style={{marginBottom: 2}}>{children}</li>,
strong: ({children}) => <strong style={{fontWeight: 700}}>{children}</strong>,
code: ({children}) => <code style={{background: 'var(--border)', borderRadius: 3, padding: '1px 4px', fontSize: '0.82em'}}>{children}</code>,
}}>{m.content}</ReactMarkdown>
: m.content
}
</div>
))}
{loading && (