Protección de aplicaciones web Spring Boot

Este artículo se aplica a los sitios creados con el marco Spring Boot. Discutiremos los siguientes cuatro métodos para agregar capas adicionales de seguridad a S...

Este artículo se aplica a los sitios creados con el marco Spring Boot. Discutiremos los siguientes cuatro métodos para agregar capas adicionales de seguridad a las aplicaciones Spring Boot:

  • Prevención de la inyección de SQL mediante consultas parametrizadas
  • Validación de entrada de parámetros de URL
  • Validación de entrada de campo de formulario
  • Codificación de salida para evitar ataques XSS reflejados

Utilizo estos métodos para mi sitio web, Compromiso inicial, que está construido con Spring Boot, el motor de plantillas de Thymeleaf, Apache Maven, y está alojado en AWS Elastic Beanstalk.

En nuestra discusión de cada sugerencia de seguridad, primero describiremos un vector de ataque para ilustrar cómo se puede explotar una vulnerabilidad relevante. Luego, describiremos cómo proteger la vulnerabilidad y mitigar el vector de ataque. Tenga en cuenta que hay muchas maneras de realizar una tarea determinada en Spring Boot; estos ejemplos se sugieren para ayudarlo a comprender mejor las vulnerabilidades potenciales y los métodos de defensa.

Prevención de la inyección SQL mediante consultas parametrizadas

SQL Injection es un ataque común y fácil de entender. Los atacantes intentarán encontrar aperturas en la funcionalidad de su aplicación que les permitan modificar las consultas SQL que su aplicación envía a la base de datos, o incluso enviar sus propias consultas SQL personalizadas. El objetivo del atacante es acceder a datos confidenciales que se almacenan en la base de datos, a los que no se debería acceder mediante el uso normal de la aplicación, o causar daños irreparables al sistema atacado.

Una forma común en que un atacante intentará inyectar SQL en su aplicación es a través de parámetros de URL que se utilizan para crear consultas SQL que se envían a la base de datos. Por ejemplo, considere la siguiente URL de ejemplo:

1
https://fakesite.com/getTransaction?transactionId=12345

Digamos que hay un punto final del controlador Spring Boot definido en /getTransaction que acepta una ID de transacción en el parámetro de URL:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@GetMapping("/getTransaction")
public ModelAndView getTransaction(@RequestParam("transactionId") String transactionId) {

    ModelAndView modelAndView = new ModelAndView();

    sql = "SELECT transaction_user, transaction_amount FROM transaction WHERE transaction_id = " + transactionId;

    Transaction transaction = jdbcTemplate.query(sql, new TransactionRowMapper());

    modelAndView.addObject("transaction", transaction);
    modelAndView.setViewName("transaction");

    return modelAndView;
}

Tenga en cuenta que la instrucción SQL de este ejemplo se crea mediante la concatenación de cadenas. El transactionId simplemente se agrega después de la cláusula "WHERE" usando el operador +.

Ahora imagine que un atacante usa la siguiente URL para acceder al sitio:

1
https://fakesite.com/getTransaction?transactionId=12345;+drop+table+transaction;

En este caso, el atacante manipula el parámetro de URL transactionId (que se define como una cadena en nuestro método de controlador) para agregar una instrucción "DROP TABLE", por lo que se ejecutará el siguiente SQL en la base de datos:

1
SELECT transaction_user, transaction_amount FROM transaction WHERE transaction_id = 12345; drop table transaction;

Esto eliminaría la tabla de transacciones, lo que conduciría a una aplicación rota y posiblemente a una pérdida de datos irreparable, debido al hecho de que la instrucción SQL acepta el parámetro de URL proporcionado por el usuario y lo ejecuta como código SQL en vivo.

Para remediar la situación, podemos usar una función llamada consultas parametrizadas. En lugar de concatenar nuestras variables dinámicas directamente en declaraciones SQL, las consultas parametrizadas reconocen que se está pasando un valor dinámico no seguro y utilizan la lógica integrada para garantizar que todo el contenido proporcionado por el usuario se escape. Esto significa que las variables que se pasan a través de consultas parametrizadas nunca se ejecutarán como código SQL activo.

Aquí hay una versión de los fragmentos de código afectados anteriores, actualizados para usar consultas parametrizadas:

1
2
3
sql = "SELECT transaction_user, transaction_amount FROM transaction WHERE transaction_id = ?";

Transaction transaction = jdbcTemplate.query(sql, new TransactionRowMapper(), transactionId);

Observe el reemplazo del operador + y la variable transactionId directamente en la instrucción SQL. Estos se reemplazan por ?, que representa una variable que se pasará más tarde. La variable transactionId se pasa como argumento al método jdbcTemplate.query(), que sabe que todos los parámetros pasados ​​como argumentos deben escaparse. Esto evitará que la base de datos procese cualquier entrada del usuario como código SQL activo.

