Code reviewed and issue fixed.

This commit is contained in:
2026-03-27 14:01:11 -04:00
parent d8828d487a
commit 359c801027
10 changed files with 788 additions and 113 deletions
+31 -4
View File
@@ -54,8 +54,16 @@ def log_action(user_id, action, entity_type=None, entity_id=None, details=None):
own business objects. This ensures the log entry is only persisted when
the parent operation succeeds — an independent commit here would leave
orphaned log entries for operations that were subsequently rolled back.
Error isolation
---------------
On failure, only the log entry itself is expelled from the session via
expunge(). db.session.rollback() is intentionally NOT called here because
that would wipe the entire session — silently undoing the parent business
operation (ticket creation, user update, etc.) that triggered this log call.
"""
ip = _get_real_ip()
entry = None
try:
entry = ActivityLog(
@@ -74,8 +82,14 @@ def log_action(user_id, action, entity_type=None, entity_id=None, details=None):
f'user_id={user_id} ip={ip}'
)
except Exception as exc:
db.session.rollback()
logger.error(f'[ACTIVITY LOG ERROR] {exc}')
# Expunge only the failed log entry — do NOT roll back the full session,
# as that would undo the parent operation that called this function.
if entry is not None:
try:
db.session.expunge(entry)
except Exception:
pass
logger.error(f'[ACTIVITY LOG ERROR] action={action} entity={entity_type}:{entity_id} error={exc}')
def log_ticket_history(ticket, field_name, old_value, new_value, changed_by_id):
@@ -85,8 +99,15 @@ def log_ticket_history(ticket, field_name, old_value, new_value, changed_by_id):
----------------
Like log_action, this function does NOT commit — the caller is responsible
for committing the session after all field changes have been recorded.
Error isolation
---------------
On failure, only the failed history entry is expelled from the session.
db.session.rollback() is intentionally NOT called here — that would undo
the parent ticket update that triggered this history recording.
"""
from app.models import TicketHistory
entry = None
try:
entry = TicketHistory(
ticket_id = ticket.id,
@@ -102,5 +123,11 @@ def log_ticket_history(ticket, field_name, old_value, new_value, changed_by_id):
f'"{old_value}" -> "{new_value}" by user_id={changed_by_id}'
)
except Exception as exc:
db.session.rollback()
logger.error(f'[TICKET HISTORY ERROR] {exc}')
# Expunge only the failed history entry — do NOT roll back the full
# session, as that would undo the parent ticket update.
if entry is not None:
try:
db.session.expunge(entry)
except Exception:
pass
logger.error(f'[TICKET HISTORY ERROR] ticket_id={ticket.id} field={field_name} error={exc}')
+1 -1
View File
@@ -251,7 +251,7 @@ def notify_comment_added(comment):
ticket_id = ticket.id,
link = f'/tickets/{ticket.id}#comment-{comment.id}',
)
user = User.query.get(user_id)
user = db.session.get(User, user_id)
if user and user.email_notif and not is_internal:
html = render_template_string(_STATUS_UPDATE_EMAIL,
ticket_number = ticket.ticket_number,
+179
View File
@@ -0,0 +1,179 @@
"""
Shared validation helpers used across multiple route modules.
Centralising these here prevents logic divergence that arises from
copy-pasting validation code into auth.py, admin.py, and future modules.
"""
import logging
logger = logging.getLogger(__name__)
# ─── Password Validation ──────────────────────────────────────────────────────
def validate_password(password: str, confirm: str) -> str | None:
"""Validate a new password and its confirmation field.
Returns an error message string if validation fails, or None if the
password is acceptable. Callers should flash the returned message with
the 'danger' category and return early.
Rules
-----
- Password and confirmation must match.
- Minimum length: 8 characters.
Parameters
----------
password : str the candidate password (plain text)
confirm : str the confirmation field value
"""
if password != confirm:
return 'Passwords do not match.'
if len(password) < 8:
return 'Password must be at least 8 characters.'
return None
# ─── File MIME-Type Validation ────────────────────────────────────────────────
# Magic-byte signatures for every extension in ALLOWED_EXT / _KB_ALLOWED_EXT.
# Format: extension → list of (offset, bytes) tuples that must ALL be present.
# Using raw magic bytes avoids a dependency on python-magic / libmagic while
# still catching the "renamed shell.php → shell.pdf" class of attack.
#
# References: https://en.wikipedia.org/wiki/List_of_file_signatures
_MAGIC: dict[str, list[tuple[int, bytes]]] = {
'png' : [(0, b'\x89PNG\r\n\x1a\n')],
'jpg' : [(0, b'\xff\xd8\xff')],
'jpeg': [(0, b'\xff\xd8\xff')],
'gif' : [(0, b'GIF87a'), (0, b'GIF89a')], # either signature is valid
'webp': [(0, b'RIFF'), (8, b'WEBP')],
'pdf' : [(0, b'%PDF')],
'zip' : [(0, b'PK\x03\x04')],
# Office Open XML (.docx, .xlsx, .pptx) are ZIP archives internally
'docx': [(0, b'PK\x03\x04')],
'xlsx': [(0, b'PK\x03\x04')],
'pptx': [(0, b'PK\x03\x04')],
# Legacy OLE2 compound document (.doc, .xls, .ppt)
'doc' : [(0, b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1')],
'xls' : [(0, b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1')],
'ppt' : [(0, b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1')],
# Plain-text formats — no reliable magic bytes; skip byte check
'txt' : [],
'csv' : [],
'log' : [],
}
# Maximum number of bytes to read for magic-byte inspection.
# 12 bytes covers all signatures above (WEBP needs offset 8 + 4 = 12).
_MAGIC_READ_BYTES = 12
# Per-file size cap (bytes). Mirrors MAX_CONTENT_LENGTH so a single
# large file cannot saturate the total-request budget on its own.
_MAX_FILE_BYTES = 16 * 1024 * 1024 # 16 MB
def validate_file(file, allowed_extensions: set[str]) -> str | None:
"""Validate an uploaded file object by extension, size, and magic bytes.
Returns an error message string if validation fails, or None on success.
The file stream seek position is reset to 0 after inspection so callers
can still read or save the file normally.
Parameters
----------
file : werkzeug FileStorage object
allowed_extensions : set of lowercase extension strings (without leading dot)
Checks performed
----------------
1. Extension is in the allowed set.
2. File does not exceed the per-file size cap (_MAX_FILE_BYTES).
3. Magic bytes in the file content match the declared extension, where
a known signature exists. Plain-text types (txt, csv, log) are
accepted on extension alone since they have no reliable magic bytes.
"""
filename = file.filename or ''
if '.' not in filename:
return 'File has no extension.'
ext = filename.rsplit('.', 1)[1].lower()
# ── 1. Extension check ────────────────────────────────────────────────────
if ext not in allowed_extensions:
return f'File type ".{ext}" is not permitted.'
# ── 2. Size check ─────────────────────────────────────────────────────────
# Read the file in chunks to measure size without loading it all into RAM,
# then seek back to the start so the caller can still save it.
file.stream.seek(0, 2) # seek to end
file_size = file.stream.tell() # position == size
file.stream.seek(0) # rewind
if file_size > _MAX_FILE_BYTES:
mb = _MAX_FILE_BYTES // (1024 * 1024)
return f'File exceeds the {mb} MB per-file size limit.'
# ── 3. Magic-byte check ───────────────────────────────────────────────────
signatures = _MAGIC.get(ext)
if signatures is None:
# Extension is allowed but has no entry in _MAGIC — treat as safe.
# This handles future extensions added to ALLOWED_EXT without a
# corresponding _MAGIC entry; log a warning for visibility.
logger.warning(f'[FILE VALIDATION] No magic signature defined for ext="{ext}"; skipping byte check.')
return None
if not signatures:
# Explicit empty list means "no magic bytes available for this type"
# (txt, csv, log) — accepted on extension alone.
return None
header = file.stream.read(_MAGIC_READ_BYTES)
file.stream.seek(0) # rewind after inspection
# For extensions with multiple valid signatures (e.g. GIF87a / GIF89a)
# the file is valid if ANY of the listed signatures matches.
matched = False
for sigs in _group_by_alternative(signatures):
# Each alternative is a list of (offset, bytes) pairs that must ALL match.
if all(header[offset:offset + len(magic)] == magic for offset, magic in sigs):
matched = True
break
if not matched:
logger.warning(
f'[FILE VALIDATION] Magic-byte mismatch: filename="{filename}" ext="{ext}" '
f'header={header.hex()}'
)
return f'File content does not match its declared type (.{ext}).'
return None
def _group_by_alternative(
signatures: list[tuple[int, bytes]],
) -> list[list[tuple[int, bytes]]]:
"""Split a flat signature list into per-alternative groups.
For most extensions there is a single signature, so this returns
[[sig1, sig2, ...]]. For GIF (two valid headers) it returns
[[gif87_sig], [gif89_sig]] so the caller can treat each inner list as
a complete match candidate.
The rule: each entry with offset=0 starts a new alternative group.
Entries with offset>0 are appended to the current group (they are
additional constraints on the same file type, e.g. WEBP needs both
offset-0 'RIFF' and offset-8 'WEBP').
"""
groups: list[list[tuple[int, bytes]]] = []
for offset, magic in signatures:
if offset == 0:
groups.append([(offset, magic)])
else:
if groups:
groups[-1].append((offset, magic))
else:
groups.append([(offset, magic)])
return groups if groups else [[]]