Foro: correcciones de la revisión de seguridad y correctitud

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 17:34:03 +00:00
parent 071ba031a1
commit 89ea16ba4d
6 changed files with 81 additions and 14 deletions
+14 -2
View File
@@ -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
+3 -2
View File
@@ -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]
+2 -2
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
{% load forum_extras %}
{% include 'partials/head.html' %}
{% include 'partials/header.html' %}
{% include 'partials/video.html' %}
@@ -34,7 +34,7 @@
{% if post.deleted %}
<div class="pbody"><em>[Mensaje eliminado]</em></div>
{% else %}
<div class="pbody">{{ post.text|safe }}</div>
<div class="pbody">{{ post.text|forum_safe }}</div>
{% endif %}
<div class="pfoot">
<span>{{ post.time }}{% if post.updated_at and post.updated_at != post.time %} · editado {{ post.updated_at }}{% endif %}</span>
View File
+18
View File
@@ -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 ''))
+44 -8
View File
@@ -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: