Stored XSS o Persistent XSS es una vulnerabilidad de aplicaciones web que ocurre cuando la aplicación guarda input dado por el usuario y lo incrusta directamente en páginas servidas a otros usuarios sin sanitizarlo correctamente. Cuando estos usuarios acceden al contenido malicioso lo ejecutan sin querer. Algunos ejemplos son foros web, reseñas de productos, comentarios, etc.
Vulnerable Web Application
Hay varias razones por las que una aplicación web es vulnerable a stored XSS. Algunas de las mejores prácticas para prevenirla son:
- Validar y sanitizar el input: Define reglas claras y validación estricta en toda la información recibida del usuario, incluso si aparentemente no lo puede modificar directamente.
- Usar escape de output: Al mostrar input dado por el usuario, en un contexto HTML, encodea todos los caracteres HTML-específicos como
<,>y&. - Aplicar encoding específico al contexto: Si usamos JavaScript debemos usar JavaScript encoding y si tenemos URLs, URL encoding.
- Practicar defensa en profundidad: No confíes en una única capa de defensa. Haz validación del lado del servidor también.
En los siguientes ejemplos listamos trazas de código vulnerables en varios lenguajes.
No lo veremos tan en profundidad por ser la solución la misma que en la implementación de Reflected XSS.
PHP
Código Vulnerable
El código de abajo tiene varias vulnerabilidades. Hace tres cosas:
- Lee un comentario del usuario y lo guarda en la variable
$comment. - Añade
$commenta la columnacommenten la tablacommentsen la base de datos. - Luego, itera sobre las filas en la columna
commenty las muestra en pantalla.
// Storing user comment $comment = $_POST['comment']; mysqli_query($conn, "INSERT INTO comments (comment) VALUES ('$comment')"); // Displaying user comment $result = mysqli_query($conn, "SELECT comment FROM comments"); while ($row = mysqli_fetch_assoc($result)) { echo $row['comment']; }
Código Arreglado
// Storing user comment $comment = mysqli_real_escape_string($conn, $_POST['comment']); mysqli_query($conn, "INSERT INTO comments (comment) VALUES ('$comment')"); // Displaying user comment $result = mysqli_query($conn, "SELECT comment FROM comments"); while ($row = mysqli_fetch_assoc($result)) { $sanitizedComment = htmlspecialchars($row['comment']); echo $sanitizedComment; }
Antes de mostrar todos los comentarios en pantalla, los pasamos por la función htmlspecialchars(). No entra en el foco de esta tarea, sin embargo, si tienes curiosidad por saber como mitigar la SQL Injection, se puede hacer con mysql_real_escape_string().
JavaScript (Node.js)
Código Vulnerable
El siguiente código JavaScript lee un comentario recibido de un usuario que fue guardado en una tabla de una base de datos. Asumimos que el array comments ha sido poblado desde la base de datos.
app.get('/comments', (req, res) => { let html = '<ul>'; for (const comment of comments) { html += `<li>${comment}</li>`; } html += '</ul>'; res.send(html); });
El problema principal es este código es que lee el input del usuario guardado en comment y lo muestra como parte del código HTML. Por lo que cualquier usuario que lo vea ejecutará cualquier script inyectado.
Código Arreglado
const sanitizeHtml = require('sanitize-html'); app.get('/comments', (req, res) => { let html = '<ul>'; for (const comment of comments) { const sanitizedComment = sanitizeHtml(comment); html += `<li>${sanitizedComment}</li>`; } html += '</ul>'; res.send(html); });
Parte de la solución es sanitizar el HTML antes de mostrarlo al usuario. Podemos eliminar elementos HTML antes de mostrárselos al usuario usando sanitizeHTML().
Python (Flask)
Código Vulnerable
Similar a las trazas de código que hemos mostrado, el siguiente código usa el framework Flask.
from flask import Flask, request, render_template_string from flask_sqlalchemy import SQLAlchemy app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///site.db' db = SQLAlchemy(app) class Comment(db.Model): id = db.Column(db.Integer, primary_key=True) content = db.Column(db.String, nullable=False) @app.route('/comment', methods=['POST']) def add_comment(): comment_content = request.form['comment'] comment = Comment(content=comment_content) db.session.add(comment) db.session.commit() return 'Comment added!' @app.route('/comments') def show_comments(): comments = Comment.query.all() return render_template_string(''.join(['<div>' + c.content + '</div>' for c in comments]))
El primer problema es que comment_content está configurado a la subida rescatada del formulario request.form['comment'] del usuario sin sanitización. Además cuando un usuario quiere verlos, se muestran sin sanitizarse.
Código Arreglado
from flask import Flask, request, render_template_string, escape from flask_sqlalchemy import SQLAlchemy from markupsafe import escape app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///site.db' db = SQLAlchemy(app) class Comment(db.Model): id = db.Column(db.Integer, primary_key=True) content = db.Column(db.String, nullable=False) @app.route('/comment', methods=['POST']) def add_comment(): comment_content = request.form['comment'] comment = Comment(content=comment_content) db.session.add(comment) db.session.commit() return 'Comment added!' @app.route('/comments') def show_comments(): comments = Comment.query.all() sanitized_comments = [escape(c.content) for c in comments] return render_template_string(''.join(['<div>' + comment + '</div>' for comment in sanitized_comments]))
Usamos la función escape() para asegurar que cualquier carácter se reemplace con entidades HTML (> -> >). Aunque el input se guarde tal cual, se sanitiza antes de mandarse de vuelta al cliente.
C# (ASP.NET)
Código Vulnerable
El siguiente código de C# tiene múltiples vulnerabilidades.
public void SaveComment(string userComment) { var command = new SqlCommand("INSERT INTO Comments (Comment) VALUES ('" + userComment + "')", connection); // Execute the command } public void DisplayComments() { var reader = new SqlCommand("SELECT Comment FROM Comments", connection).ExecuteReader(); while (reader.Read()) { Response.Write(reader["Comment"].ToString()); } // Execute the command }
Una de ellas es stored XSS. El sistema guarda lo que introduzca el usuario sin cambios y más adelante lo muestra a otros usuarios. Otra vulnerabilidad es SQLi aunque no entra en el alcance de esta tarea.
Código Arreglado
using System.Web; public void SaveComment(string userComment) { var command = new SqlCommand("INSERT INTO Comments (Comment) VALUES (@comment)", connection); command.Parameters.AddWithValue("@comment", userComment); } public void DisplayComments() { var reader = new SqlCommand("SELECT Comment FROM Comments", connection).ExecuteReader(); while (reader.Read()) { var comment = reader["Comment"].ToString(); var sanitizedComment = HttpUtility.HtmlEncode(comment); Response.Write(sanitizedComment); } reader.Close(); }
Con unos pocos cambios, la seguridad del código ha mejorado. Se arregla la stored XSS usando el método HttpUtility.HtmlEncode() antes de mostrar userComment como parte de la web. Si tienes curiosidad sobre cómo mitigar la vulnerabilidad SQL se hace con el método Parameters.AddWithValue() en el objeto SqlCommand.
