fix: critical security hardening (Phase A permissions audit)
- Remove role field from UserCreate schema, hardcode 'engineer' at registration
- Escape all user content in HTML export with html.escape() (XSS fix)
- Add field_validator to reject default SECRET_KEY when DEBUG=False
- Add CHECK constraint on users.role ('engineer'|'viewer') + migration 011
- Fix test_admin fixture to properly grant is_super_admin via ORM
- Fix circular FK (users↔invite_codes) in test DB setup with DROP SCHEMA CASCADE
- Add 5 new security tests (role validation + XSS prevention)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -75,7 +75,7 @@ async def register(
|
||||
email=user_data.email,
|
||||
password_hash=get_password_hash(user_data.password),
|
||||
name=user_data.name,
|
||||
role=user_data.role,
|
||||
role="engineer",
|
||||
invite_code_id=invite_code_record.id if invite_code_record else None
|
||||
)
|
||||
db.add(new_user)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
@@ -348,9 +349,9 @@ def _generate_text_export(session: Session, options: SessionExport) -> str:
|
||||
|
||||
def _generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
"""Generate HTML export."""
|
||||
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
||||
tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session"))
|
||||
|
||||
html = ['<!DOCTYPE html>', '<html>', '<head>',
|
||||
html_parts = ['<!DOCTYPE html>', '<html>', '<head>',
|
||||
'<meta charset="UTF-8">',
|
||||
f'<title>{tree_name}</title>',
|
||||
'<style>',
|
||||
@@ -366,40 +367,40 @@ def _generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
'</head>', '<body>']
|
||||
|
||||
if options.include_tree_info:
|
||||
html.append(f'<h1>{tree_name}</h1>')
|
||||
html.append('<div class="meta">')
|
||||
html_parts.append(f'<h1>{tree_name}</h1>')
|
||||
html_parts.append('<div class="meta">')
|
||||
if session.ticket_number:
|
||||
html.append(f'<p><strong>Ticket:</strong> {session.ticket_number}</p>')
|
||||
html_parts.append(f'<p><strong>Ticket:</strong> {html.escape(session.ticket_number)}</p>')
|
||||
if session.client_name:
|
||||
html.append(f'<p><strong>Client:</strong> {session.client_name}</p>')
|
||||
html_parts.append(f'<p><strong>Client:</strong> {html.escape(session.client_name)}</p>')
|
||||
if options.include_timestamps:
|
||||
html.append(f'<p><strong>Started:</strong> {session.started_at.strftime("%Y-%m-%d %H:%M")}</p>')
|
||||
html_parts.append(f'<p><strong>Started:</strong> {session.started_at.strftime("%Y-%m-%d %H:%M")}</p>')
|
||||
if session.completed_at:
|
||||
html.append(f'<p><strong>Completed:</strong> {session.completed_at.strftime("%Y-%m-%d %H:%M")}</p>')
|
||||
html.append('</div>')
|
||||
html_parts.append(f'<p><strong>Completed:</strong> {session.completed_at.strftime("%Y-%m-%d %H:%M")}</p>')
|
||||
html_parts.append('</div>')
|
||||
|
||||
# Scratchpad / Evidence section
|
||||
scratchpad = getattr(session, 'scratchpad', '') or ''
|
||||
if scratchpad.strip():
|
||||
html.append('<h2>Evidence / Reference</h2>')
|
||||
html.append(f'<div class="scratchpad" style="white-space: pre-wrap; background: #f9f9f9; padding: 12px; border-radius: 4px; margin-bottom: 20px;">{scratchpad}</div>')
|
||||
html_parts.append('<h2>Evidence / Reference</h2>')
|
||||
html_parts.append(f'<div class="scratchpad" style="white-space: pre-wrap; background: #f9f9f9; padding: 12px; border-radius: 4px; margin-bottom: 20px;">{html.escape(scratchpad)}</div>')
|
||||
|
||||
html.append('<h2>Troubleshooting Steps</h2>')
|
||||
html_parts.append('<h2>Troubleshooting Steps</h2>')
|
||||
|
||||
for i, decision in enumerate(session.decisions, 1):
|
||||
question = decision.get("question") or decision.get("action_performed", "Step")
|
||||
answer = decision.get("answer", "")
|
||||
notes = decision.get("notes", "")
|
||||
question = html.escape(decision.get("question") or decision.get("action_performed", "Step"))
|
||||
answer = html.escape(decision.get("answer", ""))
|
||||
notes = html.escape(decision.get("notes", ""))
|
||||
|
||||
html.append('<div class="step">')
|
||||
html.append(f'<h3>Step {i}: {question}</h3>')
|
||||
html_parts.append('<div class="step">')
|
||||
html_parts.append(f'<h3>Step {i}: {question}</h3>')
|
||||
if answer:
|
||||
html.append(f'<p class="answer">Answer: {answer}</p>')
|
||||
html_parts.append(f'<p class="answer">Answer: {answer}</p>')
|
||||
if notes:
|
||||
html.append(f'<p class="notes">Notes: {notes}</p>')
|
||||
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
|
||||
if options.include_timestamps and decision.get("timestamp"):
|
||||
html.append(f'<p class="timestamp">{decision["timestamp"]}</p>')
|
||||
html.append('</div>')
|
||||
html_parts.append(f'<p class="timestamp">{html.escape(str(decision["timestamp"]))}</p>')
|
||||
html_parts.append('</div>')
|
||||
|
||||
html.extend(['</body>', '</html>'])
|
||||
return "\n".join(html)
|
||||
html_parts.extend(['</body>', '</html>'])
|
||||
return "\n".join(html_parts)
|
||||
|
||||
Reference in New Issue
Block a user