Consigna y escenarios aplicables
El anfitrión se ejecuta en https://app.example.com e incrusta un iframe de pago desde https://pay.example.net. Una vez que el widget está listo, el anfitrión envía la configuración regional (locale), el tema y una referencia de sesión de pago de corta duración. El iframe notifica ready, cambios de altura, cancelaciones de usuarios y la finalización del flujo de pago. La página puede contener varios iframes del mismo origen, y el widget puede redirigirse o recrearse durante la carga.
Diseña el protocolo bidireccional postMessage y la implementación de frontend. Cubre la ventana de destino, la identidad del receptor, la estructura de los mensajes, los tiempos de inicialización, duplicados y reordenamientos, navegación, limpieza y pruebas de seguridad. Un mensaje de finalización solo solicita la actualización de la interfaz de usuario; el servidor anfitrión debe confirmar el estado final del pago con el sistema de pagos autoritativo.
Esta pregunta es adecuada para roles de frontend senior, plataforma Web, arquitectura frontend y seguridad de aplicaciones. Sus competencias fundamentales son la política de mismo origen del navegador, la mensajería entre documentos, los límites de confianza del cliente y el diseño de protocolos asíncronos, por lo que su categoría es frontend. CORS controla si los scripts del navegador pueden leer respuestas de red de orígenes cruzados. No reemplaza las restricciones de destino de postMessage ni la validación de los listeners de mensajes.
Qué evalúa el entrevistador
La primera señal es si el candidato protege tanto al emisor como al receptor. El emisor proporciona un targetOrigin exacto para que los datos no se entreguen si la ventana de destino navega a otro origen. El receptor verifica event.origin en cada mensaje y, cuando tiene una referencia de ventana esperada, verifica event.source. Implementar solo una parte aún deja abierta una vía de divulgación o falsificación.
La segunda señal es si el candidato trata la mensajería como un punto de entrada público con datos de entrada desconocidos. Pasar una verificación de origen solo identifica el origen en el que se ejecutó el código. No demuestra la estructura del payload, ni evita que un ataque XSS o código defectuoso en un origen de confianza envíen un comando peligroso. Una respuesta sólida define un sobre (envelope) versionado, tipos de mensajes permitidos, restricciones de campos, límites de tamaño y transiciones de estado antes de acotar event.data a partir de unknown.
La tercera señal es el manejo de los tiempos asíncronos. Un mensaje de inicialización enviado antes de que el iframe registre su listener desaparece silenciosamente. Un flujo confiable registra primero el listener del padre, carga el iframe, permite que el hijo anuncie ready y envía init solo después de la validación. Los ID de mensaje, un ID de canal, tiempos de espera (timeouts) y transiciones de estado idempotentes gestionan los reintentos, duplicados y mensajes tardíos.
Por último, el entrevistador busca un límite de confianza del lado del servidor. Un mensaje complete procedente de un iframe verificado sigue sin ser prueba de pago. El frontend debe usar una referencia de resultado sin detalles confidenciales para consultar al servidor anfitrión. CSP, frame-src, frame-ancestors y permisos mínimos en sandbox reducen la exposición, pero no reemplazan la validación de identidad y datos a nivel de mensaje.
Preguntas para clarificar primero
- ¿Son fijos ambos orígenes? Dos orígenes fijos permiten una comparación exacta. Si el anfitrión admite dominios personalizados de clientes, la página de pago debe obtener de su servidor el origen padre permitido para esta sesión. No debe confiar en cualquier
event.originque aparezca primero. - ¿Qué datos confidenciales cruzan el canal? El tema y la altura son de bajo riesgo. Las credenciales de pago, datos personales o tokens de portador reutilizables no deben transmitirse a través de una interfaz de tipo difusión (broadcast). Si una capacidad debe cruzar, utiliza una referencia de corta duración, vinculada a la audiencia, revocable y de privilegio mínimo.
- ¿Quién puede incrustar la página de pago? La página de pago debe restringir a los integradores mediante
frame-ancestors. Si hay muchos clientes legítimos, genera una política precisa del lado del servidor o utiliza un punto de entrada de incrustación controlado en lugar de permitir cualquier sitio. - ¿Cuántos iframes del mismo origen pueden existir en una página? Con uno solo, el origen más el
contentWindowesperado localiza al par. Múltiples instancias requieren referencias de ventana independientes y un estado de protocolo vinculado al canal; un manejador de difusión basado únicamente en el origen es insuficiente. - ¿Qué mensajes desencadenan acciones sensibles?
resizepuede afectar directamente al diseño visual.complete, reembolsos, envío de pedidos o cambios de cuenta requieren autorización del servidor y consulta del estado autoritativo. Un mensaje transporta una señal, no autoridad comercial. - ¿Qué se puede reintentar tras un fallo?
readyyinitse pueden reintentar de forma idempotente. Un mensaje reenviado no debe ejecutar un envío de pago dos veces. Define quién gestiona el ID de mensaje, el estado terminal y la clave de idempotencia del servidor.
Esquema de respuesta en 30 segundos
«Trataría a postMessage como una API asíncrona a través de un límite de confianza. El padre registra su listener y almacena el contentWindow del iframe antes de que el hijo envíe ready. Para cada mensaje, el padre verifica el origen exacto, la fuente esperada y el esquema en tiempo de ejecución, respondiendo luego con init usando un targetOrigin exacto. El protocolo cuenta con versión, ID de canal e ID de mensaje, mientras que una máquina de estados rechaza mensajes previos al handshake, duplicados, reordenamientos y modificaciones tras un estado terminal. complete solo provoca que el anfitrión consulte el estado de pago autoritativo a través de su servidor. Probaría orígenes maliciosos, el iframe incorrecto del mismo origen, datos mal formados, repetición y condiciones de carrera de navegación, reduciendo la exposición con CSP y permisos mínimos de iframe».
Análisis detallado paso a paso
Paso uno: mapear el límite de confianza en ambas direcciones
Antes de que el padre envíe información, debe responder a dos preguntas: si su referencia Window pertenece al iframe previsto y si el documento de destino todavía tiene el origen esperado al ejecutarse la llamada. targetOrigin resuelve la segunda pregunta. Si el destino navegó a otra parte, el navegador descarta el mensaje. Usar "*" elimina esa restricción sobre el destinatario.
La recepción también plantea dos preguntas independientes: qué origen envió el mensaje y qué ventana dentro de ese origen lo envió. Cualquier página que obtenga una referencia a la ventana actual puede intentar enviar un mensaje. Un iframe diferente en el mismo origen de confianza también puede emitir un evento con el mismo nombre. Por lo tanto, el receptor verifica al menos:
- Que
event.originsea exactamente igual al esquema completo, host y puerto; - Que
event.sourcesea la misma referencia que elcontentWindowalmacenado del iframe; - Que
event.datacoincida con una estructura de mensaje permitida por el estado actual del protocolo.
No uses coincidencia por sufijo, fragmentos de expresiones regulares ni indexOf para validar orígenes. https://pay.example.net.attacker.test puede superar una comprobación de subcadena poco estricta. Tampoco agregues event.origin dinámicamente a una lista de permitidos; eso convierte el primer sondeo del atacante en un registro válido.
Paso dos: definir un protocolo de mensajes pequeño y explícito
Representa los mensajes como una unión discriminada, no como objetos arbitrarios o comandos en texto plano. Un protocolo compacto puede contener:
| Campo | Propósito | Validación |
|---|---|---|
v | Versión del protocolo | Aceptar solo versiones enteras compatibles |
type | Tipo de mensaje | Enum fijo; nunca invocar un nombre de función dinámico |
messageId | Desduplicación y correlación de auditoría | No vacío, con límite de longitud, único dentro de la ventana actual |
channelId | Vincular un handshake exitoso | Creado por el padre tras ready; todo mensaje posterior debe coincidir |
| Campos de negocio | Datos mínimos requeridos por ese mensaje | Validar cada tipo, rango y longitud |
ready no tiene channelId antes de que exista un canal. El padre lo recibe a través del origen y la fuente ya verificados, crea un channelId aleatorio y envía init. Los mensajes posteriores resize, cancel y complete deben repetir ese ID de canal. El canal aísla una sesión antigua de mensajes tardíos en la misma ventana. No reemplaza el origen, la fuente ni la autorización del servidor.
Inicia el análisis sintáctico del receptor desde unknown. Este es un ejemplo simplificado en TypeScript para el padre. parseWidgetMessage representa una validación estricta en tiempo de ejecución, no una aserción de tipos:
const WIDGET_ORIGIN = "https://pay.example.net"
const frame = document.querySelector<HTMLIFrameElement>("#payment-widget")
if (!frame?.contentWindow) {
throw new Error("Payment iframe is unavailable")
}
const widgetWindow = frame.contentWindow
let channelId: string | null = null
const seenMessageIds = new Set<string>()
let state: "loading" | "active" | "completed" | "closed" = "loading"
window.addEventListener("message", onWidgetMessage)
frame.src = "https://pay.example.net/embed"
function onWidgetMessage(event: MessageEvent<unknown>) {
if (event.origin !== WIDGET_ORIGIN) return
if (event.source !== widgetWindow) return
const message = parseWidgetMessage(event.data)
if (!message || seenMessageIds.has(message.messageId)) return
seenMessageIds.add(message.messageId)
if (message.type === "ready") {
if (state !== "loading") return
channelId = crypto.randomUUID()
widgetWindow.postMessage(
{
v: 1,
type: "init",
messageId: crypto.randomUUID(),
channelId,
locale: "zh-CN",
theme: "system",
checkoutSessionRef: "short-lived-opaque-reference",
},
WIDGET_ORIGIN,
)
state = "active"
return
}
if (state !== "active" || channelId === null || message.channelId !== channelId) return
if (message.type === "resize") {
frame.style.height = `${Math.min(Math.max(message.height, 240), 900)}px`
}
if (message.type === "complete") {
state = "completed"
void refreshAuthoritativePaymentStatus(message.resultRef)
}
}El parser real también rechaza tipos peligrosos no reconocidos, cadenas excesivamente largas, números no finitos y combinaciones de estados no permitidas. No uses as WidgetMessage para omitir las validaciones en tiempo de ejecución. El algoritmo de clonación estructurada puede transportar objetos; no garantiza que un objeto cumpla con el protocolo de la aplicación.
El hijo aplica las mismas reglas en la dirección opuesta. Solo acepta un origen anfitrión configurado de antemano o vinculado por la sesión del servidor, requiere event.source === window.parent, almacena el ID de canal solo después de validar el esquema de init y siempre responde con el origen exacto del anfitrión. Ninguno de los dos extremos debe omitir la validación simplemente por esperar un único interlocutor.
Paso tres: eliminar la ambigüedad temporal con un handshake y una máquina de estados
El estándar HTML advierte que un documento hijo recién navegado puede no haber registrado aún su listener de mensajes. Un mensaje del padre enviado en ese momento no espera en una cola a que el hijo esté listo. El padre instala su listener antes de definir o confirmar la URL del iframe. El hijo instala su listener y anuncia ready. El padre envía la inicialización sensible solo después de que ready supere las validaciones de origen, fuente y esquema.
La máquina de estados hace explícitas las acciones permitidas:
| Estado actual | Mensajes aceptados | Estado posterior |
|---|---|---|
loading | ready | active |
active | resize, cancel, complete | Mismo, closed o completed |
completed | Ningún mensaje de negocio; confirmación opcional de solo lectura | Permanece en completed |
closed | Ninguno | Permanece en closed |
El padre puede definir un tiempo de espera (timeout) para el handshake. Al agotarse el tiempo, muestra un error reintentable y destruye el iframe anterior. Recrear el iframe requiere un nuevo ID de canal, un conjunto de desduplicación vacío, una referencia de ventana esperada actualizada y la eliminación del listener anterior. Reutilizar el canal anterior permitiría que un mensaje tardío de la página previa entre en la nueva sesión.
Paso cuatro: separar mensajes duplicados, acciones de negocio duplicadas y hechos del servidor
La entrega de mensajes y la ejecución de negocio requieren dos capas de idempotencia. El frontend utiliza messageId para ignorar un mensaje duplicado de UI y una máquina de estados para rechazar cambios después de un estado terminal. El servidor sigue necesitando una clave de idempotencia de pedido o de intento de pago para evitar un cobro duplicado, ya que recargas de página, reintentos de red y múltiples pestañas pueden eludir el conjunto de desduplicación del frontend.
complete significa únicamente que una ventana de confianza afirma haber terminado el flujo. El padre envía resultRef a su propio servidor. El servidor verifica el usuario actual, la titularidad del pedido, el monto y el estado autoritativo del proveedor de pagos antes de devolver un resultado mostrables. Incluso si un XSS toma el control de los scripts en el origen del iframe de pago, un complete falsificado no puede marcar directamente el pedido como pagado.
Los registros de mensajes contienen únicamente la versión del protocolo, el tipo, un hash del canal, el resultado y el motivo de rechazo. No contienen credenciales de pago, datos personales completos ni tokens reutilizables. Limita el tamaño del conjunto de desduplicación o destrúyelo junto con el canal para que un ataque o una página de larga duración no incrementen la memoria de forma indefinida.
Paso cinco: manejar la navegación, múltiples instancias y orígenes compartidos
event.origin representa el origen del emisor en el momento en que invocó postMessage. No garantiza que la ventana emisora tenga el mismo origen actual o futuro. Revalida cada mensaje en lugar de confiar indefinidamente en la ventana tras un único handshake. Si el iframe de destino navega hacia el origen de un atacante, un targetOrigin exacto detiene los mensajes posteriores del padre, y el receptor del padre rechaza los mensajes entrantes porque el origen difiere.
Si el iframe navega a una aplicación diferente dentro del mismo origen de confianza, la verificación de origen seguirá pasando. La fuente, el ID de canal, la versión del protocolo, los tipos de mensaje y la confirmación del servidor pueden limitar sesiones antiguas, enrutamientos erróneos e impacto en el negocio, pero no pueden contrarrestar código malicioso alojado en ese origen. El navegador trata a todo el origen como un único principal de seguridad. Las aplicaciones con diferentes niveles de confianza deben usar distintos subdominios; las rutas URL y los campos de mensajes no pueden aislarlas.
Si hay varios iframes de pago en una misma página, mantén un registro independiente de contentWindow, estado, canal y manejadores para cada elemento. Un listener global puede enrutar a las instancias, pero primero debe mapear la referencia event.source a una instancia. No debe comenzar confiando en un ID de instancia proporcionado dentro del mensaje.
Paso seis: restringir las capacidades del iframe y las relaciones de incrustación
La directiva frame-src del CSP del anfitrión permite únicamente el origen de pago aprobado. La directiva frame-ancestors del sitio de pago autoriza únicamente a los anfitriones aprobados a incrustarlo. El atributo sandbox del iframe habilita únicamente las capacidades que el flujo de pago necesita en realidad, como scripts, formularios o popups específicos. Cada permiso añadido debe corresponder a un requisito comprobable.
Estos controles determinan quién puede cargar a quién y qué capacidades de navegador tiene el documento incrustado. No autentican un mensaje ni hacen que "*" sea seguro. Si cualquier sitio pudiera incrustar un widget de pago autenticado, un atacante podría cargarlo en una página controlada por él y enviarle comandos activamente. frame-ancestors reduce directamente esa superficie de ataque.
Si los extremos necesitan un flujo independiente de alta frecuencia tras el handshake, el primer postMessage autenticado puede transferir un MessagePort. La comunicación posterior utiliza entonces un puerto dedicado, reduciendo la interferencia entre listeners globales de message. La entrega inicial del puerto aún requiere verificaciones de origen, fuente y esquema, y la posesión de un puerto no otorga ninguna autoridad comercial del lado del servidor.
Paso siete: demostrar las rutas de rechazo con pruebas negativas
Una prueba positiva solo demuestra que la página legítima funciona. La verificación de seguridad construye entradas que deben ser rechazadas:
| Prueba | Resultado esperado |
|---|---|
Un origen atacante envía un complete con estructura correcta | La comprobación de origen lo rechaza; no se ejecuta consulta de pago |
| Otro iframe en el origen de confianza envía un mensaje | La comprobación de fuente lo rechaza |
| El origen y la fuente correctos envían un tipo desconocido o un campo sobredimensionado | La validación de esquema lo rechaza |
Se repite el mismo messageId | Se procesa una sola vez |
| Un canal antiguo envía un mensaje tardío tras recrear el iframe | La comprobación de canal lo rechaza |
| El iframe navega a otro origen antes de que el padre envíe | El targetOrigin exacto hace que el mensaje se descarte |
complete incluye un resultado inexistente o perteneciente a otro usuario | La autorización del servidor y la consulta autoritativa lo rechazan |
ready llega tarde, duplicado o nunca llega | El manejo idempotente o el timeout entra en un estado de fallo reintentable |
La revisión de código también inspecciona cada llamada a postMessage en busca de "*", cada listener de message, comparaciones difusas de origen, event.data sin comprobar y rutas que escriban datos del mensaje en innerHTML. Las pruebas de integración en el navegador cubren el desmontaje de listeners, recreación de iframes, múltiples instancias y cambios de ruta.
Ejemplo de respuesta de alto nivel
«Dividiría el canal en un límite de envío y un límite de recepción. El padre envía únicamente al contentWindow del iframe que tiene almacenado y utiliza https://pay.example.net como el targetOrigin exacto. Para cada mensaje entrante, el listener compara el event.origin completo y esa misma referencia de contentWindow antes de la validación del esquema en tiempo de ejecución. El origen por sí solo es insuficiente porque la página puede contener varios iframes del mismo origen. La fuente por sí sola es insuficiente porque esa ventana podría haber navegado.
Definiría un conjunto reducido y versionado de mensajes: ready, init, resize, cancel y complete. El padre registra su listener antes de cargar el iframe. El hijo instala su listener y envía ready; solo tras su validación, el padre crea un ID de canal y envía la inicialización. Cada mensaje posterior transporta el mismo ID de canal y un ID de mensaje único. Una máquina de estados y un conjunto de desduplicación rechazan mensajes previos al handshake, duplicados, reordenamientos y regresiones de estado tras la finalización. Un timeout en el handshake destruye el iframe antiguo, mientras que el reintento utiliza una nueva referencia de ventana y canal.
Un mensaje de finalización nunca modifica el pedido directamente. Solo transporta una referencia de resultado opaca. El servidor anfitrión valida al usuario y el pedido, y consulta al sistema de pagos para obtener el estado autoritativo. Por lo tanto, un error o incluso un ataque XSS en el origen de pago de confianza no pueden falsificar un pago mediante un simple mensaje.
También restringiría el componente con frame-src en el anfitrión, frame-ancestors en la página de pago y permisos de sandbox mínimos. Las pruebas cubrirían el flujo legítimo además de orígenes atacantes, el iframe incorrecto del mismo origen, canales antiguos, repeticiones, navegación del iframe y timeouts de disponibilidad. Toda ruta de rechazo debe evitar acciones comerciales sensibles».
Errores comunes
- Enviar con
"*"→ la ventana de destino puede haber navegado a una página atacante y recibir igualmente datos sensibles → usa siempre eltargetOriginexacto esperado. - Comprobar solo
event.data.typeal recibir → cualquier página con una referencia a la ventana puede falsificar el comando nombrado → verifica el origen exacto y la fuente esperada antes de procesar los datos. - Aceptar un origen mediante subcadenas o fragmentos de sufijo de dominio → un dominio atacante puede contener el texto de confianza → compara el origen completo y normalizado proporcionado por el navegador.
- Procesar tras una aserción de tipos en TypeScript → los tipos no existen en tiempo de ejecución, por lo que datos mal formados ingresan a la lógica de negocio → comienza con
unknowny realiza una validación estricta de esquema, rangos y estado. - Enviar
initinmediatamente tras la carga de la página → el listener del iframe podría no estar registrado y el mensaje se pierde → permite que el hijo anuncieready, valídalo y luego inicializa con un timeout. - Tratar
completecomo prueba de pago → un mensaje de frontend no es un hecho comercial autoritativo, e incluso un origen de confianza puede verse comprometido → autoriza en el servidor anfitrión y consulta el estado de pago autoritativo. - Dejar de verificar el origen tras el handshake → una ventana puede navegar durante la sesión y trasladar la confianza anterior entre documentos → valida cada mensaje y aísla una sesión recreada mediante un nuevo canal.
- Asumir que CORS o sandbox ya protegen los mensajes → restringen lecturas de red y capacidades del documento, no la identidad ni los datos del mensaje → mantén las comprobaciones a nivel de mensaje y usa las políticas del navegador como defensa en profundidad.
Preguntas de seguimiento y respuestas
Pregunta de seguimiento uno: El widget debe admitir miles de dominios personalizados de clientes. ¿Cómo sabe el hijo cuál es el origen del padre?
No confíes automáticamente en el event.origin del primer mensaje. El anfitrión crea primero una sesión de incrustación con el servidor. El servidor verifica la titularidad del dominio del cliente y vincula el origen padre permitido a una sesión de corta duración. La página de pago obtiene el origen esperado a partir de esa sesión y utiliza únicamente ese valor para enviar y recibir. La expiración de la sesión, un cambio de dominio o la recreación de la ventana requieren reautorización. Un dominio personalizado que no pueda ser verificado no debe recibir capacidades de incrustación sensibles.
Pregunta de seguimiento dos: Varios iframes del mismo origen requieren mensajería. ¿Puede channelId enrutarlos por sí solo?
El manejador no puede comenzar confiando en un canal declarado por el mensaje. Un listener global primero utiliza event.source para encontrar la instancia en un registro de referencias Window conocidas, y luego comprueba el origen, el estado y el ID de canal de esa instancia. El canal rechaza mensajes de sesiones antiguas y desordenados; la referencia de la ventana identifica el contexto de navegación. Cumplen propósitos distintos.
Pregunta de seguimiento tres: El iframe visita una página de inicio de sesión y luego una página de checkout en el mismo origen de pago. ¿Cómo funciona el handshake?
El inicio de sesión y el checkout en un mismo origen constituyen el mismo principal de seguridad para el navegador; el padre no puede conocer la ruta a partir de event.origin. Si gozan del mismo nivel de confianza, cada load del iframe hace que el padre descarte el canal anterior y regrese al estado de carga. Realiza el handshake nuevamente solo después de que el nuevo documento envíe un ready que coincida con el contrato de la sesión, lo que rechaza mensajes tardíos del documento anterior. Si el inicio de sesión no debe ver datos de inicialización o tiene un menor nivel de confianza, muévelo a otro origen y proporciona al checkout un punto de entrada de incrustación dedicado. Un ID de canal no puede solucionar la falta de aislamiento de mismo origen.
Pregunta de seguimiento cuatro: ¿Se sigue verificando el origen tras cambiar a MessageChannel?
Los mensajes de puerto no incluyen el mismo campo de origen que los mensajes de ventana, por lo que la seguridad depende de quién recibió el puerto inicialmente. El primer postMessage que lo transfiere debe verificar el origen exacto, la fuente y el estado del handshake. El puerto pertenece únicamente a la sesión actual y se destruye al cerrarse. Un puerto dedicado reduce los fallos de enrutamiento, pero no reemplaza la autenticación inicial, los esquemas de mensajes ni la autorización del servidor.
Pregunta de seguimiento cinco: El iframe controla los cambios automáticos de altura. ¿Cómo evitas que estire la página a un tamaño extremo?
Incluso un resize con identidad verificada es una entrada de negocio no confiable. El protocolo acepta únicamente enteros finitos; el padre los acota a alturas mínimas y máximas definidas por el producto y aplica límites de frecuencia (rate-limiting) a las actualizaciones. Registra los motivos de rechazo para mensajes fuera de rango o excesivos. Si el contenido realmente requiere más espacio, utiliza desplazamiento interno (scrolling) o define un nuevo límite de producto en lugar de conceder al hijo control arbitrario sobre el CSS.