Cómo construir un formulario de contacto con AWS API Gateway, Lambda y SNS
Actualmente, MyWebsite está alojada en el servicio Pages de Cloudflare, que facilita crear y publicar webs. Sin embargo, este servicio no permite ejecutar código en el backend, así que solo se pueden publicar webs estáticas. Una de las necesidades que tengo ahora mismo es desarrollar un formulario de contacto para que los visitantes puedan contactarme fácilmente a través de mi web. El formulario en sí se puede crear con HTML y CSS, pero para implementar la lógica de procesado de mensajes necesito ejecutar código en el backend. En vez de mover la web a otro hosting, aprovecho las herramientas que ofrece AWS para implementar un backend serverless que se encargue de esa lógica.
Actualización: este post describe la arquitectura tal como se construyó originalmente en 2023, y la demo de más abajo sigue funcionando sobre ella. Desde entonces, la capa antiabuso ha pasado de Google reCAPTCHA a Cloudflare Turnstile (sin necesidad de banner de consentimiento bajo RGPD/ePrivacy), la Lambda usa ahora AWS SDK v3 en vez del v2 obsoleto, y se añadió un campo honeypot como filtro antispam adicional. El diseño principal — API Gateway, Lambda, SNS y Secrets Manager — no ha cambiado. Los ejemplos de código de más abajo reflejan la implementación original; consulta el repositorio contactForm para la versión actual.
Amazon Simple Notification Service (SNS)
Amazon Simple Notification Service (SNS) es un servicio de Amazon Web Services (AWS) que permite a los desarrolladores enviar notificaciones a través de múltiples protocolos, como HTTP/S, email y SMS, lo que permite crear sistemas de notificación escalables y de alta disponibilidad.
El objetivo del formulario es que, cuando alguien lo rellena y lo envía, yo reciba un email en mi dirección personal con los datos del formulario. Cuando empecé a buscar un servicio que me permitiera enviar emails desde el backend, descubrí Amazon Simple Email Service (SES), un servicio de envío de email escalable y seguro que permite enviar emails masivos, personalizados y transaccionales. Consideré usar este servicio, pero al final me pareció matar moscas a cañonazos: no necesitaba capacidad de envío masivo, solo una notificación por email cuando se enviara el formulario. Fue entonces cuando encontré Amazon SNS, una solución mucho más sencilla para lo que necesitaba.
El primer paso fue crear un nuevo topic estándar de SNS y darle un nombre; el resto de parámetros de configuración se dejaron por defecto.
El segundo paso es crear una suscripción al topic de SNS que acabo de crear, eligiendo email como protocolo y añadiendo una dirección de correo. Una vez creada, AWS enviará un email a esa dirección con un enlace para confirmar la suscripción. Una vez confirmada, cualquier mensaje publicado en el topic de SNS se entregará a esa dirección de correo.
AWS Secrets Manager
AWS Secrets Manager es un servicio de AWS que permite almacenar de forma segura, gestionar y rotar información sensible, como credenciales de bases de datos y claves de API. El servicio ofrece un lugar centralizado para almacenar y gestionar secretos, lo que ayuda a mejorar la seguridad y el cumplimiento normativo al reducir la necesidad de incrustar secretos en el código de las aplicaciones o compartirlos por otros medios.
Secrets Manager se usa para almacenar de forma segura la clave privada de reCaptcha y el identificador ARN del topic de SNS. Esto evita tener que incluir información sensible en texto plano dentro del código fuente de la función Lambda. En su lugar, durante la inicialización de la función se accede a estos datos y se asignan a variables globales dentro del código. Así se pueden usar desde el handler de la función Lambda cada vez que se invoca, manteniendo además un mayor nivel de seguridad al no tener la información sensible incluida en el código fuente.
const init = async () => {
const secretsManager = new SecretsManager({region: REGION});
try {
const secret = await secretsManager.getSecretValue({ SecretId: 'SECRET_ID' }).promise();
const secrets = JSON.parse(secret.SecretString);
CAPTCHA_KEY = secrets.CAPTCHA_SECRET_KEY;
SNS_ARN = secrets.SNS_SECRET_KEY;
} catch (error) {console.log(error);}
};
Google reCAPTCHA
Cuando el front end de una aplicación necesita realizar una tarea que solo puede llevar a cabo el backend, hace falta hacer una llamada al backend pasándole la información necesaria para realizar esa tarea. En este caso, mi backend es una API a la que se accede mediante una petición HTTPS hecha desde el navegador del cliente. La URL a la que hay que hacer esta petición es pública, ya que tiene que estar escrita en texto plano en el código fuente de JavaScript. Sin embargo, esto significa que cualquiera con acceso al código fuente y con intenciones maliciosas podría empezar a enviar peticiones no deseadas a mi backend. Es importante tener esto en cuenta y tomar las medidas de seguridad adecuadas para proteger el backend frente a posibles ataques.
Una de las medidas de seguridad que se pueden implementar es CORS (Cross-Origin Resource Sharing), un mecanismo de seguridad que se usa para restringir el acceso a recursos de un dominio desde un dominio distinto. Esto significa que una página web que corre en un dominio puede hacer peticiones HTTP a un servidor de otro dominio, siempre que ese servidor haya autorizado esas peticiones previamente. CORS es una medida de seguridad que se usa para prevenir ataques de «cross-site request forgery» (CSRF).
Hacen falta medidas adicionales para asegurar que las peticiones hechas a través del formulario de contacto de mi web son legítimas. Una opción es usar un servicio de terceros como reCAPTCHA. reCAPTCHA es un servicio de Google que protege las webs frente a spam y abuso automatizado, y se usa habitualmente en formularios de contacto.
// Front-end side
form.addEventListener('submit', (e) => {
e.preventDefault();
const token = grecaptcha.getResponse();
if (token.length === 0) {
// The captcha has not been completed
} else{
// Call the backend passing the token
}
});
Cuando un usuario envía el formulario de contacto, reCAPTCHA usa un sistema de desafíos para determinar la legitimidad de la petición. Le presenta al usuario un reto, como un puzle o una tarea de selección de imágenes, para confirmar que la petición la hace un humano y no un bot. Para validar desde mi backend que la petición a la API es válida, reCAPTCHA usa un sistema de claves pública y privada. La clave pública se usa para cifrar la información enviada desde el formulario de contacto al servidor, y se envía junto con la clave pública en una petición HTTP. Mi backend usa la clave privada para descifrar la información cifrada y validar la petición. Si la petición es legítima, continúo con la ejecución de la API; si no, la rechazo.
// Back-end side
const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
body: 'secret=' + CAPTCHA_KEY + '&response=' + token,
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
});
AWS Lambda
AWS Lambda es un servicio de computación serverless de AWS. Permite ejecutar código sin tener que aprovisionar ni gestionar servidores. Con Lambda se pueden construir y ejecutar aplicaciones y servicios sin tener que pensar en la infraestructura. Se crea una función Lambda, y AWS la escala automáticamente según el ritmo de peticiones entrantes, además de encargarse de parchear y mantener los servidores subyacentes.
Política de IAM
Hace falta un rol de Identity Access Management (IAM) para conceder los permisos necesarios para publicar el mensaje en el topic de SNS, obtener los secretos de Secrets Manager y escribir logs en Amazon CloudWatch. Siguiendo el principio de mínimo privilegio, al rol se le asigna la siguiente política de IAM.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["logs:CreateLogStream", "logs:PutLogEvents", "logs:CreateLogGroup"],
"Resource": "arn:aws:logs:REGION:ACCOUNT_ID:*"
},
{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:REGION:ACCOUNT_ID:secret:SECRET_NAME"
},
{
"Effect": "Allow",
"Action": "sns:Publish",
"Resource": "arn:aws:sns:REGION:ACCOUNT_ID:SNS_TOPIC"
}
]
}
Posibles respuestas de la función
Para llevar un control de lo que ocurre dentro de mi función Lambda durante su ejecución, he creado una serie de posibles respuestas que devuelve la función. Cada una de estas respuestas consiste en un código de respuesta del servidor y un cuerpo de respuesta que indica si la tarea se completó correctamente o si hubo un error, en cuyo caso se adjunta un código y un mensaje describiendo el problema. Estas son las posibles respuestas:
| Código | Código de estado | Mensaje |
|---|---|---|
| SECRET_RETRIEVAL_ERROR | 500 | Error al recuperar los secretos de Secrets Manager |
| RECAPTCHA_CONNECTION_ERROR | 500 | No se pudo conectar con el servidor de reCAPTCHA |
| RECAPTCHA_VERIFY_ERROR | 500 | La verificación de reCAPTCHA devolvió false |
| SNS_PUBLISH_ERROR | 500 | No se pudo enviar el mensaje al topic de SNS |
| JSON_PARSE_ERROR | 400 | Cuerpo HTTPS inválido |
| MISSING_INPUT_ERROR | 400 | Faltan campos obligatorios |
| VERIFY_INPUT_ERROR | 400 | Entradas inválidas |
| FORM_SUBMITTED_SUCCESSFULLY | 200 | Formulario enviado correctamente |
Valida y sanea las entradas de la Lambda
«validateInput» se usa para asegurar que la entrada que recibe la función Lambda es válida y segura de usar. Primero comprueba que se han proporcionado todos los campos necesarios. Después comprueba que el email tiene un formato válido, y que el subject y el message tienen un tamaño válido. Si alguna de estas comprobaciones falla, devuelve «false». Por último, sanea la entrada para eliminar cualquier código potencialmente malicioso y devuelve la entrada limpia.
const validateInput = (input) => {
const REQUIRED_INPUTS = ['mail', 'subject', 'message', 'token'];
for (let i = 0; i < REQUIRED_INPUTS.length; i++) {if (!input[REQUIRED_INPUTS[i]]) {return false;}}
const MAIL_REGEX = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
if (!MAIL_REGEX.test(input.mail)) {return false;}
if (input.subject.length < 4 || input.subject.length > 100 || input.message.length < 20 || input.message.length > 1000) {return false;}
input.mail = input.mail.trim().replace(/</g, "<").replace(/>/g, ">");
input.subject = input.subject.trim().replace(/</g, "<").replace(/>/g, ">");
input.message = input.message.trim().replace(/</g, "<").replace(/>/g, ">");
return input;
};
Publica un mensaje en un topic de SNS
«publishMessageToSNS» usa el AWS SDK para crear una instancia del cliente de SNS, y después construye el mensaje que se va a publicar. La función prepara los parámetros necesarios para el método publish de SNS, incluyendo el subject del mensaje, el mensaje en sí, y el ARN del topic de SNS. Después usa el cliente de SNS para publicar el mensaje en el topic indicado. Si el mensaje se publica correctamente, la función resuelve la promesa y devuelve la respuesta. Si hay un error y el mensaje no se pudo publicar, la función rechaza la promesa y lanza un error.
const publishMessageToSNS = async (mail, subject, message) => {
try {
const sns = new SNS({region: REGION});
const snsMessage = {mail: mail, subject: subject, message: message};
const snsParams = {Subject: '[CONTACT_FORM]', Message: JSON.stringify(snsMessage), TopicArn: SNS_ARN};
const response = await sns.publish(snsParams).promise();
return response;
} catch (error) {throw new Error(error);}
};
Lambda handler
Una función «handler» es el punto de entrada principal de una función Lambda y se encarga de gestionar el evento concreto que dispara la función. Define la lógica de la función y procesa el evento de entrada. Una vez procesado el evento, el handler devuelve una respuesta o un error.
Este handler comprueba que las variables globales CAPTCHA_KEY y SNS_ARN se han inicializado con los valores obtenidos de Secrets Manager. Después recupera la petición POST y verifica que los valores recibidos del front-end son correctos. Una vez que sé que se han recibido todos los datos necesarios, verifica que la petición a mi función es válida llamando al servidor de reCAPTCHA. Por último, si todo es correcto, publica el mensaje en el topic de SNS.
export const handler = async (event) => {
if (!CAPTCHA_KEY || !SNS_ARN) {return SECRET_RETRIEVAL_ERROR;}
let body = undefined;
try {body = JSON.parse(event.body);} catch (error) {return JSON_PARSE_ERROR;}
body = validateInput(body);
if(!body) {return VERIFY_INPUT_ERROR;}
let success = false;
try {success = await verifyRecaptcha(body.token);} catch (error) {return RECAPTCHA_CONNECTION_ERROR;}
if(!success) {return RECAPTCHA_VERIFY_ERROR;}
try {await publishMessageToSNS(body.mail, body.subject, body.message);} catch (error) {return SNS_PUBLISH_ERROR;}
return FORM_SUBMITTED_SUCCESSFULLY;
};
Si quieres echarle un vistazo al código JavaScript completo de la Lambda, puedes hacerlo en lambda.mjs.
Amazon API Gateway
Amazon API Gateway es un servicio totalmente gestionado de AWS que permite crear, publicar, mantener y monitorizar APIs (Application Programming Interface) a cualquier escala. API Gateway permite crear APIs HTTP, RESTful y WebSocket, capaces de gestionar múltiples peticiones simultáneas y enrutarlas al servicio de backend adecuado, como AWS Lambda, Amazon EC2, o un endpoint HTTP.
Para exponer mi función Lambda al front-end, uso una API HTTP. La Invoke URL por defecto está deshabilitada y sustituida por un dominio personalizado. Creo la ruta «/contactForm» y la asocio con la función Lambda. Configuro CORS para permitir solo mi dominio, el método POST, y el header content-type como único header permitido.
Formulario de contacto
HTML
Primero, defino el formulario de contacto en HTML. Este formulario debe incluir tres campos: la dirección de email del remitente, el subject del mensaje, y el message en sí. El código HTML es el siguiente:
<form id="contact" enctype="multipart/form-data" method="post">
<input type="email" placeholder="[email protected]" name="mail" maxlength="50" aria-label="Email" required>
<input type="text" placeholder="Your message subject" name="subject" maxlength="100" aria-label="Subject" required>
<textarea placeholder="Your message content" aria-label="Message" name="message" maxlength="1000" required></textarea>
<div class="g-recaptcha" data-sitekey="RECAPTCHA_PUBLIC_KEY"></div>
<input type="submit" value="Send the message">
</form>
Este código crea un formulario HTML con campos de entrada para que el usuario introduzca datos. La etiqueta «form» tiene un atributo «id» para identificar el formulario, y los elementos de entrada tienen atributos «placeholder» y «maxlength» para mostrar un mensaje de ejemplo en el campo y fijar un límite de caracteres, respectivamente. También hay un atributo «aria-label» que describe el propósito del campo para los usuarios de lectores de pantalla. Además, el atributo «required» indica que el usuario debe proporcionar un valor válido para poder enviar el formulario.
JavaScript
Ahora que mi formulario de contacto tiene buen aspecto, toca implementar la lógica que hay detrás de su gestión. Desde el JavaScript de la web defino un evento que se dispara cuando el usuario envía el formulario. Lo primero que hago dentro del evento es usar la función preventDefault() para desactivar el envío por defecto del formulario y gestionar yo mismo el envío de datos al backend, evitando así que la página se recargue.
Después recupero los datos del formulario. Para evitar inyecciones de código y texto no deseado, saneo los valores de entrada con las funciones trim() y replace(). Con las cadenas saneadas, creo el objeto formData y lo envío al backend usando la función fetch() de JavaScript y el método POST.
fetch('https://BACKEND_DOMAIN/API_PATH', {
method: 'POST',
body: JSON.stringify(formData),
headers: {
'Content-Type': 'application/json'
}
});
Gestiono las posibles respuestas que me puede dar el backend, y en función de eso aplico una clase al formulario para darle al usuario feedback de que se ha enviado correctamente o de que ha ocurrido algún error. Si quieres echarle un vistazo al código JavaScript completo, puedes hacerlo en contact-form.js.
Resultado
Aquí abajo comparto el formulario de contacto resultante de la implementación que describo en este post. Me encantaría que lo uses para dejarme tus comentarios y compartir tu opinión, si tienes ideas o críticas constructivas sobre cómo podría mejorar esta implementación. Estoy muy abierto a recibir sugerencias.
¡Gracias por leer! Nos vemos en el próximo.