Skip to content

Commit 478ca18

Browse files
committed
Phase 0 hardening + batched AI moderation + websocket/perf fixes
Security / correctness - Privilege escalation: rule/api-key/project-update routes now gate on can_manage_members instead of is_member; plain members could previously rewrite or disable moderation rules and delete API keys. - Project.is_owner() AttributeError on non-admin API-user deletion (route + template) replaced with user_id comparison. - Flask-Limiter on /auth/login, /auth/register, /auth/change-password, and /api/moderate (per API key id). - Remove silent SECRET_KEY fallback: docker-compose fails hard if unset; production config validator refuses to boot without SECRET_KEY and OPENAI_API_KEY. - Require verified email in Google OAuth auto-link flow. - User-supplied regex now evaluated via the regex package with a 1s timeout, closing the ReDoS vector. - Cap batched AI chunk count (max 40) and concurrent workers (200 -> 10). - openai>=2.32 (from 1.55.3 so reasoning_effort kwarg works); add regex, Flask-Limiter, gunicorn, eventlet, psycogreen; drop the stdlib-shadowing pypi asyncio package. Production runtime - New wsgi.py: eventlet.monkey_patch + psycogreen.patch_psycopg so DB queries cooperate with the event loop; sets SOCKETIO_ASYNC_MODE=eventlet for app startup. - Dockerfiles run as non-root app user and CMD gunicorn with --worker-class eventlet. run.py refuses FLASK_CONFIG=production so a misconfigured deploy fails loudly. - cloudbuild.yaml stops managing env vars/secrets on deploy — it was about to clobber secret-backed env vars with plain ones. Runtime config is now owned by the Cloud Run service. SocketIO async-mode fix (was causing full site lockup during API calls) - async_mode picked via SOCKETIO_ASYNC_MODE env var, default "threading". The previous sys.modules auto-detect false-positived because flask_socketio / python-engineio import eventlet on load whenever it is installed, pinning a non-monkey-patched eventlet server in front of blocking stdlib I/O. Real-time dashboard updates - New content_received WebSocket event fires when the API accepts a submission; UI renders a "Processing…" row immediately and updates it in place when moderation_update arrives. - Fix stale updated_at timestamp in moderation_update payloads. Batched AI rule evaluation - Replace the per-rule parallel fan-out (N OpenAI calls) with a single batched call evaluating all AI-prompt rules at once. On a 6-rule default project this drops safe-content cost from 5-12s to ~1-4s. - response_format=json_object + max_completion_tokens=4000 so the model emits strict JSON and stops at the close-brace. - OPENAI_REASONING_EFFORT config: minimal/low/medium/high for gpt-5 / o-series; accepts "none" on gpt-5.4. Works around the gpt-5-nano case where reasoning tokens ate the full output budget and returned empty responses. Client errors - Malformed / non-UTF-8 JSON bodies now return 400 with a clear message about UTF-8 encoding instead of a generic 500. Logging - Single INFO line per moderation: the existing "Content <id>: decision in Zs" summary. - Per-stage timing, websocket scheduling, and batched-AI call details moved to DEBUG so they are available when diagnosing latency without cluttering steady-state logs.
1 parent 8169423 commit 478ca18

20 files changed

Lines changed: 787 additions & 245 deletions

app/__init__.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,53 @@
55

66
import sentry_sdk
77
from flask import Flask
8+
from flask_limiter import Limiter
9+
from flask_limiter.util import get_remote_address
810
from flask_login import LoginManager
911
from flask_socketio import SocketIO
1012
from flask_sqlalchemy import SQLAlchemy
1113
from flask_talisman import Talisman
1214
from flask_wtf.csrf import CSRFProtect
1315

14-
from config.config import config
16+
from config.config import config, validate_production_config
17+
18+
# SocketIO async mode selection.
19+
#
20+
# Default is 'threading' so the Werkzeug dev server (run.py) and any plain
21+
# WSGI host work correctly. Production uses eventlet — but ONLY when wsgi.py
22+
# has run first and called eventlet.monkey_patch() + psycogreen.patch_psycopg().
23+
# wsgi.py signals that by setting SOCKETIO_ASYNC_MODE=eventlet in the
24+
# environment before importing this module.
25+
#
26+
# DO NOT auto-detect from sys.modules. flask_socketio / python-engineio
27+
# import eventlet at load time whenever it's installed, which would false-
28+
# positive the detection and put a non-monkey-patched eventlet server in
29+
# front of blocking stdlib I/O (that was the "site locks up during API
30+
# requests" bug).
31+
_SOCKETIO_ASYNC_MODE = os.environ.get('SOCKETIO_ASYNC_MODE', 'threading')
1532

