En un mundo donde las ciberamenazas son cada vez más sofisticadas, proteger las aplicaciones backend contra vulnerabilidades se vuelve clave para la seguridad.

En este artículo, te presentaremos cinco pasos principales que te ayudarán a minimizar los riesgos y hacer que la parte del servidor sea más segura.

Proteger el backend no es solo cuestión de un buen código. Es cuestión de supervivencia.

La parte del servidor de la aplicación no es una vitrina, sino el corazón del sistema: aquí se procesan datos confidenciales y se gestiona la lógica empresarial.

Una vulnerabilidad en el backend no es solo un error, sino una posible fuga de datos, pérdidas financieras y, en el peor de los casos, el control total del sistema por parte de los atacantes.

Vivimos en un mundo donde el enemigo es conocido. Las grandes empresas y las comunidades especializadas publican anualmente informes sobre las vulnerabilidades encontradas. Al estudiarlos, se puede ver que los ataques rara vez son nuevos; la mayoría de las veces, los desarrolladores siguen cometiendo los mismos errores.

Auditoría de seguridad: por dónde empezar

La seguridad no es un estado, sino un proceso. Y si este proceso no está integrado en el trabajo del equipo, significa que ya existen vulnerabilidades en el sistema que simplemente no conoces.

El enfoque para detectar los puntos débiles debe incluir varias prácticas clave (recuerda estas como un mantra):

  1. Tu código puede estar limpio, pero la vulnerabilidad se esconde en una de las bibliotecas. Las herramientas para analizar las dependencias deben funcionar en cada etapa del desarrollo.
  2. Nunca confíes en lo que proviene del usuario. Cualquier entrada es una potencial amenaza.
  3. Los datos confidenciales no deben aparecer en los registros de errores o los registros del sistema.
  4. A cada componente del sistema solo se le otorga el nivel de acceso que necesita. Ni una línea más.
  5. Las funciones críticas deben revisarse periódicamente en busca de vulnerabilidades.
  6. Utiliza herramientas especializadas. Esto no solo incluye el análisis estático y dinámico del código, sino también el monitoreo de la actividad y el análisis de comportamiento del tráfico.
  7. Si aparece información sobre datos comprometidos en el espacio público, tú debes ser de los primeros en enterarte.

La seguridad comienza con el conocimiento de los puntos débiles. Ahora, analicemos los pasos que ayudarán a cerrar estos puntos débiles.

Engranajes sobre código binario con la frase "Backend Development".
Concepto de seguridad en el desarrollo backend.

Lista de verificación de protección del backend: 5 pasos clave

#1. Paso 1. Protección contra XSS: protege la entrada y la salida

Problema:

XSS (Cross-Site Scripting) es una vulnerabilidad que permite a un atacante inyectar código malicioso en una página web, que luego aparecerá en el navegador de otros usuarios.

Qué hacer:

Para prevenir XSS, es necesario proteger los datos que se muestran utilizando funciones que convierten los caracteres especiales en entidades HTML. Por ejemplo, para las plantillas de Go, puedes usar html/template en lugar de text/template, lo que proporciona un escaneo automático de los datos.

Ejemplo:

import "text/template"

var tmpl = template.Must(template.ParseGlob("templates/*"))

func (a *App) indexHandler(c echo.Context) error {
  rows, _ := a.db.Query("SELECT title, content FROM posts")
  defer rows.Close()

  posts := getPosts(rows)
	
  // Se insertan los posts directamente en HTML
  templates.Get().ExecuteTemplate(c.Response().Writer, "index.html", posts)
}

Aquí, se muestra la entrada del usuario (título y contenido del post) directamente en HTML sin sanitizar, lo que permite inyectar código JavaScript si de alguna manera llegó a la base de datos. Para solucionarlo, basta con usar la librería "html/template"

import "html/template"

var tmpl = template.Must(template.ParseGlob("templates/*"))

#2. Paso 2. Protección contra CSRF: utiliza tokens

Problema:

Si el servidor no verifica el origen de la solicitud, un atacante puede hacer que el navegador del usuario realice una solicitud en su nombre.

