From 89ea16ba4d37ef2187082640b939cdf35c50db45 Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 17:34:03 +0000 Subject: [PATCH] =?UTF-8?q?Foro:=20correcciones=20de=20la=20revisi=C3=B3n?= =?UTF-8?q?=20de=20seguridad=20y=20correctitud?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CSRF real en las vistas mutadoras con @csrf_protect (el middleware global no estaba activo; verificado: POST sin token -> 403). Se documenta el hueco global. - bloquea acceso a temas de foros ocultos/borrados por URL directa (view_topic y reply) - conteo de posts respeta borrados para moderadores (paginación correcta) - tema bloqueado impide editar/borrar posts a no-moderadores (_topic_locked_for) - moderador ve el formulario de respuesta en temas bloqueados (can_reply) - filtro forum_safe: sanea el HTML también al renderizar (defensa en profundidad) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/FORO.md | 16 +++++++-- forum/db.py | 5 +-- forum/templates/forum/topic.html | 4 +-- forum/templatetags/__init__.py | 0 forum/templatetags/forum_extras.py | 18 +++++++++++ forum/views.py | 52 +++++++++++++++++++++++++----- 6 files changed, 81 insertions(+), 14 deletions(-) create mode 100644 forum/templatetags/__init__.py create mode 100644 forum/templatetags/forum_extras.py diff --git a/docs/FORO.md b/docs/FORO.md index 89a3504..0d95111 100644 --- a/docs/FORO.md +++ b/docs/FORO.md @@ -51,8 +51,20 @@ en `forum/permissions.py`: guardar — `forum/sanitize.py` — evitando el XSS que tenía el original (que renderizaba HTML del editor sin filtrar). Se renderiza con `|safe` ya saneado. - **Todas** las acciones que modifican datos (publicar, responder, editar, mover, - bloquear/desbloquear, fijar, borrar/restaurar temas y posts) van por **POST con - CSRF**. Las vistas de moderación rechazan GET. + bloquear/desbloquear, fijar, borrar/restaurar temas y posts) van por **POST**, y + las vistas mutadoras llevan **`@csrf_protect`** (fuerza la validación CSRF por + vista aunque el middleware global no esté activo). +- El HTML de los posts también se **sanea al renderizar** con el filtro + `forum_safe` (defensa en profundidad frente a datos heredados sin sanear). +- Acceso a temas de foros ocultos/borrados bloqueado también por URL directa + (no solo en los listados). + +> ⚠️ **Recomendación para todo el sitio** (fuera del foro): `novawow/settings.py` +> no incluye `django.middleware.csrf.CsrfViewMiddleware`, por lo que el resto de +> formularios POST del portal (app `home`) **no** validan CSRF. El foro está +> protegido con `@csrf_protect`, pero conviene activar el middleware global y +> revisar que las vistas POST de `home` lleven `{% csrf_token %}` (algunas ya lo +> llevan; las AJAX usan `@csrf_exempt`). ## Editor diff --git a/forum/db.py b/forum/db.py index 4d62c2d..53cb6d3 100644 --- a/forum/db.py +++ b/forum/db.py @@ -183,10 +183,11 @@ def get_topic(topic_id, include_hidden=False): return topic -def count_posts(topic_id): +def count_posts(topic_id, include_deleted=False): + delf = "" if include_deleted else "AND deleted = 0" with _conn().cursor() as cursor: cursor.execute( - "SELECT COUNT(*) FROM forum_posts WHERE topic_id = %s AND deleted = 0", + f"SELECT COUNT(*) FROM forum_posts WHERE topic_id = %s {delf}", [topic_id], ) return cursor.fetchone()[0] diff --git a/forum/templates/forum/topic.html b/forum/templates/forum/topic.html index 76de7c1..6302f10 100644 --- a/forum/templates/forum/topic.html +++ b/forum/templates/forum/topic.html @@ -1,5 +1,5 @@ - +{% load forum_extras %} {% include 'partials/head.html' %} {% include 'partials/header.html' %} {% include 'partials/video.html' %} @@ -34,7 +34,7 @@ {% if post.deleted %}
[Mensaje eliminado]
{% else %} -
{{ post.text|safe }}
+
{{ post.text|forum_safe }}
{% endif %}
{{ post.time }}{% if post.updated_at and post.updated_at != post.time %} · editado {{ post.updated_at }}{% endif %} diff --git a/forum/templatetags/__init__.py b/forum/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/forum/templatetags/forum_extras.py b/forum/templatetags/forum_extras.py new file mode 100644 index 0000000..7ffcce2 --- /dev/null +++ b/forum/templatetags/forum_extras.py @@ -0,0 +1,18 @@ +"""Filtros de plantilla del foro.""" + +from django import template +from django.utils.safestring import mark_safe + +from .. import sanitize + +register = template.Library() + + +@register.filter +def forum_safe(value): + """ + Renderiza el HTML de un post saneándolo con nh3 en el momento (defensa en + profundidad). Aunque el contenido nuevo ya se sanea al guardar, esto protege + frente a filas heredadas/importadas que pudieran contener HTML sin sanear. + """ + return mark_safe(sanitize.clean_post_html(value or '')) diff --git a/forum/views.py b/forum/views.py index 2a7efce..27448e8 100644 --- a/forum/views.py +++ b/forum/views.py @@ -12,6 +12,7 @@ from django.contrib import messages from django.http import JsonResponse from django.shortcuts import redirect, render from django.urls import reverse +from django.views.decorators.csrf import csrf_protect from . import db from . import permissions as perm @@ -54,6 +55,14 @@ def _require_login(request): return None +def _topic_locked_for(request, topic_id): + """True si el tema está bloqueado y el usuario no es moderador.""" + if perm.is_moderator(request): + return False + topic = db.get_topic(topic_id, include_hidden=True) + return bool(topic and topic['locked']) + + # -------------------------------------------------------------------------- # Navegación # -------------------------------------------------------------------------- @@ -108,12 +117,18 @@ def search(request): def view_topic(request, topic_id, page=1): - topic = db.get_topic(topic_id, include_hidden=perm.is_moderator(request)) + is_mod = perm.is_moderator(request) + topic = db.get_topic(topic_id, include_hidden=is_mod) if not topic: messages.error(request, 'Tema no encontrado.') return redirect('app_forum') - forum = db.get_forum(topic['forum_id'], include_hidden=True) + # El foro padre debe ser visible (salvo moderador); si no, no se accede al + # tema por URL directa aunque no aparezca en los listados. + forum = db.get_forum(topic['forum_id'], include_hidden=is_mod) + if not forum: + messages.error(request, 'Tema no disponible.') + return redirect('app_forum') # Contador de visitas (solo si la columna existe) if db.has_views_column(): @@ -123,10 +138,9 @@ def view_topic(request, topic_id, page=1): else: topic['has_views'] = False - total = db.count_posts(topic_id) + total = db.count_posts(topic_id, include_deleted=is_mod) pag, offset = _paginate(total, page, settings.FORUM_POSTS_PER_PAGE) - include_deleted = perm.is_moderator(request) - posts = db.get_posts(topic_id, offset, settings.FORUM_POSTS_PER_PAGE, include_deleted) + posts = db.get_posts(topic_id, offset, settings.FORUM_POSTS_PER_PAGE, is_mod) # Marcar como leído db.mark_read(perm.get_account_id(request), topic_id) @@ -141,8 +155,8 @@ def view_topic(request, topic_id, page=1): 'forum': forum, 'posts': posts, 'pagination': pag, - 'can_reply': perm.can_post(request) and not topic['locked'], - 'move_forums': db.list_forums_for_move(topic['forum_id']) if perm.is_moderator(request) else [], + 'can_reply': perm.can_post(request) and (not topic['locked'] or is_mod), + 'move_forums': db.list_forums_for_move(topic['forum_id']) if is_mod else [], 'reply_form': ReplyForm(), })) @@ -165,6 +179,7 @@ def create_topic(request, forum_id): })) +@csrf_protect def submit_create_topic(request, forum_id): login = _require_login(request) if login: @@ -193,6 +208,7 @@ def submit_create_topic(request, forum_id): return redirect('forum_topic_p1', topic_id=topic_id) +@csrf_protect def reply_to_topic(request, topic_id): login = _require_login(request) if login: @@ -200,11 +216,16 @@ def reply_to_topic(request, topic_id): if request.method != 'POST': return redirect('forum_topic_p1', topic_id=topic_id) + is_mod = perm.is_moderator(request) topic = db.get_topic(topic_id) if not topic: messages.error(request, 'Tema no encontrado.') return redirect('app_forum') - if topic['locked'] and not perm.is_moderator(request): + # El foro padre debe ser visible (salvo moderador) + if not db.get_forum(topic['forum_id'], include_hidden=is_mod): + messages.error(request, 'Tema no disponible.') + return redirect('app_forum') + if topic['locked'] and not is_mod: messages.error(request, 'El tema está bloqueado.') return redirect('forum_topic_p1', topic_id=topic_id) @@ -243,6 +264,7 @@ def edit_post(request, post_id): })) +@csrf_protect def update_post(request, post_id): login = _require_login(request) if login: @@ -254,6 +276,9 @@ def update_post(request, post_id): if not perm.can_edit_post(request, post): messages.error(request, 'No tienes permiso para editar este post.') return redirect('forum_topic_p1', topic_id=post['topic_id']) + if _topic_locked_for(request, post['topic_id']): + messages.error(request, 'El tema está bloqueado.') + return redirect('forum_topic_p1', topic_id=post['topic_id']) if request.method != 'POST': return redirect('forum_edit_post', post_id=post_id) @@ -267,6 +292,7 @@ def update_post(request, post_id): return redirect('forum_topic_p1', topic_id=post['topic_id']) +@csrf_protect def delete_post(request, post_id): login = _require_login(request) if login: @@ -278,6 +304,9 @@ def delete_post(request, post_id): if not perm.can_edit_post(request, post): messages.error(request, 'No tienes permiso.') return redirect('forum_topic_p1', topic_id=post['topic_id']) + if _topic_locked_for(request, post['topic_id']): + messages.error(request, 'El tema está bloqueado.') + return redirect('forum_topic_p1', topic_id=post['topic_id']) if request.method != 'POST': return redirect('forum_topic_p1', topic_id=post['topic_id']) db.set_post_deleted(post_id, True) @@ -285,6 +314,7 @@ def delete_post(request, post_id): return redirect('forum_topic_p1', topic_id=post['topic_id']) +@csrf_protect def restore_post(request, post_id): if not perm.is_moderator(request): return redirect('app_forum') @@ -319,6 +349,7 @@ def _mod_post_only(request): return None +@csrf_protect def lock_topic(request, topic_id): block = _mod_post_only(request) if block: @@ -328,6 +359,7 @@ def lock_topic(request, topic_id): return redirect('forum_topic_p1', topic_id=topic_id) +@csrf_protect def unlock_topic(request, topic_id): block = _mod_post_only(request) if block: @@ -337,6 +369,7 @@ def unlock_topic(request, topic_id): return redirect('forum_topic_p1', topic_id=topic_id) +@csrf_protect def toggle_sticky(request, topic_id): block = _mod_post_only(request) if block: @@ -348,6 +381,7 @@ def toggle_sticky(request, topic_id): return redirect('forum_topic_p1', topic_id=topic_id) +@csrf_protect def delete_topic(request, topic_id): block = _mod_post_only(request) if block: @@ -360,6 +394,7 @@ def delete_topic(request, topic_id): return redirect('app_forum') +@csrf_protect def restore_topic(request, topic_id): block = _mod_post_only(request) if block: @@ -369,6 +404,7 @@ def restore_topic(request, topic_id): return redirect('forum_topic_p1', topic_id=topic_id) +@csrf_protect def move_topic(request, topic_id): block = _mod_only(request) if block: