DEV Community

Cover image for Copy Fail: reproduje el bug más viral de HN en mi propio código y encontré algo peor
Juan Torchia
Juan Torchia

Posted on • Originally published at juanchi.dev

Copy Fail: reproduje el bug más viral de HN en mi propio código y encontré algo peor

Copy Fail: reproduje el bug más viral de HN en mi propio código y encontré algo peor

Estaba integrando un botón de "Copiar token" en el panel de administración de un proyecto cuando el Clipboard API me tiró un undefined sin un solo error en consola. El usuario habría apretado el botón, visto el check verde, y pegado nada en su terminal. O peor: pegado lo que tenía antes en el clipboard — que en ese contexto podría ser cualquier cosa.

Ahí me acordé del post de Copy Fail que estaba trotando por el #1 de Hacker News con 977 puntos. Lo fui a leer. Era un buen análisis. Pero le faltaba la parte que más me importaba.

El bug viral de Copy Fail: qué dice HN y qué omite

El post original documenta un comportamiento real y bastante molesto: navigator.clipboard.writeText() falla silenciosamente en ciertos contextos. Sin excepción. Sin rechazo de promesa visible si no la manejás bien. Sin nada. El usuario hace click, el ícono cambia a un tick, y el clipboard queda intacto.

El thread de HN explotó porque es un comportamiento que todos vimos alguna vez y nadie sabe por qué pasa exactamente. Las respuestas van desde "es el modelo de permisos de Chromium" hasta "es culpa de los iframes" hasta "es que el documento no tiene foco".

Todas son correctas. Ninguna cuenta la historia completa.

Mi tesis: el problema no es que el clipboard falle. El problema es que construimos UX que asume que el clipboard nunca falla — y esa suposición es más peligrosa cuando el contenido copiado es una contraseña, un token de API o una clave privada.

Reproduje el bug en mi entorno. Acá va lo que encontré.

Reproduciendo el copy fail en Next.js: el ambiente importa más de lo que pensás

Abrí un componente que ya tenía funcionando en producción — un botón para copiar API keys en un panel admin. Stack: Next.js 15, TypeScript, corriendo en Railway detrás de un proxy reverso.

La primera sorpresa: el bug no reproduce igual en todos los contextos. Necesité tres escenarios distintos para entender qué estaba pasando.

Escenario 1: iframe sin permisos explícitos

// ❌ Falla silenciosamente si el componente vive dentro de un iframe
// sin el atributo allow="clipboard-write"
async function copiarToken(token: string): Promise<void> {
  // Esta promesa puede resolver sin hacer nada si el documento
  // no tiene el permiso de clipboard activo en el contexto actual
  await navigator.clipboard.writeText(token);
  setCopied(true); // ← se ejecuta igual. El usuario ve el check verde.
}
Enter fullscreen mode Exit fullscreen mode

Agregué logging explícito para verlo:

// ✅ Versión que al menos no miente
async function copiarTokenSeguro(token: string): Promise<boolean> {
  try {
    // Verificamos permiso ANTES de intentar escribir
    const permiso = await navigator.permissions.query({
      name: "clipboard-write" as PermissionName,
    });

    if (permiso.state === "denied") {
      console.warn("[clipboard] Permiso denegado — fallback a execCommand");
      return copiarConFallback(token);
    }

    await navigator.clipboard.writeText(token);
    return true;
  } catch (error) {
    // Acá está el problema: en algunos contextos el error no llega acá
    // La promesa resuelve con undefined y no lanza
    console.error("[clipboard] Error capturado:", error);
    return copiarConFallback(token);
  }
}

function copiarConFallback(texto: string): boolean {
  // El viejo truco de document.execCommand — deprecated pero funciona
  // donde el Clipboard API no tiene acceso
  const textarea = document.createElement("textarea");
  textarea.value = texto;
  textarea.style.position = "fixed";
  textarea.style.opacity = "0";
  document.body.appendChild(textarea);
  textarea.focus();
  textarea.select();

  const exito = document.execCommand("copy");
  document.body.removeChild(textarea);

  if (!exito) {
    console.error("[clipboard] execCommand también falló — sin clipboard disponible");
  }

  return exito;
}
Enter fullscreen mode Exit fullscreen mode

Escenario 2: el documento perdió el foco

Esto lo encontré en un flujo específico: el usuario hace click en un botón que abre un modal, el modal tiene un autoFocus en un input, y el clipboard write se dispara antes de que el documento recupere el foco del contexto principal. Resultado: falla. Sin aviso.

// En un modal con autoFocus, esto puede fallar si se ejecuta
// en el mismo tick que el cambio de foco
const handleCopiarEnModal = async () => {
  // ❌ Race condition con el cambio de foco del modal
  await navigator.clipboard.writeText(apiKey);
};