Qué hacer:

El usuario abre un formulario para crear una publicación. Si un atacante obliga al usuario a enviar una solicitud POST /post, por ejemplo, a través de <img src="http://ejemplo.com/create?title=Hacked&content=Malicious" />, el servidor ejecutará esta solicitud sin verificar quién la envió.

Para protegerse contra CSRF, es necesario utilizar tokens especiales que son únicos para cada solicitud. El servidor los verifica, y si el token está ausente o no es válido, el servidor rechaza la solicitud. Una de las opciones es vincular los tokens CSRF a la sesión del usuario, lo que impide que un atacante use los tokens de otro usuario. Para tu comodidad, existen bibliotecas como gorilla/csrf que proporcionan protección contra ataques CSRF.

Adicionalmente, puedes aumentar el nivel de seguridad verificando los encabezados Referer u Origin en las solicitudes POST para asegurarte de que la solicitud proviene de una fuente confiable. También es útil restringir CORS (Cross-Origin Resource Sharing) para que solo ciertos sitios puedan enviar solicitudes a tu servidor.

Ejemplo:

func (a *App) postCreateHandler(c echo.Context) error {
   title := r.FormValue("title")
   content := r.FormValue("content")

   _, err := a.db.Exec("INSERT INTO posts (title, content) VALUES (?, ?)", title, content)
   if err != nil {}

   return c.Redirect(http.StatusSeeOther, "/")
}

Para corregir el problema, hay que crear un token CSRF y verificarlo al crear la publicación.

Método modificado para la página de creación de publicación:

func (a *App) postPageHandler(c echo.Context) error {
   token := generateCSRFToken()

   templates.Get().ExecuteTemplate(c.Response().Writer, "post.html", token)

   return nil
}

Método modificado para guardar la publicación:

func (a *App) postCreateHandler(c echo.Context) error {
   token := c.FormValue("csrf_token")
   if !validateCSRFToken(token) {
       return c.String(http.StatusForbidden, "invalid request method")
   }

   // Lógica similar al ejemplo con error
}

#3. Paso 3. Control de acceso: evita vulnerabilidades IDOR y ACL

Problema:

Todo esto son deficiencias en el control de acceso IDOR. Este problema surge cuando la aplicación permite al usuario acceder a recursos o acciones sin la verificación adecuada de los derechos. Esto ocurre a menudo si el acceso a los recursos se identifica mediante valores de ID que se pueden predecir o modificar.

Qué hacer:

Para evitar vulnerabilidades IDOR y Broken ACL, debes verificar los derechos del usuario antes de otorgar acceso a los recursos. Consideremos un ejemplo con el siguiente fragmento de código.

Ejemplo:

Se añadió al sitio la posibilidad de ver una publicación específica si se pasa su ID en la URL /post/{{post_id}}

func (a *App) getUserPostsHandler(c echo.Context) error {
   postID, _ := strconv.Atoi(r.URL.Query().Get("post_id"))

   rows, err := a.db.Query("SELECT title, content FROM posts WHERE post_id = ? ORDER BY created_at DESC", postID)
   if err != nil {
       return c.String(http.StatusInternalServerError, "internal server error")
   }
   defer rows.Close()

   post := getPost(rows)

   templates.Get().ExecuteTemplate(c.Response().Writer, "user_posts.html", posts)

   return nil
}

A simple vista, parece seguro. Pero en realidad no hay verificación de si la publicación pertenece al usuario actual. Esto permite a un atacante acceder a cualquier publicación, solo con conocer su post_id.

Para corregirlo, basta con indicar el usuario en la consulta:

func (a *App) getUserPostsHandler(c echo.Context) error {
 userID, _ := strconv.Atoi(c.QueryParam("user_id"))
 postID, _ := strconv.Atoi(c.QueryParam("post_id"))

 const query = `
  SELECT title, content 
  FROM posts 
  WHERE user_id = ? AND post_id = ? 
  ORDER BY created_at DESC
 `
 rows, err := a.db.Query(query, userID, postID)
 // Lógica similar al ejemplo con error
}

