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

View file

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

View file

@ -8,7 +8,7 @@ from app.database import get_db
from app.models.user import User from app.models.user import User
from app.models.ai_model_config import AIModelConfig from app.models.ai_model_config import AIModelConfig
from app.services import ai_service 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() router = APIRouter()
@ -51,6 +51,13 @@ def text_to_speech(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Convert text to speech using configured or user-selected TTS model.""" """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(): if not request.text.strip():
raise HTTPException(status_code=400, detail="Text cannot be empty") 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 response.headers["X-New-Token"] = new_token
return response 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", "axios": "^1.6.7",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.0" "react-router-dom": "^6.22.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import api from '../api/client' import api from '../api/client'
/** /**
@ -196,9 +197,18 @@ export default function TeachChat({ question }) {
fontSize: '0.85rem', fontSize: '0.85rem',
lineHeight: 1.55, lineHeight: 1.55,
border: m.role === 'assistant' ? '1px solid var(--border)' : 'none', 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> </div>
))} ))}
{loading && ( {loading && (