Fetch con certificados autofirmados en Node.js.

Despídete de los errores sin sentido en tus desarrollos de Sveltekit. Aprende a usar certificados autofirmados en La Esquina Gris

Fetch con certificados autofirmados en Node.js.
El logo de Svelte está ahí porque me topé con este error usando Sveltekit. El error NO es propio de Svelte o Sveltekit.

Parece que esta semana los astros se alinearon para que me lluevan ideas de contenido. Esta vez, como no podía ser de otra forma, estoy de nuevo frente a esos problemas que están TAN mal documentados en internet que no parece haber una solución trivial al mismo. En este caso, realizar peticiones HTTP en Sveltekit del lado del servidor a una dirección HTTPS con un certificado autofirmado.

SSL y la cochina doctrina de internet.

Este será un artículo corto, pues el problema es relativamente pequeño y no requiere de muchas vueltas para solucionarse, sin embargo, por el bien de mantener algo de contenido interesante, daré algo de contexto de porque me veo en la necesidad de documentar esto.

Hace relativamente poco tiempo, me hice con una máquina que comencé a utilizar como servidor casero. Todo iba bien al momento de configurar, hasta que te toca asignar dominios a tus servicios desplegados para acceder a ellos de una forma más sencilla, súmale la necesidad de ponerle un certificado SSL global para todos los servicios desplegados en su respectivo subdominio.

Ya desde aquí comenzaron los problemas. Generar y aceptar certificados SSL para servicios LAN ONLY ya es un problema en sí mismo. Primero comencé buscando las soluciones en los sitios donde, cada día, estoy más seguro que (casi) nadie sabe nada.

Claro que, las soluciones que abundaban eran las más flojas en cuestión de trabajo. Bastante sencillo, de hecho, solo consigues un dominio en DuckDNS/NoIP, pasas todo a través de un tunel de Cloudflare y pides certificados de Let’s Encrypt a lo desgraciado.

Más que ser una solución, esas respuestas apestaban a skill issues, básicamente exponer tu servidor a internet y encima confiar todo tu tráfico a Cloudflare. Sus ventajas ha de tener para esos casos de uso. En mi caso solo quiero mis servicios disponibles en mi LAN y en mi VPN, nada más. No deseo exponerlos, mucho menos compartirlos con el internet.

Eventualmente, luego de horas de prueba y error, aprendí a hacer mi propia CA para generar los certificados que necesitaba. Claro que esto tuvo su costo, internet está tan acostumbrado a la forma en la que se manejan los SSL que no te vas a salir con la tuya con esa clase de certificados. Tuve que importar todo a mano en los dispositivos de mi red, pero el esfuerzo valió la pena 😄… hasta ahora.

Para entender de donde vengo y a donde voy con todo esto, primero debemos entender que es lo que tenemos como beneficio al usar certificados SSL:

  • Cifrado: Cualquier certificado SSL, incluso los autofirmados, nos otorgan cifrado en la comunicación cliente-servidor.
  • Autenticación: Aquí es donde flaquean los certificados autofirmados, como no poseen algún tipo de “autenticación” válida, cualquier atacante en nuestra red podría generar un certificado y adjudicarse la identidad de nuestro servidor.

Es precisamente en la autenticación donde las entidades certificadoras juegan su papel. Para evitar que escenarios como el anterior ocurran, esto quiere decir que alguien o algo debe verificar la propiedad del dominio y la información de identidad del mismo. Esto, junto con otros detalles técnicos y administrativos, se traduce en dinero, dinero que se le cobra a las personas que necesitan un certificado SSL para asegurar su (o sus) sitios web. Ese dinero cobrado lo utiliza la CA para verificar la identidad de la persona, empresa u organización y su o sus respectivos dominios en internet para que el certificado se considere como válido.

Pero existen Let's Encrypt. Ellos te dan certificados gratis.

Lo sé, he usado Let’s Encrypt en el pasado y tengo que decir que, para proyectos personales, Pymes o emprendimientos individuales es una excelente opción. Sí, sus certificados duran considerablemente menos tiempo que uno emitido por una CA de paga, pero son más que suficientes para la mayoría de casos. Esto lo pueden hacer gracias a la ayuda de sus patrocinadores, además de que no ofrecen una variedad muy grande de certificados, prácticamente sus opciones están reducidas a validación de dominios.

