04/06 remediate some issues

This commit is contained in:
2026-04-06 16:31:50 -04:00
parent d7293f2747
commit 3b17705911
8 changed files with 211 additions and 28 deletions
+16 -4
View File
@@ -9,7 +9,7 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request,
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
import bleach
from app import db
from app import db, limiter
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
KBAttachment, UserRole, TicketStatus)
from app.services.log_service import log_action
@@ -404,8 +404,10 @@ def all_tickets():
if search:
from app.models import Comment
from sqlalchemy import func
submitter_alias = db.aliased(User)
assignee_alias = db.aliased(User)
stripped_body = func.regexp_replace(Comment.body, r'<[^>]+>', '', 'g')
q = (
q
.outerjoin(submitter_alias, submitter_alias.id == Ticket.created_by_id)
@@ -417,7 +419,7 @@ def all_tickets():
Ticket.description.ilike(f'%{search}%') |
submitter_alias.full_name.ilike(f'%{search}%') |
assignee_alias.full_name.ilike(f'%{search}%') |
Comment.body.ilike(f'%{search}%')
stripped_body.ilike(f'%{search}%')
)
.distinct()
)
@@ -540,6 +542,7 @@ def kb_upload_image():
# ── File-serve route (images embedded in articles + attachment downloads) ─────
@admin_bp.route('/kb/files/<string:stored_name>')
@login_required
def kb_serve_file(stored_name):
"""Serve a KB attachment file. Login required — no public access.
@@ -551,6 +554,12 @@ def kb_serve_file(stored_name):
traverse outside the upload directory. <string:> disallows slashes,
restricting the value to a flat filename — matching the UUID-based
stored_name format (e.g. 'a1b2c3d4e5f6....png') used by all upload helpers.
@login_required is applied here because the upload directory is shared
across KB files, ticket attachments, comment images, and user avatars.
Without authentication, an unauthenticated caller who knows or guesses
any stored_name (UUID-based) could retrieve arbitrary files from the
shared uploads folder — including confidential ticket attachments.
"""
upload_dir = current_app.config['UPLOAD_FOLDER']
return send_from_directory(upload_dir, stored_name)
@@ -572,7 +581,6 @@ def kb_delete_attachment(article_id, att_id):
logger.info(f'[KB ATTACHMENT DELETE] att_id={att.id} article_id={article_id} by user_id={current_user.id}')
db.session.delete(att)
db.session.commit()
logger.info(f'[KB ATTACHMENT DELETE] att_id={att.id} article_id={article_id} completed')
# Return JSON so the edit page can remove the row without a full reload
return jsonify({'ok': True, 'att_id': att.id})
@@ -766,6 +774,7 @@ def activity_logs():
@admin_bp.route('/tickets/export')
@login_required
@admin_required
@limiter.limit('10 per hour')
def export_tickets():
"""Stream a CSV of tickets matching the current filter params."""
status = request.args.get('status', '')
@@ -780,8 +789,10 @@ def export_tickets():
elif assigned == 'unassigned': q = q.filter_by(assigned_to_id=None)
if search:
from sqlalchemy import func
submitter_alias = db.aliased(User)
assignee_alias = db.aliased(User)
stripped_body = func.regexp_replace(Comment.body, r'<[^>]+>', '', 'g')
q = (
q
.outerjoin(submitter_alias, submitter_alias.id == Ticket.created_by_id)
@@ -793,7 +804,7 @@ def export_tickets():
Ticket.description.ilike(f'%{search}%') |
submitter_alias.full_name.ilike(f'%{search}%') |
assignee_alias.full_name.ilike(f'%{search}%') |
Comment.body.ilike(f'%{search}%')
stripped_body.ilike(f'%{search}%')
)
.distinct()
)
@@ -845,6 +856,7 @@ def _roles():
@admin_bp.route('/settings', methods=['GET', 'POST'])
@login_required
@admin_required
def settings():
from app.models import SystemSetting
+28 -4
View File
@@ -42,22 +42,46 @@ def _call_groq(api_key, history, user_msg):
The system prompt is prepended as a system message. Prior history and the
new user message are appended in order.
History is capped at the most recent _MAX_HISTORY_TURNS turns and each
message content is truncated to _MAX_MSG_CHARS characters before being
forwarded. This prevents a malicious or runaway client from exhausting
the model's context window or inflating token costs.
Raises requests.HTTPError or requests.exceptions.RequestException on failure.
"""
# ── History sanitisation ──────────────────────────────────────────────────
# 1. Strip create_ticket action blocks — re-sending them causes the model
# to re-trigger ticket creation on every subsequent turn.
# 2. Cap to the most recent N turns so the client cannot inflate context.
# 3. Truncate each message's content to avoid per-message token blowout.
_MAX_HISTORY_TURNS = 20
_MAX_MSG_CHARS = 2000
model = current_app.config.get('GROQ_MODEL', _GROQ_MODEL)
# Strip any assistant messages containing a create_ticket action block.
# These should never reach the model — if they do, the model re-triggers
# ticket creation on every subsequent turn.
clean_history = [
msg for msg in history
if not (msg.get('role') == 'assistant' and '"action": "create_ticket"' in msg.get('content', ''))
]
# Keep only the most recent turns after filtering
if len(clean_history) > _MAX_HISTORY_TURNS:
logger.warning(
f'[CHATBOT] history truncated from {len(clean_history)} to '
f'{_MAX_HISTORY_TURNS} turns for user_id={current_user.id}'
)
clean_history = clean_history[-_MAX_HISTORY_TURNS:]
# Truncate individual message content lengths
clean_history = [
{**msg, 'content': msg.get('content', '')[:_MAX_MSG_CHARS]}
for msg in clean_history
]
messages = (
[{'role': 'system', 'content': _SYSTEM_PROMPT}]
+ clean_history
+ [{'role': 'user', 'content': user_msg}]
+ [{'role': 'user', 'content': user_msg[:_MAX_MSG_CHARS]}]
)
resp = requests.post(
_GROQ_API_URL,
+47 -5
View File
@@ -25,6 +25,23 @@ logger = logging.getLogger(__name__)
ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx', 'txt', 'zip', 'log'}
def _strip_html(text: str) -> str:
"""Remove HTML tags from *text* for plain-text search matching.
Comment bodies are stored as sanitized HTML (rendered at write time via
render_comment_body). Searching with ilike('%term%') against raw HTML
produces two problems:
1. A search for 'bold' misses '<strong>bold</strong>' in the stored body.
2. HTML tag names ('strong', 'pre') can accidentally match search terms.
Stripping tags before comparison gives consistent, tag-agnostic results.
Uses a simple regex rather than a full HTML parser sufficient for the
sanitized subset of HTML that bleach allows in comment bodies.
"""
import re
return re.sub(r'<[^>]+>', '', text)
def _resolve_mime_type(att):
"""Return a reliable MIME type for an attachment.
@@ -195,8 +212,13 @@ def ticket_list():
if search:
# Extend search to cover comments and assignee name via outer joins.
# distinct() prevents duplicate ticket rows when multiple comments match.
# Comment bodies are stored as sanitized HTML — use REGEXP_REPLACE to
# strip tags at the SQL level before matching so 'bold' finds
# '<strong>bold</strong>' and HTML tag names don't pollute results.
from app.models import Comment
from sqlalchemy import func
assignee_alias = db.aliased(User)
stripped_body = func.regexp_replace(Comment.body, r'<[^>]+>', '', 'g')
query = (
query
.outerjoin(Comment, Comment.ticket_id == Ticket.id)
@@ -205,7 +227,7 @@ def ticket_list():
Ticket.title.ilike(f'%{search}%') |
Ticket.ticket_number.ilike(f'%{search}%') |
Ticket.description.ilike(f'%{search}%') |
Comment.body.ilike(f'%{search}%') |
stripped_body.ilike(f'%{search}%') |
assignee_alias.full_name.ilike(f'%{search}%')
)
.distinct()
@@ -389,12 +411,32 @@ def update_ticket(ticket_id):
@tickets_bp.route('/comments/<int:comment_id>/delete', methods=['POST'])
@login_required
def delete_comment(comment_id):
comment = db.session.get(Comment, comment_id) or abort(404)
if not current_user.is_it_staff and comment.author_id != current_user.id:
abort(403)
comment = db.session.get(Comment, comment_id) or abort(404)
ticket_id = comment.ticket_id
# Authorization: IT staff may always delete any comment.
# Employees may only delete their own comments, and only while the
# ticket is still open or in-progress. Allowing deletion on resolved
# or closed tickets would silently alter the historical record of a
# completed support interaction.
if not current_user.is_it_staff:
if comment.author_id != current_user.id:
abort(403)
ticket = db.session.get(Ticket, ticket_id) or abort(404)
if ticket.status in (TicketStatus.RESOLVED, TicketStatus.CLOSED):
logger.warning(
f'[COMMENT DELETE BLOCKED] comment_id={comment.id} '
f'ticket_id={ticket_id} status={ticket.status} '
f'user_id={current_user.id} — ticket is {ticket.status}'
)
flash('Comments cannot be deleted on resolved or closed tickets.', 'warning')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
# Include a truncated snapshot of the body in the audit log so the
# content is recoverable from logs even after the DB row is gone.
body_snapshot = comment.body[:200].replace('\n', ' ')
log_action(current_user.id, 'comment_delete', 'comment', comment.id,
f'ticket_id={ticket_id}')
f'ticket_id={ticket_id} body_snapshot="{body_snapshot}"')
logger.info(f'[COMMENT DELETE] comment_id={comment.id} ticket_id={ticket_id} by user_id={current_user.id}')
db.session.delete(comment)
db.session.commit()