diff --git a/Dockerfile b/Dockerfile index 32b6676b..230840f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -97,7 +97,7 @@ USER timetracker # Expose port EXPOSE 8080 -# Health check +# Health check (liveness) HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ CMD curl -f http://localhost:8080/_health || exit 1 diff --git a/app/__init__.py b/app/__init__.py index 6cfda9b9..498b9d69 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -8,6 +8,9 @@ from flask_login import LoginManager from flask_socketio import SocketIO from dotenv import load_dotenv from flask_babel import Babel, _ +from flask_wtf.csrf import CSRFProtect, CSRFError +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address from authlib.integrations.flask_client import OAuth import re from jinja2 import ChoiceLoader, FileSystemLoader @@ -22,6 +25,8 @@ migrate = Migrate() login_manager = LoginManager() socketio = SocketIO() babel = Babel() +csrf = CSRFProtect() +limiter = Limiter(key_func=get_remote_address, default_limits=[]) oauth = OAuth() def create_app(config=None): @@ -33,7 +38,17 @@ def create_app(config=None): app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) # Configuration - app.config.from_object('app.config.Config') + # Load env-specific config class + try: + env_name = os.getenv('FLASK_ENV', 'production') + cfg_map = { + 'development': 'app.config.DevelopmentConfig', + 'testing': 'app.config.TestingConfig', + 'production': 'app.config.ProductionConfig', + } + app.config.from_object(cfg_map.get(env_name, 'app.config.Config')) + except Exception: + app.config.from_object('app.config.Config') if config: app.config.update(config) @@ -72,6 +87,20 @@ def create_app(config=None): login_manager.init_app(app) socketio.init_app(app, cors_allowed_origins="*") oauth.init_app(app) + csrf.init_app(app) + try: + # Configure limiter defaults from config if provided + default_limits = [] + raw = app.config.get('RATELIMIT_DEFAULT') + if raw: + # support semicolon or comma separated limits + parts = [p.strip() for p in str(raw).replace(',', ';').split(';') if p.strip()] + if parts: + default_limits = parts + limiter._default_limits = default_limits # set after init + limiter.init_app(app) + except Exception: + limiter.init_app(app) # Ensure translations exist and configure absolute translation directories before Babel init try: @@ -183,6 +212,56 @@ def create_app(config=None): # Setup logging setup_logging(app) + + # Fail-fast on weak secret in production + if not app.debug and app.config.get('FLASK_ENV', 'production') == 'production': + if app.config.get('SECRET_KEY') == 'dev-secret-key-change-in-production': + app.logger.error('Weak SECRET_KEY configured in production; refusing to start') + raise RuntimeError('Weak SECRET_KEY in production') + + # Apply security headers and a basic CSP + @app.after_request + def apply_security_headers(response): + try: + headers = app.config.get('SECURITY_HEADERS', {}) or {} + for k, v in headers.items(): + # do not overwrite existing header if already present + if not response.headers.get(k): + response.headers[k] = v + # Minimal CSP allowing our own resources and common CDNs used in templates + if not response.headers.get('Content-Security-Policy'): + csp = ( + "default-src 'self'; " + "img-src 'self' data: https:; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.datatables.net; " + "font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com data:; " + "script-src 'self' 'unsafe-inline' https://code.jquery.com https://cdn.datatables.net https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; " + "connect-src 'self' ws: wss:; " + "frame-ancestors 'none'" + ) + response.headers['Content-Security-Policy'] = csp + # Additional privacy headers + if not response.headers.get('Referrer-Policy'): + response.headers['Referrer-Policy'] = 'no-referrer' + if not response.headers.get('Permissions-Policy'): + response.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()' + except Exception: + pass + return response + + # CSRF error handler + @app.errorhandler(CSRFError) + def handle_csrf_error(e): + return ({'error': 'csrf_token_missing_or_invalid'}, 400) + + # Expose csrf_token() in Jinja templates even without FlaskForm + try: + from flask_wtf.csrf import generate_csrf + @app.context_processor + def inject_csrf_token(): + return dict(csrf_token=lambda: generate_csrf()) + except Exception: + pass # Register blueprints from app.routes.auth import auth_bp diff --git a/app/config.py b/app/config.py index d834415c..2033749e 100644 --- a/app/config.py +++ b/app/config.py @@ -85,6 +85,10 @@ class Config: 'X-XSS-Protection': '1; mode=block', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains' } + + # Rate limiting + RATELIMIT_DEFAULT = os.getenv('RATELIMIT_DEFAULT', '') # e.g., "200 per day;50 per hour" + RATELIMIT_STORAGE_URI = os.getenv('RATELIMIT_STORAGE_URI', 'memory://') # Internationalization LANGUAGES = { @@ -136,6 +140,7 @@ class ProductionConfig(Config): FLASK_DEBUG = False SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True + WTF_CSRF_ENABLED = True # Configuration mapping config = { diff --git a/app/routes/admin.py b/app/routes/admin.py index 2139c64c..74847998 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1,7 +1,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory, send_file, jsonify, render_template_string from flask_babel import gettext as _ from flask_login import login_required, current_user -from app import db +from app import db, limiter from app.models import User, Project, TimeEntry, Settings, Invoice from datetime import datetime from sqlalchemy import text @@ -19,7 +19,8 @@ admin_bp = Blueprint('admin', __name__) RESTORE_PROGRESS = {} # Allowed file extensions for logos -ALLOWED_LOGO_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'} +# Avoid SVG due to XSS risk unless sanitized server-side +ALLOWED_LOGO_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} def admin_required(f): """Decorator to require admin access""" @@ -260,6 +261,7 @@ def settings(): @admin_bp.route('/admin/pdf-layout', methods=['GET', 'POST']) +@limiter.limit("30 per minute", methods=["POST"]) # editor saves @login_required @admin_required def pdf_layout(): @@ -301,6 +303,7 @@ def pdf_layout(): @admin_bp.route('/admin/pdf-layout/reset', methods=['POST']) +@limiter.limit("10 per minute") @login_required @admin_required def pdf_layout_reset(): @@ -345,6 +348,7 @@ def pdf_layout_default(): @admin_bp.route('/admin/pdf-layout/preview', methods=['POST']) +@limiter.limit("60 per minute") @login_required @admin_required def pdf_layout_preview(): @@ -479,6 +483,7 @@ def pdf_layout_preview(): return page_html @admin_bp.route('/admin/upload-logo', methods=['POST']) +@limiter.limit("10 per minute") @login_required @admin_required def upload_logo(): @@ -497,6 +502,17 @@ def upload_logo(): file_extension = file.filename.rsplit('.', 1)[1].lower() unique_filename = f"company_logo_{uuid.uuid4().hex[:8]}.{file_extension}" + # Basic server-side validation: verify image type + try: + from PIL import Image + file.stream.seek(0) + img = Image.open(file.stream) + img.verify() + file.stream.seek(0) + except Exception: + flash('Invalid image file.', 'error') + return redirect(url_for('admin.settings')) + # Save file upload_folder = get_upload_folder() file_path = os.path.join(upload_folder, unique_filename) @@ -579,6 +595,7 @@ def backup(): return redirect(url_for('admin.admin_dashboard')) @admin_bp.route('/admin/restore', methods=['GET', 'POST']) +@limiter.limit("3 per minute", methods=["POST"]) # heavy operation @login_required @admin_required def restore(): diff --git a/app/routes/auth.py b/app/routes/auth.py index ec4fc6aa..857f9da6 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -5,12 +5,13 @@ from app.models import User from app.config import Config from app.utils.db import safe_commit from flask_babel import gettext as _ -from app import oauth +from app import oauth, limiter auth_bp = Blueprint('auth', __name__) @auth_bp.route('/login', methods=['GET', 'POST']) +@limiter.limit("5 per minute", methods=["POST"]) # rate limit login attempts def login(): """Login page. Local username login is allowed only if AUTH_METHOD != 'oidc'.""" if request.method == 'GET': diff --git a/app/routes/main.py b/app/routes/main.py index e39d8bab..2352b78a 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -52,23 +52,17 @@ def dashboard(): @main_bp.route('/_health') def health_check(): - """Health check endpoint for monitoring""" + """Liveness probe: shallow checks only, no DB access""" + return {'status': 'healthy'}, 200 + +@main_bp.route('/_ready') +def readiness_check(): + """Readiness probe: verify DB connectivity and critical dependencies""" try: - # Test database connection db.session.execute(text('SELECT 1')) - return {'status': 'healthy', 'timestamp': datetime.utcnow().isoformat()}, 200 + return {'status': 'ready', 'timestamp': datetime.utcnow().isoformat()}, 200 except Exception as e: - # Try to initialize database if connection fails - try: - from flask import current_app - if hasattr(current_app, 'initialize_database'): - current_app.initialize_database() - # Test connection again - db.session.execute(text('SELECT 1')) - return {'status': 'healthy', 'timestamp': datetime.utcnow().isoformat(), 'note': 'database initialized'}, 200 - except Exception as init_error: - return {'status': 'unhealthy', 'error': str(e), 'init_error': str(init_error)}, 500 - return {'status': 'unhealthy', 'error': str(e)}, 500 + return {'status': 'not_ready', 'error': 'db_unreachable'}, 503 @main_bp.route('/about') def about(): diff --git a/app/templates/auth/edit_profile.html b/app/templates/auth/edit_profile.html index c3d86daf..72ebba73 100644 --- a/app/templates/auth/edit_profile.html +++ b/app/templates/auth/edit_profile.html @@ -19,7 +19,8 @@
{{ _('Profile Details') }}
-
+ +
diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index ca69c8a8..2c42258c 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -24,6 +24,7 @@

+
diff --git a/app/templates/base.html b/app/templates/base.html index 8661c487..9c42ca82 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -9,6 +9,9 @@ {% block title %}{{ app_name }}{% endblock %} + {% if csrf_token %} + + {% endif %} {% if settings and settings.has_logo() %} @@ -398,5 +401,63 @@ } catch (e) {} })(); + diff --git a/app/templates/main/dashboard.html b/app/templates/main/dashboard.html index adf738fa..2409abc1 100644 --- a/app/templates/main/dashboard.html +++ b/app/templates/main/dashboard.html @@ -78,6 +78,7 @@
{% if active_timer %} + @@ -302,6 +303,7 @@
+