1633
# SQLAlchemy - database interface
1734
db = SQLAlchemy()
1835
login_manager = LoginManager()
1936
socketio = SocketIO(cors_allowed_origins="*")
2037
csrf = CSRFProtect()
2138

39+
# Rate limiter. Uses in-memory storage; when scaling to multiple workers or
40+
# processes, set storage_uri to a Redis URL so limits are shared. Per-route
41+
# limits are declared in the route files with @limiter.limit(...).
42+
limiter = Limiter(
43+
key_func=get_remote_address,
44+
default_limits=[],
45+
storage_uri="memory://",
46+
strategy="fixed-window",
47+
)
48+
2249

2350
def create_app(config_name: str = 'default') -> Flask:
51+
# Fail fast in production if required secrets are missing
52+
if config_name == 'production':
53+
validate_production_config()
54+
2455
app = Flask(__name__)
2556
app.config.from_object(config[config_name])
2657

@@ -77,9 +108,11 @@ def before_send(event, hint):
77108
# Initialize SocketIO with increased timeouts to handle browser tab throttling
78109
# ping_timeout: Time to wait for client response before considering connection dead
79110
# ping_interval: Time between server pings to check client connection
111+
# async_mode: auto-detected above — 'eventlet' under gunicorn, 'threading' under werkzeug dev
112+
app.logger.info(f"Initialising SocketIO with async_mode={_SOCKETIO_ASYNC_MODE}")
80113
socketio.init_app(
81114
app,
82-
async_mode='threading',
115+
async_mode=_SOCKETIO_ASYNC_MODE,
83116
ping_timeout=120, # Increased from default 60s to 2 minutes
84117
ping_interval=25, # Keep default 25s interval
85118
logger=False, # Disable SocketIO logger to reduce noise
@@ -106,6 +139,9 @@ def filtered_werkzeug_log(msg, *args, **kwargs):
106139
# Initialize CSRF protection
107140
csrf.init_app(app)
108141

142+
# Initialize rate limiter (limits declared on individual routes)
143+
limiter.init_app(app)
144+
109145
# Configure CSRF header names for AJAX requests
110146
app.config['WTF_CSRF_HEADERS'] = ['X-CSRFToken', 'X-CSRF-Token']
111147

app/routes/api.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
from typing import Callable
44

55
from flask import Blueprint, current_app, jsonify, render_template, request
6+
from flask_limiter.util import get_remote_address
67

8+
from app import limiter
79
from app.schemas import ContentListRequest, ModerateContentRequest
810
from app.services.database_service import db_service
911
from app.services.error_tracker import error_tracker
@@ -19,6 +21,19 @@
1921
api_bp = Blueprint('api', __name__)
2022

2123

24+
def _api_rate_limit_key() -> str:
25+
"""Rate-limit key for /api/moderate.
26+
27+
Prefers the API key id (set by @require_api_key) so a leaked key cannot be
28+
replayed at unbounded QPS from many IPs. Falls back to remote address when
29+
the key hasn't been resolved yet (malformed/missing key -> 401 path).
30+
"""
31+
api_key = getattr(request, 'api_key', None)
32+
if api_key is not None:
33+
return f"api_key:{api_key.id}"
34+
return get_remote_address()
35+
36+
2237
def require_api_key(f: Callable) -> Callable:
2338
"""Decorator to require valid API key for API endpoints"""
2439
@wraps(f)
@@ -52,6 +67,7 @@ async def decorated_function(*args, **kwargs):
5267

5368

5469
@api_bp.route('/moderate', methods=['POST'])
70+
@limiter.limit("120 per minute; 5000 per hour", key_func=_api_rate_limit_key)
5571
@require_api_key
5672
@validate_json_request(ModerateContentRequest)
5773
@handle_api_error
@@ -75,7 +91,7 @@ async def moderate_content(validated_data=None):
7591

7692
max_content_size = 5000000 # 5MB limit (increased from 1MB)
7793
content_size_kb = len(content_data) // 1000
78-
current_app.logger.info(f'Content moderation request: {content_size_kb}KB')
94+
current_app.logger.debug(f'Content moderation request: {content_size_kb}KB')
7995

8096
if len(content_data) > max_content_size:
8197
current_app.logger.warning(f'Content too large: {content_size_kb}KB > {max_content_size // 1000}KB')
@@ -141,6 +157,14 @@ async def moderate_content(validated_data=None):
141157

142158
# Start moderation process
143159
moderation_orchestrator = ModerationOrchestrator()
160+
161+
# Emit "content_received" before the (potentially slow) moderation pass
162+
# so the dashboard can render a pending row immediately. Fetch the fresh
163+
# record so the notifier has real timestamps.
164+
created_content = await db_service.get_content_by_id(content_id)
165+
if created_content is not None:
166+
moderation_orchestrator.websocket_notifier.send_content_created(created_content)
167+
144168
result = await moderation_orchestrator.moderate_content(
145169
content_id, request_start_time)
146170

app/routes/auth.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, url_for
55
from flask_login import current_user, login_required, login_user, logout_user
66

7+
from app import limiter
78
from app.models.system_settings import SystemSettings
89
from app.services.database_service import db_service
910

@@ -12,6 +13,7 @@
1213

1314

1415
@auth_bp.route('/login', methods=['GET', 'POST'])
16+
@limiter.limit("10 per minute; 50 per hour", methods=["POST"])
1517
async def login():
1618
# Redirect to dashboard if already logged in
1719
if current_user.is_authenticated:
@@ -100,6 +102,7 @@ async def login():
100102

101103

102104
@auth_bp.route('/register', methods=['GET', 'POST'])
105+
@limiter.limit("5 per hour; 20 per day", methods=["POST"])
103106
async def register():
104107
# Redirect to dashboard if already logged in
105108
if current_user.is_authenticated:
@@ -294,6 +297,7 @@ async def google_callback():
294297

295298
google_id = user_info.get('sub')
296299
email = user_info.get('email')
300+
email_verified = user_info.get('email_verified') is True
297301

298302
if not google_id or not email:
299303
flash('Invalid user information from Google', 'error')
@@ -308,11 +312,18 @@ async def google_callback():
308312
flash('Login successful!', 'success')
309313
return redirect(url_for('dashboard.index'))
310314

315+
# Reject unverified emails before any account-linking or account-creation branch.
316+
# An attacker who controls a Google workspace can register arbitrary unverified
317+
# addresses and would otherwise hijack a matching local account here.
318+
if not email_verified:
319+
flash('Your Google account email must be verified before you can sign in here.', 'error')
320+
return redirect(url_for('auth.login'))
321+
311322
# Check if user exists with this email
312323
user = await db_service.get_user_by_email(email.lower())
313324

314325
if user:
315-
# Link Google account to existing user
326+
# Link Google account to existing user (email is verified per check above)
316327
await db_service.link_google_account(user.id, google_id)
317328
login_user(user)
318329
flash('Google account linked successfully!', 'success')
@@ -357,6 +368,7 @@ async def google_callback():
357368

358369
@auth_bp.route('/change-password', methods=['POST'])
359370
@login_required
371+
@limiter.limit("10 per hour")
360372
async def change_password():
361373
# Check if this is an AJAX request by looking for specific headers or content type
362374
# is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest' or \

app/routes/dashboard.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -196,10 +196,10 @@ async def create_rule(project_id):
196196
"""Create new moderation rule"""
197197
project = Project.query.filter_by(id=project_id).first_or_404()
198198

199-
# Check if user has access to this project
200-
if not project.is_member(current_user.id):
201-
flash('You do not have access to this project', 'error')
202-
return redirect(url_for('dashboard.projects'))
199+
# Mutating rules requires owner/admin role; plain members are read-only
200+
if not project.can_manage_members(current_user.id):
201+
flash('You do not have permission to create rules for this project', 'error')
202+
return redirect(url_for('dashboard.project_rules', project_id=project_id))
203203

204204
if request.method == 'POST':
205205
name = request.form.get('name')
@@ -251,8 +251,8 @@ async def update_rule(project_id, rule_id):
251251
"""Update existing moderation rule"""
252252
project = Project.query.filter_by(id=project_id).first_or_404()
253253

254-
# Check if user has access to this project
255-
if not project.is_member(current_user.id):
254+
# Mutating rules requires owner/admin role; plain members are read-only
255+
if not project.can_manage_members(current_user.id):
256256
return jsonify({'success': False, 'error': 'Access denied'}), 403
257257
rule = ModerationRule.query.filter_by(
258258
id=rule_id, project_id=project.id).first_or_404()
@@ -301,8 +301,8 @@ async def toggle_rule(project_id, rule_id):
301301
"""Toggle rule active/inactive status"""
302302
project = Project.query.filter_by(id=project_id).first_or_404()
303303

304-
# Check if user has access to this project
305-
if not project.is_member(current_user.id):
304+
# Mutating rules requires owner/admin role; plain members are read-only
305+
if not project.can_manage_members(current_user.id):
306306
return jsonify({'success': False, 'error': 'Access denied'}), 403
307307
rule = ModerationRule.query.filter_by(
308308
id=rule_id, project_id=project.id).first_or_404()
@@ -337,8 +337,8 @@ async def delete_rule(project_id, rule_id):
337337
"""Delete moderation rule"""
338338
project = Project.query.filter_by(id=project_id).first_or_404()
339339

340-
# Check if user has access to this project
341-
if not project.is_member(current_user.id):
340+
# Deleting rules requires owner/admin role; plain members are read-only
341+
if not project.can_manage_members(current_user.id):
342342
return jsonify({'success': False, 'error': 'Access denied'}), 403
343343
rule = ModerationRule.query.filter_by(
344344
id=rule_id, project_id=project.id).first_or_404()
@@ -464,8 +464,8 @@ async def toggle_api_key(project_id, key_id):
464464
"""Toggle API key active/inactive status"""
465465
project = Project.query.filter_by(id=project_id).first_or_404()
466466

467-
# Check if user has access to this project
468-
if not project.is_member(current_user.id):
467+
# Mutating API keys requires owner/admin role; plain members are read-only
468+
if not project.can_manage_members(current_user.id):
469469
return jsonify({'success': False, 'error': 'Access denied'}), 403
470470
api_key = APIKey.query.filter_by(
471471
id=key_id, project_id=project.id).first_or_404()
@@ -500,8 +500,8 @@ async def delete_api_key(project_id, key_id):
500500
"""Delete API key"""
501501
project = Project.query.filter_by(id=project_id).first_or_404()
502502

503-
# Check if user has access to this project
504-
if not project.is_member(current_user.id):
503+
# Deleting API keys requires owner/admin role; plain members are read-only
504+
if not project.can_manage_members(current_user.id):
505505
return jsonify({'success': False, 'error': 'Access denied'}), 403
506506
api_key = APIKey.query.filter_by(
507507
id=key_id, project_id=project.id).first_or_404()
@@ -573,10 +573,10 @@ async def update_project(project_id):
573573
"""Update project information"""
574574
project = Project.query.filter_by(id=project_id).first_or_404()
575575

576-
# Check if user has access to this project
577-
if not project.is_member(current_user.id):
578-
flash('You do not have access to this project', 'error')
579-
return redirect(url_for('dashboard.projects'))
576+
# Updating project identity requires owner/admin role; plain members are read-only
577+
if not project.can_manage_members(current_user.id):
578+
flash('You do not have permission to modify project settings', 'error')
579+
return redirect(url_for('dashboard.project_settings', project_id=project_id))
580580

581581
name = request.form.get('name')
582582
description = request.form.get('description', '')

app/routes/manual_review.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@ async def delete_user_data(user_id):
500500
api_user = APIUser.query.get_or_404(user_id)
501501

502502
# Check if user has permission (admin or project owner)
503-
if not current_user.is_admin and not api_user.project.is_owner(current_user.id):
503+
if not current_user.is_admin and api_user.project.user_id != current_user.id:
504504
flash('You do not have permission to delete user data', 'error')
505505
return redirect(url_for('manual_review.api_user_detail', user_id=user_id))
506506

0 commit comments

Comments
 (0)