En otras palabras, para obtener un certificado por parte de Let’s Encrypt, es necesario que tu dominio sea alcanzable en internet. Por lo que puedes despedirte si quieres usarlo en servicios exclusivos en LAN.

Pero existen los tuneles de CloudFlare, ellos son seguros y puedes confiar en ellos.

Adelante, confía en ellos, a mí en lo personal no me agrada mucho ese MITM voluntario donde ellos tienen el control sobre el SSL y en cualquier momento podrían ver que estás consumiendo, transportando o viendo en tu red/servicios.

No tengo nada interesante que ocultar o que me puedan robar, no me importa ese tipo de privacidad.

Eres libre de usar el método DDNS + CF + Let’s Encrypt entonces. Con respecto a la parte de la privacidad, te invito entonces a cambiar todas las puertas y ventanas de tu hogar por unas hechas de material 100% transparente. Si no tienes nada interesante que ocultar, estoy seguro de que no te sentirás incómodo con la idea 😉.

La mala fe a los certificados autofirmados es, más bien, mediocridad normalizada.

Similar al argumento pedorro que se utiliza para desprestigiar la práctica de alojar tus propios servicios en un servidor manejado por ti, los certificados sufren del mismo cáncer argumental. La gran mayoría de entidades certificadoras son entidades gubernamentales o corporaciones grandes que:

  • Invierten millones en seguridad cada año, es muy poco probable que pase algo así
  • Tienen mejor infraestructura que tú
  • Tienen un equipo de ingenieros más preparados
  • Etc.

Por supuesto, estoy seguro de que nunca han pasado fallos con las CA en las que confían todos… ¿Verdad?

No importa el tamaño de la organización, una vulneración en los servicios no es cuestión de si va a pasar o no es una cuestión de cuando va a pasar. La falacia en todo esto reside en creer que estas medidas son infalibles.

Depositar toda tu confianza en terceros, ya sean grandes corporaciones o instituciones gubernamentales, es arriesgado. Más allá del tamaño de la organización, la dependencia absoluta de terceros para garantizar la seguridad puede volverse en contra de quienes confían en ellos, esto se ha visto en muchísimos más entornos, como el caso de IFX Networks. Es más, ni las big-tech que tanto defienden algunos se salvan del castigo divino del tiempo.

Defender este modelo ciegamente no solo muestra una falta de enfoque crítico, sino que también puede resultar en una pérdida de control y eficiencia en caso de que estos servicios sean retirados o fallen. Es importante mantener un enfoque crítico y considerar la diversificación de las fuentes de confianza y seguridad.

A estas alturas creo que no necesito explicar el porqué del subtítulo que leíste antes. Con buena disciplina, prácticas de seguridad en tu CA, rotación de credenciales y certificados puedes tranquilamente disminuir tus riesgos de un ataque y llevar tus servicios cifrados en casa.

Tampoco dejes los que los humos te ganen, estás en desventaja contra una empresa y por eso deberías esforzarte al doble y estar bien pendiente de tu monitoreo, control de accesos y configuraciones generales. Si una empresa ya es paranoica, tú deberías ser el doble de paranoico.

No es culpa de Sveltekit, JavaScript es experto en malas decisiones.

Ni las herramientas como curl ni los lenguajes de programación se salvan de esto. En mi caso, al intentar hacer un fetch a una REST API que tengo sobre https con un certificado autofirmado obtengo el siguiente error:

TypeError: fetch failed
    at node:internal/deps/undici/undici:12618:11
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async Object.getAPIApps 

  /// Más error aquí.
  cause: Error: self-signed certificate

En mi búsqueda me topé con que tenía básicamente 4 opciones, ninguna realmente agradable:

  1. Importar el archivo .pub y .key de mi certificado autogenerado
  2. Instalar (de nuevo undici) porque Node está más feo que el cine en domingo y no puedo reusar dependencias.
  3. Poner la variable de entorno process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0; al inicio de mi proyecto de Sveltekit.

En mi búsqueda me topé con una idea en GitHub:

Fuente: https://github.com/orgs/nodejs/discussions/44038

Más que una idea estoy bastante de acuerdo con la persona de este post.

Podrá ser una herejía la solución que encontré para esto, pero ¿Y si encuentro la manera de apagar esa variable cuando lo necesite sin dejarla apagada por toda la ejecución del programa? Debería haber una forma.

