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 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. renderizaba HTML del editor sin filtrar). Se renderiza con `|safe` ya saneado.
- **Todas** las acciones que modifican datos (publicar, responder, editar, mover, - **Todas** las acciones que modifican datos (publicar, responder, editar, mover,
bloquear/desbloquear, fijar, borrar/restaurar temas y posts) van por **POST con bloquear/desbloquear, fijar, borrar/restaurar temas y posts) van por **POST**, y
CSRF**. Las vistas de moderación rechazan GET. 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 ## Editor
+3 -2
View File
@@ -183,10 +183,11 @@ def get_topic(topic_id, include_hidden=False):
return topic 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: with _conn().cursor() as cursor:
cursor.execute( 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], [topic_id],
) )
return cursor.fetchone()[0] return cursor.fetchone()[0]
+2 -2
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
{% load forum_extras %}
{% include 'partials/head.html' %} {% include 'partials/head.html' %}
{% include 'partials/header.html' %} {% include 'partials/header.html' %}
{% include 'partials/video.html' %} {% include 'partials/video.html' %}
@@ -34,7 +34,7 @@
{% if post.deleted %} {% if post.deleted %}
<div class="pbody"><em>[Mensaje eliminado]</em></div> <div class="pbody"><em>[Mensaje eliminado]</em></div>
{% else %} {% else %}
<div class="pbody">{{ post.text|safe }}</div> <div class="pbody">{{ post.text|forum_safe }}</div>
{% endif %} {% endif %}
<div class="pfoot"> <div class="pfoot">
<span>{{ post.time }}{% if post.updated_at and post.updated_at != post.time %} · editado {{ post.updated_at }}{% endif %}</span> <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.http import JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.views.decorators.csrf import csrf_protect
from . import db from . import db
from . import permissions as perm from . import permissions as perm
@@ -54,6 +55,14 @@ def _require_login(request):
return None 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 # Navegación
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -108,12 +117,18 @@ def search(request):
def view_topic(request, topic_id, page=1): 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: if not topic:
messages.error(request, 'Tema no encontrado.') messages.error(request, 'Tema no encontrado.')
return redirect('app_forum') 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) # Contador de visitas (solo si la columna existe)
if db.has_views_column(): if db.has_views_column():
@@ -123,10 +138,9 @@ def view_topic(request, topic_id, page=1):
else: else:
topic['has_views'] = False 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) 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, is_mod)
posts = db.get_posts(topic_id, offset, settings.FORUM_POSTS_PER_PAGE, include_deleted)
# Marcar como leído # Marcar como leído
db.mark_read(perm.get_account_id(request), topic_id) db.mark_read(perm.get_account_id(request), topic_id)
@@ -141,8 +155,8 @@ def view_topic(request, topic_id, page=1):
'forum': forum, 'forum': forum,
'posts': posts, 'posts': posts,
'pagination': pag, 'pagination': pag,
'can_reply': perm.can_post(request) and not topic['locked'], 'can_reply': perm.can_post(request) and (not topic['locked'] or is_mod),
'move_forums': db.list_forums_for_move(topic['forum_id']) if perm.is_moderator(request) else [], 'move_forums': db.list_forums_for_move(topic['forum_id']) if is_mod else [],
'reply_form': ReplyForm(), 'reply_form': ReplyForm(),
})) }))
@@ -165,6 +179,7 @@ def create_topic(request, forum_id):
})) }))
@csrf_protect
def submit_create_topic(request, forum_id): def submit_create_topic(request, forum_id):
login = _require_login(request) login = _require_login(request)
if login: if login:
@@ -193,6 +208,7 @@ def submit_create_topic(request, forum_id):
return redirect('forum_topic_p1', topic_id=topic_id) return redirect('forum_topic_p1', topic_id=topic_id)
@csrf_protect
def reply_to_topic(request, topic_id): def reply_to_topic(request, topic_id):
login = _require_login(request) login = _require_login(request)
if login: if login:
@@ -200,11 +216,16 @@ def reply_to_topic(request, topic_id):
if request.method != 'POST': if request.method != 'POST':
return redirect('forum_topic_p1', topic_id=topic_id) return redirect('forum_topic_p1', topic_id=topic_id)
is_mod = perm.is_moderator(request)
topic = db.get_topic(topic_id) topic = db.get_topic(topic_id)
if not topic: if not topic:
messages.error(request, 'Tema no encontrado.') messages.error(request, 'Tema no encontrado.')
return redirect('app_forum') 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.') messages.error(request, 'El tema está bloqueado.')
return redirect('forum_topic_p1', topic_id=topic_id) 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): def update_post(request, post_id):
login = _require_login(request) login = _require_login(request)
if login: if login:
@@ -254,6 +276,9 @@ def update_post(request, post_id):
if not perm.can_edit_post(request, post): if not perm.can_edit_post(request, post):
messages.error(request, 'No tienes permiso para editar este post.') messages.error(request, 'No tienes permiso para editar este post.')
return redirect('forum_topic_p1', topic_id=post['topic_id']) 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': if request.method != 'POST':
return redirect('forum_edit_post', post_id=post_id) 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']) return redirect('forum_topic_p1', topic_id=post['topic_id'])
@csrf_protect
def delete_post(request, post_id): def delete_post(request, post_id):
login = _require_login(request) login = _require_login(request)
if login: if login:
@@ -278,6 +304,9 @@ def delete_post(request, post_id):
if not perm.can_edit_post(request, post): if not perm.can_edit_post(request, post):
messages.error(request, 'No tienes permiso.') messages.error(request, 'No tienes permiso.')
return redirect('forum_topic_p1', topic_id=post['topic_id']) 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': if request.method != 'POST':
return redirect('forum_topic_p1', topic_id=post['topic_id']) return redirect('forum_topic_p1', topic_id=post['topic_id'])
db.set_post_deleted(post_id, True) 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']) return redirect('forum_topic_p1', topic_id=post['topic_id'])
@csrf_protect
def restore_post(request, post_id): def restore_post(request, post_id):
if not perm.is_moderator(request): if not perm.is_moderator(request):
return redirect('app_forum') return redirect('app_forum')
@@ -319,6 +349,7 @@ def _mod_post_only(request):
return None return None
@csrf_protect
def lock_topic(request, topic_id): def lock_topic(request, topic_id):
block = _mod_post_only(request) block = _mod_post_only(request)
if block: if block:
@@ -328,6 +359,7 @@ def lock_topic(request, topic_id):
return redirect('forum_topic_p1', topic_id=topic_id) return redirect('forum_topic_p1', topic_id=topic_id)
@csrf_protect
def unlock_topic(request, topic_id): def unlock_topic(request, topic_id):
block = _mod_post_only(request) block = _mod_post_only(request)
if block: if block:
@@ -337,6 +369,7 @@ def unlock_topic(request, topic_id):
return redirect('forum_topic_p1', topic_id=topic_id) return redirect('forum_topic_p1', topic_id=topic_id)
@csrf_protect
def toggle_sticky(request, topic_id): def toggle_sticky(request, topic_id):
block = _mod_post_only(request) block = _mod_post_only(request)
if block: if block:
@@ -348,6 +381,7 @@ def toggle_sticky(request, topic_id):
return redirect('forum_topic_p1', topic_id=topic_id) return redirect('forum_topic_p1', topic_id=topic_id)
@csrf_protect
def delete_topic(request, topic_id): def delete_topic(request, topic_id):
block = _mod_post_only(request) block = _mod_post_only(request)
if block: if block:
@@ -360,6 +394,7 @@ def delete_topic(request, topic_id):
return redirect('app_forum') return redirect('app_forum')
@csrf_protect
def restore_topic(request, topic_id): def restore_topic(request, topic_id):
block = _mod_post_only(request) block = _mod_post_only(request)
if block: if block:
@@ -369,6 +404,7 @@ def restore_topic(request, topic_id):
return redirect('forum_topic_p1', topic_id=topic_id) return redirect('forum_topic_p1', topic_id=topic_id)
@csrf_protect
def move_topic(request, topic_id): def move_topic(request, topic_id):
block = _mod_only(request) block = _mod_only(request)
if block: if block: