Sep 16 - Optimize code, part 1
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
utils/excel_safety.py
|
||||
=====================
|
||||
Protection against spreadsheet formula injection in exports.
|
||||
|
||||
Text that reaches an export can come from the public check-in page (address,
|
||||
device, selected location), from imported time-clock files, or from the legacy
|
||||
database. In an .xlsx file a cell whose value starts with "=" is stored as a
|
||||
formula; in a CSV a value starting with = + - @ tab or CR is evaluated when the
|
||||
file is opened in Excel. These helpers keep such text inert while the formulas
|
||||
the exports generate on purpose (map HYPERLINKs, SUM / SUMIF totals) keep working.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Formulas the exports generate themselves. Any other cell value starting with
|
||||
# "=" is written as plain text. HYPERLINKs are accepted only with a Google Maps
|
||||
# target and correctly escaped string arguments ("" for a quote), so user text
|
||||
# inside them can never close the string and append another function.
|
||||
_ALLOWED_FORMULA_PATTERNS = (
|
||||
re.compile(
|
||||
r'^=HYPERLINK\("(?:https://www\.google\.com/maps/place/|http://maps\.google\.com/maps\?q=)'
|
||||
r'(?:[^"]|"")*","(?:[^"]|"")*"\)$'
|
||||
),
|
||||
re.compile(r'^=SUM\(\$?[A-Z]{1,3}\$?\d+:\$?[A-Z]{1,3}\$?\d+\)$'),
|
||||
re.compile(
|
||||
r'^=SUMIF\(\$?[A-Z]{1,3}\$?\d+:\$?[A-Z]{1,3}\$?\d+,'
|
||||
r'\$?[A-Z]{1,3}\$?\d+,'
|
||||
r'\$?[A-Z]{1,3}\$?\d+:\$?[A-Z]{1,3}\$?\d+\)$'
|
||||
),
|
||||
)
|
||||
|
||||
_CSV_FORMULA_PREFIXES = ('=', '+', '-', '@', '\t', '\r')
|
||||
|
||||
|
||||
def excel_escape_string(text):
|
||||
"""Escape text for use inside a double-quoted string argument of an Excel formula."""
|
||||
value = '' if text is None else str(text)
|
||||
return value.replace('"', '""').replace('\r', ' ').replace('\n', ' ')
|
||||
|
||||
|
||||
def excel_hyperlink(url, display_text):
|
||||
"""=HYPERLINK("url","display text") with both arguments safely quoted."""
|
||||
return f'=HYPERLINK("{excel_escape_string(url)}","{excel_escape_string(display_text)}")'
|
||||
|
||||
|
||||
def is_allowed_formula(value):
|
||||
"""True for the formula shapes the exports generate on purpose."""
|
||||
return any(pattern.match(value) for pattern in _ALLOWED_FORMULA_PATTERNS)
|
||||
|
||||
|
||||
def neutralize_unexpected_formulas(workbook):
|
||||
"""
|
||||
Store every formula the app did not generate itself as plain text.
|
||||
|
||||
Call right before saving an export workbook. Returns the number of cells
|
||||
changed. The cell keeps its visible text; it is just never evaluated.
|
||||
"""
|
||||
changed = 0
|
||||
for worksheet in workbook.worksheets:
|
||||
for row in worksheet.iter_rows():
|
||||
for cell in row:
|
||||
value = cell.value
|
||||
if isinstance(value, str) and value.startswith('=') and not is_allowed_formula(value):
|
||||
cell.data_type = 's' # written as a string, not as <f>
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
def csv_safe(value):
|
||||
"""A CSV value that Excel will not evaluate as a formula (leading apostrophe)."""
|
||||
if isinstance(value, str) and value.startswith(_CSV_FORMULA_PREFIXES):
|
||||
return "'" + value
|
||||
return value
|
||||
+57
-1
@@ -16,7 +16,7 @@ from datetime import datetime, date, time, timedelta
|
||||
from functools import wraps
|
||||
|
||||
import qrcode
|
||||
from flask import session, redirect, flash, request, url_for
|
||||
from flask import session, redirect, flash, request, url_for, jsonify
|
||||
from user_agents import parse
|
||||
|
||||
from extensions import logger_handler
|
||||
@@ -323,6 +323,62 @@ def staff_or_admin_required(f):
|
||||
return decorated_function
|
||||
|
||||
|
||||
# Role sets for areas the sidebar only offers to some roles. Enforced on the
|
||||
# server so a user cannot reach them by typing the URL (base_authenticated.html
|
||||
# is the reference for who sees what).
|
||||
PAYROLL_AREA_ROLES = ('admin', 'payroll', 'accounting') # Time Attendance, Legacy, Employees, Statistics
|
||||
QR_MANAGEMENT_ROLES = ('admin', 'staff', 'payroll', 'accounting') # create / edit / bulk-import QR codes
|
||||
|
||||
|
||||
def _wants_json_response():
|
||||
"""API and fetch() callers get JSON errors instead of a redirect to an HTML page."""
|
||||
return (request.path.startswith('/api/')
|
||||
or request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
or request.accept_mimetypes.best == 'application/json')
|
||||
|
||||
|
||||
def _role_denied_response(allowed_roles):
|
||||
"""None when the logged-in user's role is allowed; otherwise the response to return."""
|
||||
if 'user_id' not in session:
|
||||
if _wants_json_response():
|
||||
return jsonify({'success': False, 'error': 'Authentication required'}), 401
|
||||
flash('Please log in to access this page.', 'error')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
role = session.get('role')
|
||||
if role in allowed_roles:
|
||||
return None
|
||||
|
||||
logger_handler.logger.warning(
|
||||
f"Access denied: user {session.get('username')} (role {role}) -> {request.endpoint}"
|
||||
)
|
||||
if _wants_json_response():
|
||||
return jsonify({'success': False, 'error': 'Access denied'}), 403
|
||||
flash('You do not have permission to access that page.', 'error')
|
||||
return redirect(url_for('dashboard.dashboard'))
|
||||
|
||||
|
||||
def roles_required(*allowed_roles):
|
||||
"""Decorator: the user must be logged in with one of allowed_roles."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
denied = _role_denied_response(allowed_roles)
|
||||
if denied is not None:
|
||||
return denied
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def restrict_blueprint_to_roles(blueprint, allowed_roles):
|
||||
"""Apply the role check to every route of a blueprint (one line per module)."""
|
||||
@blueprint.before_request
|
||||
def _enforce_blueprint_roles():
|
||||
return _role_denied_response(allowed_roles)
|
||||
return blueprint
|
||||
|
||||
|
||||
def is_admin_user(user_id):
|
||||
"""Helper function to safely check if user is admin"""
|
||||
from extensions import db
|
||||
|
||||
Reference in New Issue
Block a user