fixed some security risks

This commit is contained in:
2026-02-21 10:36:53 -05:00
parent b62df442b5
commit c2fe59da0d
8 changed files with 170 additions and 250 deletions
+30 -204
View File
@@ -1,210 +1,36 @@
# Byte-compiled / optimized / DLL files # Environment & secrets — NEVER commit these
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env .env
.envrc *.env
.venv
env/ # Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.eggs/
# Virtual environments
venv/ venv/
ENV/ env/
env.bak/ .venv/
venv.bak/
# Spyder project settings # Flask / instance
.spyderproject instance/
.spyproject app/static/uploads/
# Rope project settings # IDEs
.ropeproject .vscode/
.idea/
*.swp
*.swo
# mkdocs documentation # OS
/site .DS_Store
Thumbs.db
# mypy # Logs
.mypy_cache/ *.log
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
wsgi.py
gunicorn_config.py
+39 -6
View File
@@ -1,5 +1,6 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from urllib.parse import urlparse
from app import db from app import db
from app.models.user import User from app.models.user import User
from app.utils.forms import LoginForm, UserForm from app.utils.forms import LoginForm, UserForm
@@ -7,6 +8,23 @@ from app.utils.decorators import admin_required
bp = Blueprint('auth', __name__, url_prefix='/auth') bp = Blueprint('auth', __name__, url_prefix='/auth')
def _safe_next(next_url: str | None) -> str:
"""
Validate that the redirect target is a relative URL on this host.
Returns the safe URL, or the dashboard index if the URL is external/invalid.
This prevents open-redirect attacks where an attacker crafts a login link
containing next=https://evil.com to hijack post-login redirects.
"""
if not next_url:
return url_for('dashboard.index')
parsed = urlparse(next_url)
# Reject any URL that specifies a network location (external host) or scheme
if parsed.netloc or parsed.scheme:
return url_for('dashboard.index')
return next_url
@bp.route('/login', methods=['GET', 'POST']) @bp.route('/login', methods=['GET', 'POST'])
def login(): def login():
if current_user.is_authenticated: if current_user.is_authenticated:
@@ -18,14 +36,17 @@ def login():
if user and user.check_password(form.password.data): if user and user.check_password(form.password.data):
login_user(user) login_user(user)
next_page = request.args.get('next') # Use validated next URL — never redirect blindly to request.args['next']
next_page = _safe_next(request.args.get('next'))
flash(f'Welcome back, {user.username}!', 'success') flash(f'Welcome back, {user.username}!', 'success')
return redirect(next_page or url_for('dashboard.index')) return redirect(next_page)
else: else:
# Generic message — don't reveal whether the username exists
flash('Invalid credentials. Please try again.', 'danger') flash('Invalid credentials. Please try again.', 'danger')
return render_template('auth/login.html', form=form) return render_template('auth/login.html', form=form)
@bp.route('/logout') @bp.route('/logout')
@login_required @login_required
def logout(): def logout():
@@ -33,6 +54,7 @@ def logout():
flash('Successfully logged out.', 'success') flash('Successfully logged out.', 'success')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@bp.route('/users') @bp.route('/users')
@login_required @login_required
@admin_required @admin_required
@@ -40,6 +62,7 @@ def list_users():
users = User.query.order_by(User.created_at.desc()).all() users = User.query.order_by(User.created_at.desc()).all()
return render_template('auth/users.html', users=users) return render_template('auth/users.html', users=users)
@bp.route('/users/new', methods=['GET', 'POST']) @bp.route('/users/new', methods=['GET', 'POST'])
@login_required @login_required
@admin_required @admin_required
@@ -53,15 +76,14 @@ def create_user():
role=form.role.data role=form.role.data
) )
user.set_password(form.password.data) user.set_password(form.password.data)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
flash(f'User {user.username} created successfully.', 'success') flash(f'User {user.username} created successfully.', 'success')
return redirect(url_for('auth.list_users')) return redirect(url_for('auth.list_users'))
return render_template('auth/user_form.html', form=form, title='Create User') return render_template('auth/user_form.html', form=form, title='Create User')
@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST']) @bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required @login_required
@admin_required @admin_required
@@ -83,6 +105,7 @@ def edit_user(user_id):
return render_template('auth/user_form.html', form=form, user=user, title='Edit User') return render_template('auth/user_form.html', form=form, user=user, title='Edit User')
@bp.route('/users/<int:user_id>/delete', methods=['POST']) @bp.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required @login_required
@admin_required @admin_required
@@ -93,9 +116,19 @@ def delete_user(user_id):
flash('Cannot delete your own account.', 'danger') flash('Cannot delete your own account.', 'danger')
return redirect(url_for('auth.list_users')) return redirect(url_for('auth.list_users'))
# Guard: block deletion if user has related records that would orphan data
# or violate FK constraints (inspections they conducted, issues assigned to them,
# or templates they created).
if user.inspections.count() > 0:
flash(
f'Cannot delete "{user.username}" — they have existing inspection records. '
'Deactivate the account instead.',
'danger'
)
return redirect(url_for('auth.list_users'))
username = user.username username = user.username
db.session.delete(user) db.session.delete(user)
db.session.commit() db.session.commit()
flash(f'User {username} deleted successfully.', 'success') flash(f'User {username} deleted successfully.', 'success')
return redirect(url_for('auth.list_users')) return redirect(url_for('auth.list_users'))
+2 -3
View File
@@ -1,6 +1,5 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required from flask_login import login_required
from flask_wtf.csrf import generate_csrf
from app import db from app import db
from app.models.facility import Facility, Area from app.models.facility import Facility, Area
from app.utils.forms import FacilityForm, AreaForm from app.utils.forms import FacilityForm, AreaForm
@@ -12,7 +11,7 @@ bp = Blueprint('facilities', __name__, url_prefix='/facilities')
@login_required @login_required
def list_facilities(): def list_facilities():
facilities = Facility.query.order_by(Facility.name).all() facilities = Facility.query.order_by(Facility.name).all()
return render_template('facilities/list.html', facilities=facilities, csrf_token=generate_csrf()) return render_template('facilities/list.html', facilities=facilities)
@bp.route('/new', methods=['GET', 'POST']) @bp.route('/new', methods=['GET', 'POST'])
@login_required @login_required
@@ -42,7 +41,7 @@ def create_facility():
def view_facility(facility_id): def view_facility(facility_id):
facility = Facility.query.get_or_404(facility_id) facility = Facility.query.get_or_404(facility_id)
areas = facility.areas.order_by(Area.name).all() areas = facility.areas.order_by(Area.name).all()
return render_template('facilities/view.html', facility=facility, areas=areas, csrf_token=generate_csrf()) return render_template('facilities/view.html', facility=facility, areas=areas)
@bp.route('/<int:facility_id>/edit', methods=['GET', 'POST']) @bp.route('/<int:facility_id>/edit', methods=['GET', 'POST'])
@login_required @login_required
+14 -4
View File
@@ -1,6 +1,5 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf.csrf import generate_csrf
from app import db from app import db
from app.models.inspection import InspectionTemplate, ChecklistItem from app.models.inspection import InspectionTemplate, ChecklistItem
from app.utils.forms import InspectionTemplateForm, ChecklistItemForm from app.utils.forms import InspectionTemplateForm, ChecklistItemForm
@@ -178,8 +177,7 @@ def form_editor(template_id):
return render_template( return render_template(
'templates/form_editor.html', 'templates/form_editor.html',
template=template, template=template,
form_schema_json=json.dumps(form_schema), form_schema_json=json.dumps(form_schema)
csrf_token=generate_csrf()
) )
@@ -196,16 +194,28 @@ def save_form_schema(template_id):
fields = data.get('fields', []) fields = data.get('fields', [])
# Hard cap on total field count to prevent oversized JSON payloads
MAX_FIELDS = 150
if len(fields) > MAX_FIELDS:
return jsonify({'success': False, 'error': f'Form may not exceed {MAX_FIELDS} fields.'}), 400
# Basic sanitisation — ensure each field has the minimum required keys # Basic sanitisation — ensure each field has the minimum required keys
# Track seen IDs to enforce uniqueness
seen_ids = set()
sanitised = [] sanitised = []
for field in fields: for field in fields:
if not isinstance(field, dict): if not isinstance(field, dict):
continue continue
if not field.get('id') or not field.get('type'): if not field.get('id') or not field.get('type'):
continue continue
# Reject duplicate field IDs
field_id = str(field.get('id', ''))
if field_id in seen_ids:
continue
seen_ids.add(field_id)
ftype = str(field.get('type', 'text')) ftype = str(field.get('type', 'text'))
entry = { entry = {
'id': str(field.get('id', '')), 'id': field_id,
'type': ftype, 'type': ftype,
'label': str(field.get('label', 'Untitled'))[:255], 'label': str(field.get('label', 'Untitled'))[:255],
'placeholder': str(field.get('placeholder', ''))[:255], 'placeholder': str(field.get('placeholder', ''))[:255],
+11 -2
View File
@@ -385,6 +385,13 @@
</div> </div>
</div> </div>
{# Schema data is stored in a data-attribute and parsed with JSON.parse() in JS.
This avoids using |safe which bypasses Jinja2 auto-escaping and could allow
stored XSS if the sanitiser ever lets a malicious value through. #}
<div id="schema-data"
data-schema="{{ form_schema_json | tojson }}"
style="display:none;"></div>
<!-- PROPERTIES --> <!-- PROPERTIES -->
<div class="props-panel"> <div class="props-panel">
<div class="props-empty" id="propsEmpty"> <div class="props-empty" id="propsEmpty">
@@ -405,7 +412,8 @@
// CONSTANTS (keep in sync with CSS vars) // CONSTANTS (keep in sync with CSS vars)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
const SAVE_URL = "{{ url_for('templates.save_form_schema', template_id=template.id) }}"; const SAVE_URL = "{{ url_for('templates.save_form_schema', template_id=template.id) }}";
const CSRF_TOKEN = "{{ csrf_token }}"; // csrf_token() is a Flask-WTF global available in all templates
const CSRF_TOKEN = "{{ csrf_token() }}";
const COLS = 12; const COLS = 12;
const CELL_W = 72; // px — matches --cell-w const CELL_W = 72; // px — matches --cell-w
@@ -486,7 +494,8 @@ function growSurface() {
// INIT // INIT
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
(function init() { (function init() {
const raw = {{ form_schema_json|safe }}; // Retrieve schema from the data-attribute (safe — no |safe bypass needed)
const raw = JSON.parse(document.getElementById('schema-data').dataset.schema || '[]');
if (Array.isArray(raw) && raw.length) { if (Array.isArray(raw) && raw.length) {
fields = raw.map(f => ({ fields = raw.map(f => ({
...f, ...f,
+38 -14
View File
@@ -1,46 +1,70 @@
import os import os
from datetime import timedelta from datetime import timedelta
from dotenv import load_dotenv
# Load .env from the project root (only takes effect locally; no-op in production
# if variables are already set in the environment)
load_dotenv()
basedir = os.path.abspath(os.path.dirname(__file__)) basedir = os.path.abspath(os.path.dirname(__file__))
class Config:
# Secret key for session management
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
# Database configuration def _require_env(key: str) -> str:
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ """Return the value of a required environment variable, raising if absent."""
'mysql+pymysql://jqc_admin:Jqc4dm1n141!@localhost/janitorial_qc' value = os.environ.get(key)
if not value:
raise RuntimeError(
f"Required environment variable '{key}' is not set. "
f"Add it to your .env file (development) or server environment (production)."
)
return value
class Config:
# ── Security ────────────────────────────────────────────────────────────
# SECRET_KEY must be set externally — no insecure fallback.
SECRET_KEY = _require_env('SECRET_KEY')
# ── Database ────────────────────────────────────────────────────────────
# DATABASE_URL must be set externally — no hardcoded credentials.
SQLALCHEMY_DATABASE_URI = _require_env('DATABASE_URL')
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = False SQLALCHEMY_ECHO = False
# Upload configuration # ── File uploads ────────────────────────────────────────────────────────
UPLOAD_FOLDER = os.path.join(basedir, 'app/static/uploads') UPLOAD_FOLDER = os.path.join(basedir, 'app/static/uploads')
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16 MB
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
# Session configuration # ── Session / cookies ───────────────────────────────────────────────────
PERMANENT_SESSION_LIFETIME = timedelta(hours=24) PERMANENT_SESSION_LIFETIME = timedelta(hours=24)
SESSION_COOKIE_SECURE = False # Change to True on production # Secure by default — subclasses must explicitly opt out for local dev.
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax' SESSION_COOKIE_SAMESITE = 'Lax'
# Mail configuration (configure later) # ── Mail ────────────────────────────────────────────────────────────────
MAIL_SERVER = os.environ.get('MAIL_SERVER') MAIL_SERVER = os.environ.get('MAIL_SERVER')
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587) MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587)
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1'] MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ('true', 'on', '1')
MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
class DevelopmentConfig(Config): class DevelopmentConfig(Config):
DEBUG = True DEBUG = True
SQLALCHEMY_ECHO = True SQLALCHEMY_ECHO = True
# Allow HTTP cookies during local development (HTTP, not HTTPS)
SESSION_COOKIE_SECURE = False
class ProductionConfig(Config): class ProductionConfig(Config):
DEBUG = False DEBUG = False
SESSION_COOKIE_SECURE = True # Inherits SESSION_COOKIE_SECURE = True from Config — no override needed.
config = { config = {
'development': DevelopmentConfig, 'development': DevelopmentConfig,
'production': ProductionConfig, 'production': ProductionConfig,
'default': DevelopmentConfig 'default': DevelopmentConfig,
} }
+12
View File
@@ -0,0 +1,12 @@
import multiprocessing
bind = "127.0.0.1:8000"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync"
worker_connections = 1000
timeout = 30
keepalive = 2
errorlog = "/home/jqc/logs/gunicorn-error.log"
accesslog = "/home/jqc/logs/gunicorn-access.log"
loglevel = "info"
+7
View File
@@ -0,0 +1,7 @@
from app import create_app
import os
app = create_app(os.getenv('FLASK_ENV') or 'production')
if __name__ == "__main__":
app.run()