Pues, si estoy haciendo un fetch de forma asíncrona, eso significa que mi función regresa una promesa. Y el tipo Promise en JavaScript y en TypeScript tiene los métodos:

  • Promise.prototype.catch()
  • Promise.prototype.finally()
  • Promise.prototype.then()

Según Mozilla:

The finally() method of Promise instances schedules a function to be called when the promise is settled (either fulfilled or rejected). It immediately returns an equivalent Promise object, allowing you to chain calls to other promise methods.

O sea, mientras mi promesa esté resuelta, no hay pedo, el método finally() va a ejecutarse…

¿Y si hago una función usando genéricos? 🤔 Podría encender y apagar el valor de la variable con un poco de discreción al momento de usar la función. Por supuesto que podría ser inseguro o sucio. Pero es la manera más simple que se me ocurre sin tener que instalar undici.

Al final terminé con una función genérica llamada unfetch por “unsafe fetch” que hace lo que crees que hace.

Dije que era un artículo corto, así que aquí está la solución con la que di, sin instalar dependencias y sin apagar las variables de entorno en toda la ejecución del programa:

/**
 * Fetches data from a specified URL with provided headers.
 * @template T The expected return type of the fetch request.
 * @param {string} url The URL to fetch data from.
 * @param {Record<string, string>} headers The headers to include in the fetch request.
 * @returns {Promise<T>} Returns a promise that resolves with the fetched data.
 */
export async function unfetch<T = any>(
    url: string,
    headers: Record<string, string>
): Promise<T> {
    const originalRejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
    process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";

    return fetch(url, { headers })
        .then(result => {
            if (!result.ok) {
                return Promise.reject(result);
            }
            return result.json() as Promise<T>;
        })
        .catch(error => {
            return Promise.reject(error);
        })
        .finally(() => {
            process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalRejectUnauthorized;
        });
}

Ejemplos de uso

Aquí te dejo algunos ejemplos de como podrías usar la función que acabo de publicar aquí.

Con tipos de TypeScript

Primero, definamos un tipo de TypeScript para los datos que esperamos recibir:

interface Data {
    id: number;
    name: string;
    email: string;
}

Ahora, podemos usar este tipo al llamar a la función unfetch:

unfetch<Data>('https://api.example.com/data', { 'Content-Type': 'application/json' })
    .then(data => console.log(data))
    .catch(error => console.error(error));

Con tipos de Zod

Para Zod, primero necesitamos definir un esquema que describa la forma de los datos:

import { z } from 'zod';

const DataSchema = z.object({
    id: z.number(),
    name: z.string(),
    email: z.string(),
});

Luego, podemos usar este esquema para validar los datos devueltos por la función unfetch:

unfetch('https://api.example.com/data', { 'Content-Type': 'application/json' })
    .then(data => {
        const result = DataSchema.safeParse(data);
        if (result.success) {
            console.log(result.data);
        } else {
            console.error(result.error);
        }
    })
    .catch(error => console.error(error));

En este ejemplo, DataSchema.safeParse(data) intentará validar los datos. Si los datos son válidos, result.success será true y result.data contendrá los datos validados. Si los datos no son válidos, result.success será false y result.error contendrá detalles sobre los errores de validación.

Alternativamente puedes exportar el tipo de TypeScript de tu esquema de Zod y ajustar el código a esto:

import { z } from 'zod';

const DataSchema = z.object({
    id: z.number(),
    name: z.string(),
    email: z.string(),
});

export type Data = z.infer<typeof DataSchema>;

Con eso, podrás llamar a la función de la misma forma que lo harías con los tipos de TypeScript:

unfetch<Data>('https://api.example.com/data', { 'Content-Type': 'application/json' })
    .then(data => console.log(data))
    .catch(error => console.error(error));

Conclusión ✍️

Esta función podría tener errores, así que si conocer alguna forma de mejorarla estoy más que dispuesto a escuchar tus sugerencias 😉. Como te dije es un artículo corto y no tiene nada más que un rant y una solución digna de una comparación a arreglar un coche con cinta de aislar, a un problema que considero, debería ser sencillo de solucionar.

¿Se te ocurre otra manera de solucionarlo? Irónicamente, esto es un skill issue de mi parte, podría seguir la doctrina de Node, pero me niego a dejarle al desarrollador lo que debería ser trabajo del Sysadmin.

Canción triste del día

A carnival of idiots on show […]