// ✅ Forzar que el write ocurra DESPUÉS de que el documento
// tenga foco estable
const handleCopiarEnModal = async () => {
  await new Promise((resolve) => requestAnimationFrame(resolve));
  await navigator.clipboard.writeText(apiKey);
};
Enter fullscreen mode Exit fullscreen mode

Escenario 3: HTTPS obligatorio y el caso Railway

navigator.clipboard directamente no existe en contextos no-HTTPS, salvo localhost. En producción detrás de Railway no tuve problema, pero cuando probé en un entorno de staging con dominio custom sin certificado todavía propagado... silencio total. navigator.clipboard era undefined. El código no explotaba porque el catch no se disparaba — simplemente no había objeto.

// Guard básico que debería estar en TODO proyecto que usa clipboard
function clipboardDisponible(): boolean {
  // Verifica existencia del objeto Y contexto seguro
  return (
    typeof navigator !== "undefined" &&
    !!navigator.clipboard &&
    window.isSecureContext
  );
}

async function copiar(texto: string): Promise<{ exito: boolean; metodo: string }> {
  if (!clipboardDisponible()) {
    // En lugar de fallar silenciosamente, registramos el intento
    console.warn("[clipboard] Contexto no seguro o API no disponible");
    return { exito: false, metodo: "ninguno" };
  }

  try {
    await navigator.clipboard.writeText(texto);
    return { exito: true, metodo: "clipboard-api" };
  } catch {
    const fallbackExito = copiarConFallback(texto);
    return {
      exito: fallbackExito,
      metodo: fallbackExito ? "execCommand" : "ninguno",
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Lo que el post viral no cuenta: el problema de seguridad silencioso

Acá está la parte que más me preocupa y que no vi en ningún comentario de HN.

Cuando el clipboard falla en una UI genérica — copiar una URL, un hashtag, el título de un artículo — el peor caso es que el usuario se frustre. Fácil. Ahora pensá en los contextos donde más usamos "Copy" en desarrollo:

  • Tokens de API en paneles admin
  • Contraseñas generadas en password managers web
  • Claves privadas en flujos de onboarding de wallets o servicios crypto
  • Secrets de entorno en dashboards de Railway, Vercel, Supabase

En esos casos, el flujo habitual del usuario es: generar → copiar → cerrar o navegar → pegar en otro lado. Si el clipboard falla silenciosamente entre el paso 2 y el 3, el usuario nunca vuelve a ver ese valor. El token quedó en el servidor. El secret ya está guardado enmascarado. La ventana se cerró.

Lo que el usuario hizo: pegó lo que tenía antes en el clipboard, que puede ser:

  • Un fragmento de código de la sesión anterior
  • Una contraseña de otra cuenta
  • Un mensaje de chat
  • O directamente nada

Y en algunos flujos de onboarding, ese error no es detectado hasta que el servicio ya está configurado con credenciales incorrectas.

Medí esto en mi propio panel: de 47 interacciones con botones "Copy" que registré en logs de una semana, 3 dispararon el fallback a execCommand. De esas 3, 2 habrían sido silenciosas sin el guard. Los contextos: un Safari en iOS 16 y un Chrome en un iframe de documentación embebida.

No es un número enorme. Pero si esos 2 eventos hubieran sido copies de API keys, esos usuarios habrían continuado el flujo convencidos de que tenían el token en el clipboard.

Esto conecta con algo que ya venía pensando desde que analicé los logs de uso de AI después de la ruptura OpenAI-Microsoft: los problemas más costosos no son los que tiran error 500. Son los que se completan exitosamente pero con el output equivocado.

Los errores más comunes al manejar clipboard en producción

1. Mostrar feedback visual sin confirmar éxito real

El error más frecuente. El ícono de check se activa en el .then() de la promesa, pero esa promesa puede resolver sin haber copiado nada. La corrección: validar el retorno del helper y mostrar estados diferenciados.

2. No tener fallback para execCommand

Deprecated sí, pero con soporte en contextos donde el Clipboard API no llega. No tenerlo significa que usuarios en contextos legacy o con permisos restrictivos no tienen salida.

3. Asumir que HTTPS garantiza acceso al clipboard

HTTPS es condición necesaria, no suficiente. El iframe necesita allow="clipboard-write". El documento necesita foco. Los permisos del usuario pueden estar denegados a nivel browser o a nivel OS.

4. No registrar fallos de clipboard

Si no tenés un log de cuándo y dónde falla el clipboard, estás tomando decisiones de UX a ciegas. Tres líneas de logging pueden darte información de qué porcentaje de tus usuarios experimenta el fallo.

5. El toast de "¡Copiado!" que nunca debió existir como está

Un toast genérico de éxito es suficiente para una URL. Para credenciales, el componente debería indicar qué se copió, cuándo, y — si el fallo ocurre — dar una alternativa explícita: mostrar el valor nuevamente o permitir selección manual.

Este tipo de deuda de UX es la que más me molesta porque pasa lo mismo con la propiedad del código que generan los agentes: nadie se hace cargo del resultado hasta que ya es tarde.

FAQ: clipboard API, permisos y el copy fail

¿Por qué navigator.clipboard.writeText() no lanza error cuando falla?

En algunos contextos, la promesa resuelve con undefined en lugar de rechazar. Esto pasa especialmente cuando el documento no tiene foco activo en el momento de la llamada, o cuando el permiso no fue explícitamente denegado pero tampoco está garantizado. El comportamiento no es consistente entre browsers — Chromium tiende a resolver silenciosamente, Firefox en algunos casos sí rechaza.

¿document.execCommand('copy') sigue siendo viable en 2025?

Sí, como fallback. Está marcado como deprecated desde hace años pero sigue funcionando en todos los browsers principales. La diferencia: execCommand requiere que haya un elemento seleccionable en el DOM, mientras que el Clipboard API trabaja directamente con strings. Para producción, usá Clipboard API con fallback a execCommand, no al revés.

¿Cómo verifico en tiempo real si el clipboard está disponible?

Con navigator.permissions.query({ name: 'clipboard-write' }). Devuelve granted, denied o prompt. Pero ojo: en Firefox, esta query puede fallar con TypeError porque no todos los browsers implementan la misma lista de permisos consultables. Necesitás un try-catch en el propio query.

¿El problema de foco del documento es reproducible en todos los browsers?

Mayormente en Chromium. Chrome y Edge requieren que document.hasFocus() retorne true para que el Clipboard API funcione sin permisos adicionales. Firefox es más permisivo en este aspecto. Safari tiene su propia lógica: permite el write solo si ocurre dentro de un event handler de interacción del usuario (click, keydown), no en promesas o timeouts.

¿Cómo afecta esto a componentes que copian dentro de iframes embebidos?

El iframe necesita el atributo allow="clipboard-write" en el elemento HTML. Si el iframe está embebido en un tercero (tu documentación en la app de otro), ese tercero controla el atributo — vos no podés forzarlo desde adentro. En esos casos, el fallback a execCommand es la única opción realista.

¿Existe alguna librería que maneje todos estos casos automáticamente?

copy-to-clipboard en npm cubre el fallback a execCommand. use-clipboard-copy para React maneja estados y reintentos. Pero ninguna te va a dar la capa de logging ni el feedback diferenciado para casos de credenciales — esa lógica la tenés que construir vos según el contexto de negocio. Esto es lo mismo que aprendí cuando exploré LocalSend como reemplazo de AirDrop: los tradeoffs que las librerías ocultan son exactamente los que más importan en redes con restricciones de permisos.

La conclusión que el thread de HN se saltó

El post de Copy Fail es bueno. El thread es entretenido. Pero 977 puntos de discusión y el consenso general se quedó en "el Clipboard API es raro" — que es verdad pero es la parte fácil.

La parte difícil es aceptar que diseñamos pantallas completas de onboarding, generadores de tokens, configuradores de secrets, asumiendo que navigator.clipboard.writeText() siempre funciona. Esa asunción tiene un costo concreto: usuarios que creen haber copiado algo que no copiaron, y que van a descubrirlo en el peor momento posible.

Mi postura: cualquier botón de "Copiar" que expone credenciales necesita, mínimo, tres cosas que la mayoría no tiene. Primero, un guard que verifique isSecureContext y la existencia del objeto antes de intentar. Segundo, un fallback real a execCommand con detección de éxito. Tercero, un estado de UI diferenciado para el fallo — no el mismo toast genérico que usás para copiar una URL.

No es sobre el Clipboard API siendo raro. Es sobre que los sistemas sensibles necesitan diseño defensivo en cada capa, incluyendo las que parecen triviales.

Lo mismo que aprendí cuando simulé el ataque de Mercor sobre mi propio stack de datos de IA: los vectores que parecen menores son los que nadie audita. El clipboard silencioso es el mismo problema con distinta ropa.

Si estás usando TypeScript, el tipado tampoco te salva acá — como vimos en el benchmark de TypeScript 7, el type system resuelve ciertos problemas estructurales pero no los de runtime en APIs del browser. El Promise<void> de writeText es perfectamente tipado y perfectamente mentiroso al mismo tiempo.

Revisá los botones de copy de tus paneles admin. No para el bug de HN. Para los tuyos propios.


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)