security: Fix critical vulnerabilities in ZOP Kaszubia module

- Fix XSS: innerHTML → textContent for modal messages
- Fix XSS: Safe DOM element creation for toast notifications
- Add project_id validation in admin_zopk_news_add
- Add URL protocol validation (allow only http/https)
- Hide exception details from API responses (log instead)
- Add rate limiting (60/min) on public ZOPK routes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-11 21:07:13 +01:00
parent 9a527febf3
commit 91fea3ba2c
2 changed files with 35 additions and 7 deletions

31
app.py
View File

@ -7780,6 +7780,7 @@ def release_notes():
# ============================================================
@app.route('/zopk')
@limiter.limit("60 per minute") # SECURITY: Rate limit public ZOPK page
def zopk_index():
"""
Public knowledge base page for ZOPK.
@ -7830,6 +7831,7 @@ def zopk_index():
@app.route('/zopk/projekty/<slug>')
@limiter.limit("60 per minute") # SECURITY: Rate limit public ZOPK project pages
def zopk_project_detail(slug):
"""Project detail page"""
from database import ZOPKProject, ZOPKNews, ZOPKResource, ZOPKCompanyLink
@ -7869,6 +7871,7 @@ def zopk_project_detail(slug):
@app.route('/zopk/aktualnosci')
@limiter.limit("60 per minute") # SECURITY: Rate limit public ZOPK news list
def zopk_news_list():
"""All ZOPK news - paginated"""
from database import ZOPKProject, ZOPKNews
@ -8153,7 +8156,7 @@ def admin_zopk_news_add():
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
from database import ZOPKNews
from database import ZOPKNews, ZOPKProject
import hashlib
db = SessionLocal()
@ -8169,6 +8172,25 @@ def admin_zopk_news_add():
if not title or not url:
return jsonify({'success': False, 'error': 'Tytuł i URL są wymagane'}), 400
# SECURITY: Validate URL protocol (block javascript:, data:, etc.)
from urllib.parse import urlparse
parsed = urlparse(url)
allowed_protocols = ('http', 'https')
if parsed.scheme.lower() not in allowed_protocols:
return jsonify({'success': False, 'error': 'Nieprawidłowy protokół URL. Dozwolone: http, https'}), 400
# SECURITY: Validate project_id if provided
if project_id:
try:
project_id = int(project_id)
project = db.query(ZOPKProject).filter(ZOPKProject.id == project_id).first()
if not project:
return jsonify({'success': False, 'error': 'Nieprawidłowy ID projektu'}), 400
except (ValueError, TypeError):
return jsonify({'success': False, 'error': 'ID projektu musi być liczbą'}), 400
else:
project_id = None
# Generate URL hash for deduplication
url_hash = hashlib.sha256(url.encode()).hexdigest()
@ -8178,8 +8200,6 @@ def admin_zopk_news_add():
return jsonify({'success': False, 'error': 'Ten artykuł już istnieje w bazie'}), 400
# Extract domain from URL
from urllib.parse import urlparse
parsed = urlparse(url)
source_domain = parsed.netloc.replace('www.', '')
news = ZOPKNews(
@ -8194,7 +8214,7 @@ def admin_zopk_news_add():
moderated_by=current_user.id,
moderated_at=datetime.now(),
published_at=datetime.now(),
project_id=project_id if project_id else None
project_id=project_id
)
db.add(news)
db.commit()
@ -8207,7 +8227,8 @@ def admin_zopk_news_add():
except Exception as e:
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
logger.error(f"Error adding ZOPK news: {e}")
return jsonify({'success': False, 'error': 'Wystąpił błąd podczas dodawania newsa'}), 500
finally:
db.close()

View File

@ -443,7 +443,7 @@ function showConfirm(message, options = {}) {
const modal = document.getElementById('confirmModal');
document.getElementById('confirmModalIcon').textContent = options.icon || '❓';
document.getElementById('confirmModalTitle').textContent = options.title || 'Potwierdzenie';
document.getElementById('confirmModalMessage').innerHTML = message;
document.getElementById('confirmModalMessage').textContent = message;
document.getElementById('confirmModalCancel').textContent = options.cancelText || 'Anuluj';
document.getElementById('confirmModalOk').textContent = options.okText || 'OK';
document.getElementById('confirmModalOk').className = 'btn ' + (options.okClass || 'btn-primary');
@ -479,7 +479,14 @@ function showToast(message, type = 'info', duration = 4000) {
const icons = { success: '✓', error: '✕', warning: '⚠', info: '' };
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `<span style="font-size: 1.2em;">${icons[type] || icons.info}</span><span>${message}</span>`;
// Bezpieczne tworzenie elementów - unikamy innerHTML z danymi użytkownika
const iconSpan = document.createElement('span');
iconSpan.style.fontSize = '1.2em';
iconSpan.textContent = icons[type] || icons.info;
const msgSpan = document.createElement('span');
msgSpan.textContent = message;
toast.appendChild(iconSpan);
toast.appendChild(msgSpan);
container.appendChild(toast);
setTimeout(() => { toast.style.animation = 'slideOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, duration);
}