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