Otro formato para pasar consultas parametrizadas en Java es [NamedParameterJdbcTemplate](https://docs.spring.io/spring/docs/4.3.0.RC1/javadoc-api/index.html?org/springframework/jdbc/core/ namedparam/NamedParameterJdbcTemplate.html). Esto presenta una forma más clara de identificar y realizar un seguimiento de las variables pasadas a través de las consultas. En lugar de usar el símbolo ? para identificar parámetros, NamedParameterJdbcTemplate usa dos puntos : seguido del nombre del parámetro. Los nombres y valores de los parámetros se registran en una estructura de mapa o diccionario, como se ve a continuación:

1
2
3
4
5
6
7
Map<String, Object> params = new HashMap<>();

sql = "SELECT transaction_user, transaction_amount FROM transaction WHERE transaction_id = :transactionId";

params.put("transactionId", transactionId);

Transaction transaction = jdbcTemplate.query(sql, params, new TransactionRowMapper());

Este ejemplo se comporta de manera idéntica al anterior, pero es más popular debido a la claridad que brinda al identificar los parámetros en una declaración SQL. Esto es especialmente cierto en declaraciones SQL más complejas que tendrían un gran número de ? que deben verificarse para asegurarse de que estén en el orden correcto.

Validación de entrada de parámetros de URL

Al pensar en la seguridad de la aplicación, una consideración principal es enumerar todos los puntos en los que la aplicación acepta la entrada de los usuarios. Cada punto de entrada puede ser vulnerable si no se asegura adecuadamente y, como desarrolladores, debemos esperar que los atacantes intenten explotar todas las fuentes de entrada.

Una forma común en que las aplicaciones reciben datos de entrada de los usuarios es directamente desde la cadena de URL en forma de parámetros de URL. La URL de muestra que usamos en la sección anterior es un ejemplo de pasar un transactionId como parámetro de URL:

1
https://fakesite.com/getTransaction?transactionId=12345

Supongamos que queremos asegurarnos de que el ID de la transacción sea un número y que se encuentre dentro del rango de 1 a 100 000. Este es un proceso simple de dos pasos:

Agregue la anotación @Validated en la clase de controlador en la que vive el método.

Use anotaciones de validación en línea directamente en @RequestParam en el argumento del método, de la siguiente manera:

1
2
3
4
@GetMapping("/getTransaction")
public ModelAndView getTransaction(@RequestParam("transactionId") @min(1) @max(100000) Integer transactionId) {
    // Method content
}

Tenga en cuenta que cambiamos el tipo de transactionId a Integer de String y agregamos las anotaciones @min y @max en línea con el argumento transactionId para aplicar el rango numérico especificado.

Si el usuario proporciona un parámetro no válido que no cumple con estos criterios, se lanza una javax.validation.ContractViolationException que se puede manejar para presentar al usuario un error que describe lo que hizo mal.

Aquí hay algunas otras anotaciones de restricciones comúnmente utilizadas para la validación de parámetros de URL:

  • @Size: el tamaño del elemento debe estar entre los límites especificados.
  • @NotBlank: el elemento no debe ser NULL ni estar vacío.
  • @NotNull: el elemento no debe ser NULL.
  • @AssertTrue: el elemento debe ser verdadero.
  • @AssertFalse: el elemento debe ser falso.
  • @Past: el elemento debe ser una fecha en el pasado.
  • @Future: el elemento debe ser una fecha en el futuro.
  • @Pattern: el elemento debe coincidir con una expresión regular especificada.

Validación de entrada de campo de formulario

Otro tipo más obvio de entrada del usuario proviene de los campos de formulario presentados a los usuarios finales con el propósito específico de recopilar información para guardarla en la base de datos o procesarla de alguna manera. Algunos ejemplos de campos de formulario son cuadros de texto, casillas de verificación, botones de opción y menús desplegables.

Por lo general, la entrada del campo de formulario se transmite del cliente al servidor a través de una solicitud POST. Dado que los datos del formulario suelen incluir entradas arbitrarias del usuario, todos los datos de los campos de entrada deben validarse para asegurarse de que no contengan valores maliciosos que puedan dañar la aplicación o exponer información confidencial.

Supongamos que estamos trabajando con una aplicación web veterinaria que tiene un formulario web que permite a los usuarios finales registrar a su mascota. Nuestro código Java incluiría una clase de dominio que representa una mascota, de la siguiente manera:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Entity
public class Pet {

    @Id
    @GeneratedValue(strategy=GenerationType.IDENTITY)
    private Integer id;

    @NotBlank(message="Name must not be empty")
    @Size(min=2, max=40)
    @Pattern(regexp="^$|[a-zA-Z ]+$", message="Name must not include special characters.")
    private String name;

    @NotBlank(message="Kind must not be empty")
    @Size(min=2, max=30)
    @Pattern(regexp="^$|[a-zA-Z ]+$", message="Kind must not include special characters.")
    private String kind;

    @NotBlank(message="Age must not be empty")
    @Min(0)
    @Max(40)
    private Integer age;

    // standard getter and setter methods...
}

Tenga en cuenta las anotaciones de restricciones que se han incluido sobre cada campo. Estos funcionan de la misma manera que se describe en la sección anterior, excepto que hemos especificado un “mensaje” para algunos de ellos, que anulará los mensajes de error predeterminados que se muestran al usuario cuando se viola la restricción respectiva.

Tenga en cuenta que cada campo tiene anotaciones que especifican el rango en el que debe caer el campo. Además, los campos String (nombre y tipo) tienen una anotación @Pattern, que implementa una restricción de expresiones regulares que solo acepta letras y espacios. Esto evita que los atacantes intenten incluir caracteres y símbolos especiales, que pueden tener importancia en contextos de código como la base de datos o el navegador.

El formulario HTML contiene los campos de Clase de mascota correspondientes, incluido el nombre de la mascota, el tipo de animal, la edad y podría verse como a continuación:

Tenga en cuenta que este HTML cortado incluye etiquetas de plantilla de Thymeleaf para marcar el HTML.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<form id="petForm" th:action="@{/submitNewPet}" th:object="${pet}" method="POST">
    <input type="text" th:field="*{name}" placeholder="Enter pet name…" />

    <select th:field="*{kind}">
        <option value="cat">Cat</option>
        <option value="dog">Dog</option>
        <option value="hedgehog">Hedgehog</option>
    </select>

    <input type="number" th:field="*{age}" />

    <input type="submit" value="Submit Form" />
</form>

Cuando se completan los campos del formulario y se hace clic en el botón "Enviar", el navegador enviará una solicitud POST al servidor en el punto final "/submitNewPet". Esto será recibido por un método @RequestMapping, definido de la siguiente manera:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@PostMapping("/submitNewPet")
public ModelAndView submitNewPet(@Valid @ModelAttribute("pet") Pet pet, BindingResult bindingResult) {

    ModelAndView modelAndView = new ModelAndView();

    if (bindingResult.hasErrors()) {
        modelAndView.addObject("pet", pet);
        modelAndView.setViewName("submitPet");
    } else {
        modelAndView.setViewName("submitPetConfirmation");
    }

    return modelAndView;
}

La anotación @Valid en el argumento del método aplicará las validaciones definidas en el objeto de dominio Pet. Spring maneja automáticamente el argumento bindingResult y contendrá errores si alguno de los atributos del modelo tiene validaciones de restricciones. En este caso, incorporamos un inicio de sesión simple para recargar la página submitPet si se violan las restricciones y mostramos una página de confirmación si los campos del formulario son válidos.

Codificación de salida para evitar ataques XSS reflejados

El último tema de seguridad que vamos a discutir es la codificación de salida de la entrada proporcionada por el usuario y los datos recuperados de la base de datos.

Imagine un escenario en el que un atacante pueda pasar un valor como entrada a través de un parámetro de URL, un campo de formulario o una llamada a la API. En algunos casos, esta entrada proporcionada por el usuario podría pasarse como una variable directamente a la plantilla de vista que se devuelve al usuario, o podría guardarse en la base de datos.

Por ejemplo, el atacante pasa una cadena que es un código Javascript válido como:

1
alert('This app has totally been hacked, bro');

Consideremos los escenarios en los que la cadena anterior se guarda en un campo de la base de datos como un comentario, luego se recupera en la plantilla de vista y se muestra al usuario en su navegador de Internet. Si la variable no se escapa correctamente, la instrucción alert() se ejecutará como código en vivo tan pronto como el navegador del usuario reciba la página; verá aparecer la alerta. Si bien es molesto, en un ataque real, este código no sería una alerta, sería un script malicioso que podría engañar al usuario para que haga algo desagradable.

De hecho, el contenido malicioso proporcionado por el usuario no necesariamente debe guardarse en la base de datos para causar daño. En muchos casos, la entrada proporcionada por el usuario, como los nombres de usuario, se repite esencialmente al usuario para que se muestre en la página que está visitando. Estos se denominan ataques "reflejados" por este motivo, ya que la entrada maliciosa se refleja en el navegador, donde puede causar daño.

En ambos casos, el contenido dinámico debe codificarse correctamente (o escaparse) para asegurarse de que el navegador no lo procese como código Javascript, HTML o XML en vivo.

Esto se puede lograr fácilmente mediante el uso de un motor de plantillas maduro, como Thymeleaf. Thymeleaf se puede integrar fácilmente en una aplicación Spring Boot agregando las dependencias de archivos POM requeridas y realizando algunos pasos de configuración menores que no abordaremos aquí. El atributo th:text en Thymeleaf tiene una lógica incorporada que manejará la codificación de cualquier variable que se le pase de la siguiente manera:

1
<h1>Welcome to the Site! Your username is: <span th:text="${username}"></span></h1>

En este caso, incluso si la variable username contuviera código malicioso como alert('Ha sido pirateado');, el texto simplemente se mostraría en la página en lugar de que el navegador lo ejecutara como código Javascript en vivo. Esto se debe a la lógica de codificación integrada de Thymeleaf.

Sobre el autor

Este artículo fue escrito por Jacob Stopak, un consultor y desarrollador de software apasionado por ayudar a otros a mejorar sus vidas a través del código. Jacob es el creador de Compromiso inicial, un sitio dedicado a ayudar a los desarrolladores curiosos a aprender cómo se codifican sus programas favoritos. Su proyecto destacado ayuda a las personas a aprender git a nivel de código. o.