Adicionalmente, se debería reconsiderar el uso de identificadores numéricos para publicaciones, ya que los valores enteros son fáciles de recorrer.

Por ejemplo, un atacante puede simplemente incrementar post_id e intentar obtener información de publicaciones con IDs mayores para evaluar la popularidad de tu servicio en comparación con la competencia.

Para evitar esto, se recomienda usar UUID versión 7. Este tipo de identificadores está diseñado para funcionar bien con bases de datos y no ser susceptibles a un recorrido simple.

#4. Paso 4. No almacenes información confidencial en texto plano

Problema:

Si la información confidencial se almacena en la base de datos en texto plano, cualquier fuga de datos se convierte en una catástrofe. Esto puede ocurrir debido a errores en el código, una configuración incorrecta o fugas a través del registro.

Qué hacer:

Para evitar la divulgación de datos confidenciales, no los incluyas en las respuestas de la API. Es suficiente reducir el nivel de registro para los datos sensibles o aplicar ofuscación a los confidenciales. También te recomiendo evitar el almacenamiento explícito de secretos en el código.

Ejemplo:

Observemos el esquema de almacenamiento de datos de usuarios:

CREATE TABLE users (
    id INTEGER PRIMARY KEY DEFAULT nextval('users_id_sequence'),
    login TEXT UNIQUE,
    password TEXT,
    is_admin BOOLEAN,
);

En el código actual, se puede ver que las contraseñas se almacenan en la base de datos en un campo de tipo TEXT en texto plano. Esto es extremadamente inseguro, ya que cualquiera que tenga acceso a la base de datos puede obtener las cuentas. Para mayor seguridad, se deben almacenar los hashes de las contraseñas, no las contraseñas mismas.

Para ello, recomiendo usar una función hash criptográfica (Argon2 o su modificación Argon2id). Este algoritmo proporcionará un almacenamiento seguro y confiable de las contraseñas.

Para corregir el problema actual, hay que hacer los siguientes cambios en el código:

  1. Para trabajar con el algoritmo Argon2, se debe usar esta biblioteca.
  2. Al registrar un usuario, solo se debe escribir en la base de datos el hash de la contraseña, no la contraseña misma. Se ve así:
func (a *App) signupHandler(c echo.Context) error {
 password := c.FormValue("password")

 // Lógica adicional de la aplicación
 hash, _ := argon2id.CreateHash(password, argon2id.DefaultParams)
 const query = `
  INSERT INTO users (username, password, is_admin) 
  VALUES (?, ?, ?) 
  RETURNING id
 `
 row := a.db.QueryRow(query, username, hash, isAdmin)
 // .. 
}

Si usas parámetros de hashing no estándar, se recomienda guardarlos en la base de datos junto con el hash para mantener la compatibilidad con versiones anteriores de usuarios en futuras migraciones a nuevos algoritmos. Al iniciar sesión, hay que comparar la contraseña ingresada con el hash almacenado en la base de datos. Esto se puede implementar de la siguiente manera:

func (a *App) loginHandler(c echo.Context) error {
 // ..
 password := c.FormValue("password")
 // Lógica adicional
 row := a.db.QueryRow("SELECT id, password FROM users WHERE username = ?" username)
 var (
  userID int
  hash string
 )
 // Se leen los datos en las variables de row
 // …
 // Se compara la contraseña y la función hash que se escribió al registrarse en la BD
 match, err := argon2id.ComparePasswordAndHash(password, hash)
 if err != nil || !match {
  return c.String(http.StatusForbidden, "invalid credentails")
 }
 // ..
}

#5. Paso 5. Protección contra inyecciones SQL: utiliza consultas preparadas

Problema:

Las inyecciones SQL ocurren cuando la entrada del usuario se procesa directamente en las consultas SQL sin la debida filtración o protección. Un atacante puede insertar código SQL malicioso para obtener acceso a los datos, modificarlos o destruirlos.

Qué hacer:

