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:
+14
-2
@@ -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
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user