XSS protection added to comments
All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/vui) (push) Successful in 1m1s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/vui-data-loader) (push) Successful in 8s

This commit is contained in:
2025-11-27 23:51:04 +01:00
parent dd6d0fae46
commit 7e89ffb6f1
4 changed files with 82 additions and 14 deletions

View File

@@ -25,7 +25,7 @@ spec:
mountPath: /data mountPath: /data
containers: containers:
- name: web - name: web
image: git.baumann.gr/adebaumann/vui:0.958-comments image: git.baumann.gr/adebaumann/vui:0.958-comments-XSS
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8000 - containerPort: 8000

View File

@@ -219,6 +219,36 @@
<!-- JavaScript for Comments --> <!-- JavaScript for Comments -->
<script> <script>
// Content Security Policy for comment system
document.addEventListener('DOMContentLoaded', function() {
// Prevent inline script execution in dynamically loaded content
const commentsContainer = document.getElementById('commentsContainer');
if (commentsContainer) {
// Use DOMPurify-like approach - only allow safe HTML
const allowedTags = ['br', 'small', 'div', 'span', 'button'];
const allowedAttributes = ['class', 'data-comment-id', 'aria-hidden'];
// Monitor for any script injection attempts
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === 1) { // Element node
const tagName = node.tagName.toLowerCase();
if (tagName === 'script') {
console.warn('Script injection attempt blocked');
node.parentNode.removeChild(node);
}
}
});
});
});
observer.observe(commentsContainer, {
childList: true,
subtree: true
});
}
});
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
let currentVorgabeId = null; let currentVorgabeId = null;
let currentVorgabeNummer = null; let currentVorgabeNummer = null;
@@ -270,7 +300,7 @@ document.addEventListener('DOMContentLoaded', function() {
<strong>${comment.user}</strong> <strong>${comment.user}</strong>
<small class="text-muted">(${comment.created_at})</small> <small class="text-muted">(${comment.created_at})</small>
${comment.updated_at !== comment.created_at ? `<small class="text-muted">(bearbeitet: ${comment.updated_at})</small>` : ''} ${comment.updated_at !== comment.created_at ? `<small class="text-muted">(bearbeitet: ${comment.updated_at})</small>` : ''}
<div class="mt-1">${comment.text.replace(/\n/g, '<br>')}</div> <div class="mt-1">${comment.text}</div>
</div> </div>
${canDelete ? ` ${canDelete ? `
<button class="btn btn-sm btn-outline-danger ml-2 delete-comment-btn" data-comment-id="${comment.id}"> <button class="btn btn-sm btn-outline-danger ml-2 delete-comment-btn" data-comment-id="${comment.id}">

View File

@@ -4,6 +4,8 @@ from django.http import JsonResponse
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.utils.html import escape, mark_safe
from django.utils.safestring import SafeString
import json import json
from .models import Dokument, Vorgabe, VorgabeKurztext, VorgabeLangtext, Checklistenfrage, VorgabeComment from .models import Dokument, Vorgabe, VorgabeKurztext, VorgabeLangtext, Checklistenfrage, VorgabeComment
from abschnitte.utils import render_textabschnitte from abschnitte.utils import render_textabschnitte
@@ -264,16 +266,21 @@ def get_vorgabe_comments(request, vorgabe_id):
comments_data = [] comments_data = []
for comment in comments: for comment in comments:
# Escape HTML but preserve line breaks
escaped_text = escape(comment.text).replace('\n', '<br>')
comments_data.append({ comments_data.append({
'id': comment.id, 'id': comment.id,
'text': comment.text, 'text': escaped_text,
'user': comment.user.username, 'user': escape(comment.user.username),
'created_at': comment.created_at.strftime('%d.%m.%Y %H:%M'), 'created_at': comment.created_at.strftime('%d.%m.%Y %H:%M'),
'updated_at': comment.updated_at.strftime('%d.%m.%Y %H:%M'), 'updated_at': comment.updated_at.strftime('%d.%m.%Y %H:%M'),
'is_own': comment.user == request.user 'is_own': comment.user == request.user
}) })
return JsonResponse({'comments': comments_data}) response = JsonResponse({'comments': comments_data})
response['Content-Security-Policy'] = "default-src 'self'"
response['X-Content-Type-Options'] = 'nosniff'
return response
@require_POST @require_POST
@@ -286,31 +293,53 @@ def add_vorgabe_comment(request, vorgabe_id):
data = json.loads(request.body) data = json.loads(request.body)
text = data.get('text', '').strip() text = data.get('text', '').strip()
# Validate input
if not text: if not text:
return JsonResponse({'error': 'Kommentar darf nicht leer sein'}, status=400) return JsonResponse({'error': 'Kommentar darf nicht leer sein'}, status=400)
if len(text) > 2000: # Reasonable length limit
return JsonResponse({'error': 'Kommentar ist zu lang (max 2000 Zeichen)'}, status=400)
# Additional XSS prevention - check for dangerous patterns
dangerous_patterns = ['<script', 'javascript:', 'onload=', 'onerror=', 'onclick=', 'onmouseover=']
text_lower = text.lower()
for pattern in dangerous_patterns:
if pattern in text_lower:
return JsonResponse({'error': 'Kommentar enthält ungültige Zeichen'}, status=400)
comment = VorgabeComment.objects.create( comment = VorgabeComment.objects.create(
vorgabe=vorgabe, vorgabe=vorgabe,
user=request.user, user=request.user,
text=text text=text
) )
return JsonResponse({ # Escape HTML but preserve line breaks
escaped_text = escape(comment.text).replace('\n', '<br>')
response = JsonResponse({
'success': True, 'success': True,
'comment': { 'comment': {
'id': comment.id, 'id': comment.id,
'text': comment.text, 'text': escaped_text,
'user': comment.user.username, 'user': escape(comment.user.username),
'created_at': comment.created_at.strftime('%d.%m.%Y %H:%M'), 'created_at': comment.created_at.strftime('%d.%m.%Y %H:%M'),
'updated_at': comment.updated_at.strftime('%d.%m.%Y %H:%M'), 'updated_at': comment.updated_at.strftime('%d.%m.%Y %H:%M'),
'is_own': True 'is_own': True
} }
}) })
response['Content-Security-Policy'] = "default-src 'self'"
response['X-Content-Type-Options'] = 'nosniff'
return response
except json.JSONDecodeError: except json.JSONDecodeError:
return JsonResponse({'error': 'Ungültige Daten'}, status=400) response = JsonResponse({'error': 'Ungültige Daten'}, status=400)
response['Content-Security-Policy'] = "default-src 'self'"
response['X-Content-Type-Options'] = 'nosniff'
return response
except Exception as e: except Exception as e:
return JsonResponse({'error': str(e)}, status=500) response = JsonResponse({'error': 'Serverfehler'}, status=500)
response['Content-Security-Policy'] = "default-src 'self'"
response['X-Content-Type-Options'] = 'nosniff'
return response
@require_POST @require_POST
@@ -321,10 +350,19 @@ def delete_vorgabe_comment(request, comment_id):
# Check if user can delete this comment # Check if user can delete this comment
if comment.user != request.user and not request.user.is_staff: if comment.user != request.user and not request.user.is_staff:
return JsonResponse({'error': 'Keine Berechtigung zum Löschen dieses Kommentars'}, status=403) response = JsonResponse({'error': 'Keine Berechtigung zum Löschen dieses Kommentars'}, status=403)
response['Content-Security-Policy'] = "default-src 'self'"
response['X-Content-Type-Options'] = 'nosniff'
return response
try: try:
comment.delete() comment.delete()
return JsonResponse({'success': True}) response = JsonResponse({'success': True})
response['Content-Security-Policy'] = "default-src 'self'"
response['X-Content-Type-Options'] = 'nosniff'
return response
except Exception as e: except Exception as e:
return JsonResponse({'error': str(e)}, status=500) response = JsonResponse({'error': 'Serverfehler'}, status=500)
response['Content-Security-Policy'] = "default-src 'self'"
response['X-Content-Type-Options'] = 'nosniff'
return response

View File

@@ -215,7 +215,7 @@
</p> </p>
</div> </div>
<div class="col-sm-6 text-right"> <div class="col-sm-6 text-right">
<p class="text-muted">Version {{ version|default:"0.957-xss" }}</p> <p class="text-muted">Version {{ version|default:"0.958-comments-XSS" }}</p>
</div> </div>
</div> </div>
</div> </div>