Para protegerse contra inyecciones SQL, es importante usar siempre expresiones preparadas. Esto garantiza que la entrada del usuario esté protegida y no se interprete como parte de la consulta SQL. En lugar de compilar consultas manualmente, siempre pasa los parámetros a través de los símbolos ? o $1, dependiendo de la base de datos que uses. Te recomiendo que nunca agregues datos directamente a la cadena de la consulta SQL, ya que esto abre la posibilidad de inyecciones SQL.

Además, es importante verificar y validar cuidadosamente la entrada del usuario para que coincida con el formato esperado. Por ejemplo, para las direcciones de correo electrónico u otros datos, utiliza expresiones regulares para asegurarte de que la entrada coincida con el patrón requerido. Esto ayuda a prevenir datos incorrectos o potencialmente peligrosos que pueden usarse para atacar tu sistema.

Ejemplo:

func (a *App) loginHandler(c echo.Context) error {
   username := c.FormValue("username")
   password := c.FormValue("password")

   row := a.db.QueryRow("SELECT id FROM users WHERE username = " + username + "AND password = " + password)
   var userID int
   if err := row.Scan(&userID); err != nil {
       return c.String(http.StatusUnauthorized, "invalid credentails")
   }

   return c.Redirect(http.StatusSeeOther, "/")
}

Para protegerse contra inyecciones SQL, basta con usar sentencias preparadas, lo que mejora significativamente la seguridad de la aplicación. En el ejemplo anterior, basta con reemplazar la cadena de la consulta por el uso de consultas parametrizadas:

func (a *App) loginHandler(c echo.Context) error {
 //..

 const query = `
  SELECT id 
  FROM users 
  WHERE 
   username = ? AND 
   password = ?
 `

 row := a.db.QueryRow(query, username, password)
 // ..
}

Si trabajas con consultas más complejas en Go, puedes usar la librería squirrel, que ayuda a construir consultas de forma segura y evitar errores al trabajar con SQL.

Como puedes ver, la mayoría de los problemas están relacionados con la validación y el procesamiento correcto de los datos que provienen del mundo exterior. Es muy importante no descuidar la verificación de los casos límite, ya que a menudo son la fuente de vulnerabilidades y problemas.

Además, quiero señalar otra práctica importante que a menudo se pasa por alto: el uso de la herramienta golangci-lint.

Actualmente, es un estándar para los desarrolladores de Go, aunque a veces puedes encontrar proyectos donde no se utiliza. Permite encontrar automáticamente vulnerabilidades, errores y antipatrones en el código en las primeras etapas del desarrollo. La composición de golangci-lint incluye el analizador estático gosec, que está orientado a la búsqueda de problemas de seguridad.

El uso de la herramienta ayuda a reducir considerablemente los riesgos de errores en producción. Para otros lenguajes de programación populares, también existen herramientas similares que debes tener en cuenta durante el desarrollo.

Tecnologías y enfoques prometedores en la protección del backend

En el ámbito de la protección del backend, existen varias tecnologías y enfoques prometedores que pueden mejorar significativamente la seguridad.

Arquitectura Zero Trust (ZTA) o Política Zero Trust (ZTP)

Se basa en el principio de «no confiar en nadie». En este enfoque, ninguna solicitud se considera confiable de forma predeterminada, incluso si proviene de la red interna. Esto significa que los servicios que pertenecen a un mismo equipo no pueden confiar automáticamente entre sí. Para la interacción entre ellos, es necesario solicitar accesos que se pueden gestionar de forma centralizada, lo que aumenta el nivel de control y seguridad.

Cifrado Homomórfico (Homomorphic Encryption)

Este es un método que permite realizar cálculos con datos cifrados sin descifrarlos. Los resultados de los cálculos solo se pueden ver después de que finaliza la operación. Este enfoque es especialmente útil en situaciones donde los datos deben procesarse mediante sistemas externos y mantener su confidencialidad. Por ejemplo, en la legislación de Rusia existe responsabilidad por las fugas de datos personales, lo que puede llevar a graves pérdidas financieras o incluso al cierre del negocio. El cifrado homomórfico ayuda a resolver este problema, permitiendo el procesamiento de datos sin su revelación.

DevSecOps

Este es un enfoque que integra la seguridad en los procesos de desarrollo y operación en todas las etapas del ciclo de vida del software. Se hace hincapié en la automatización, la colaboración y la mejora continua de la seguridad. El objetivo principal de DevSecOps es que la seguridad no sea una etapa separada que se realiza después de que finaliza el desarrollo, sino una parte integral de la cultura y los procesos del proyecto.

Habilidades del desarrollador backend que lo protegerán de las vulnerabilidades

Un ingeniero que busca minimizar errores y garantizar la seguridad del producto debe tener una serie de habilidades clave. Analicemos estas con más detalle.

En primer lugar, una comprensión profunda de los principios de seguridad. Esto incluye el conocimiento de las vulnerabilidades comunes: inyecciones SQL, XSS, CSRF, SSRF y otras amenazas de la lista OWASP. También es importante comprender los principios de minimización de privilegios y diseño seguro.

Una parte integral del trabajo es la práctica de la programación segura. El desarrollador debe saber trabajar con mecanismos de autenticación y autorización: OAuth 2.0, OpenID Connect y JWT, y usar conexiones seguras (TLS/HTTPS) y cifrado (AES, RSA).

La seguridad de la API merece atención especial. El ingeniero debe comprender cómo diseñar interfaces seguras, aplicar límites a la frecuencia de las solicitudes, validar los datos de entrada y verificar los tokens. Además, es importante saber trabajar con herramientas de seguridad: SAST (SonarQube, Checkmarx), DAST (OWASP ZAP, Burp Suite) y SCA (Snyk, Dependabot).

Para entender la infraestructura y la seguridad del despliegue, el desarrollador debe gestionar con confianza los contenedores (Docker, Kubernetes), trabajar con servicios en la nube (AWS, Azure, GCP) y usar los mecanismos de seguridad integrados. También es importante comprender los principios de monitoreo y respuesta a incidentes: analizar registros, configurar sistemas de alerta y responder rápidamente a las amenazas.

Para mejorar el nivel de seguridad del equipo, no solo es necesario tener conocimientos, sino también actualizarlos regularmente. Se puede comenzar estudiando la clasificación OWASP Top 10, que recopila las amenazas más actuales. Sin embargo, la teoría sin práctica es inútil, por lo que vale la pena usar plataformas de entrenamiento: Hack The Box, PortSwigger Academy y PentesterLab.

La implementación de los enfoques DevSecOps aumenta significativamente la seguridad del sistema. Las herramientas automatizadas: análisis estático de código (SAST), pruebas dinámicas (DAST) y análisis de dependencias (SCA), deben integrarse en el proceso CI/CD.

Además, se pueden organizar competiciones internas en el formato Capture The Flag (CTF) para involucrar al equipo en el proceso y mejorar su conocimiento. Si este formato no te resulta familiar, puedes estudiar ejemplos, como las competiciones que organiza Google: Google CTF.

Las verificaciones anónimas periódicas de vulnerabilidades, pruebas de phishing o simulaciones de ataques mediante la suplantación de puntos de acceso, ayudarán a identificar puntos débiles y fortalecer la protección.

Palabras Finales

En mi opinión, lo más importante es crear una cultura de seguridad en el equipo. Las discusiones periódicas, el intercambio de conocimientos y el reconocimiento a los empleados por las vulnerabilidades identificadas ayudarán no solo a reducir los riesgos, sino también a hacer que la seguridad sea una parte natural del proceso de trabajo.

Mi principal consejo para los desarrolladores backend en materia de seguridad:

…desde el comienzo del desarrollo, implementa herramientas y métodos dirigidos a reducir los riesgos de vulnerabilidades en todas las etapas de desarrollo y operación.

Garantizar la seguridad es un proceso complejo y multifacético que requiere un esfuerzo significativo y un cambio de cultura dentro de toda la empresa. Las habilidades de ciberseguridad ya no son una esfera exclusiva de especialistas, sino una necesidad para cada persona en el mundo moderno.

Categorizado en:

Fundamentos, Go,