Buenas prácticas de ofuscación de JavaScript

Cómo ofuscar por defecto al enviar tu JavaScript, marcar las regiones que importan con directivas, y rotar tu build para que los atacantes no puedan adelantarse.

La ofuscación debería ser el valor por defecto al enviar apps web

Todo lo que hay en tu JavaScript enviado es legible. Secretos, lógica de negocio, el algoritmo del que dependes: cualquiera puede abrir el bundle, y un LLM de última generación lo explicará línea por línea si se lo pides. El escáner de seguridad muestra exactamente qué sale de un bundle de producción real. Ejecútalo sobre el tuyo para ver qué estás regalando ya.

Ofuscar ese bundle te da propiedades concretas y verificables:

  • Un parche escrito contra tu app deja de sobrevivir a tus releases. Un userscript, un script de Tampermonkey o una extensión que personaliza o automatiza tu app tiene que anclarse en algo estable: un nombre de función, una cadena literal, una forma reconocible. Renombrar y codificar cadenas elimina esos anclajes, y una semilla nueva reconfigura la salida en cada build. El autor escribe el parche contra un release; ya no coincide con el siguiente, y los usuarios a los que se lo enviaron se quedan con un script roto hasta que se reescribe.
  • Un buscar-y-reemplazar contra tu código no tiene nada que coincidir. La sustitución simple de texto, la forma más barata de automatizar o alterar una app web, funciona sobre isSubscribed y sobre "Upgrade to continue". Ninguna de las dos cadenas está en el archivo que envías.
  • Tus constantes dejan de ser texto legible. Los literales se codifican, así que un grep para una clave, un endpoint, una tabla de precios o el texto alrededor de una puerta de pago no devuelve nada, y tampoco los escáneres y scrapers que leen bundles a escala.
  • La salida no es tu programa. AfterPack reescribe la lógica en formas equivalentes pero que destruyen información. Un lector que trabaja el artefacto hasta el final recupera un programa que funciona, no el que tú escribiste, y las decisiones algorítmicas que hacían el tuyo valioso de copiar desaparecen.
  • El coste se repite. No hay estructura invariante entre builds que atacar, así que un desofuscador o un parche escrito contra el release anterior no se traslada a este, y el trabajo que alguien ya hizo sobre él no transfiere.

Nada de esto es una garantía, y no sustituye a la autoridad del lado del servidor. Cualquier código de cliente puede revertirse en algún grado, porque el runtime tiene que ejecutarlo. Lo que cambia es el precio y el ritmo: quien quiera leer o modificar tu app vuelve a pagarlo en cada release que envías. El resto de esta página trata de dónde gastar esa protección para que cuente.

Rota tu build, mantente por delante de los atacantes

Un único release ofuscado es un objetivo fijo al que un rompedor de captchas, una extensión de trampas o un scraper pueden adaptarse, con suficiente esfuerzo. Una semilla nueva en cada build es el valor por defecto, así que cada release ya es estructuralmente distinto del anterior y quien se adaptó a uno empieza de cero en el siguiente.

Si publicas releases con poca frecuencia, esa defensa se apaga entre uno y otro. Añade un rebuild programado, independiente de los cambios de código, para que el bundle desplegado siga rotando; Build y CI lo conecta a un pipeline.

Elige un preset, luego marca lo que importa

El preset por defecto es light (objetivo de complejidad 5): codifica los literales de cadena, renombra los identificadores y reescribe la sintaxis, y no añade capas estructurales. Súbelo globalmente para un build de producción que necesite protección real:

$ npx afterpack@latest dist/ --preset=hard

La escala es minify (0), light (5), medium (7), hard (12), extreme (25); consulta Presets. --complexity=<number> sobrescribe el objetivo que trae el preset: --preset=hard --complexity=40 es hard con objetivo 40. Un valor fijado así se aplica a cada token, incluso con light.

Luego marca las regiones donde equivocarse cuesta más, para que el tratamiento más pesado caiga donde compensa su coste en tamaño.

Marcar una región con una directiva

/* @afterpack preset=hard */
function checkLicense(token: string): boolean {
  return verifySignature(token, PUBLIC_KEY);
}
/* @afterpack end */

const apiKey = /* @afterpack preset=extreme */ "YOUR_SECRET";
// only the rest of this line is covered

Directivas es la referencia: la gramática, cada clave que una directiva puede fijar, qué marcar y por qué, y qué le pasa a una directiva en un build que no puede aplicarla. Las sobrescrituras por región tienen efecto en builds Pro.

Las dos transformaciones anti-manipulación

Anti-manipulación no forma parte de preset=hard. Son dos flags de configuración Pro explícitas, ambas desactivadas por defecto, y no se aplican en Free:

  • transforms.comparisonHardening.enabled reescribe una igualdad estricta contra un literal de cadena en tiempo de build (x === "LIT") como una comprobación de resumen de un solo sentido, así que el literal está ausente del artefacto por completo. Úsalo para cualquier comprobación que compare contra una constante que no quieres que se pueda extraer con grep.
  • transforms.selfIntegrity.enabled añade una comprobación de integridad sobre la salida ofuscada. Sin manipular, no tiene efecto sobre el comportamiento. Un archivo manipulado falla al decodificarse correctamente. Necesita código en medium o superior al que adjuntarse, así que un build light solo lo lleva dentro de una región elevada a ese nivel. A partir del motor 0.2.3, un build sin ese código falla con DIAG_SELF_INTEGRITY_INERT en lugar de entregarse sin la comprobación.

Actívalas ambas para todo el programa. Consulta Configuración.

Protege una licencia o prueba ligada a un dominio

AfterPack no tiene ninguna transformación integrada de bloqueo por dominio o de caducidad. Escribe la comprobación tú mismo, y AfterPack protege el JavaScript que escribas. Para una comprobación de licencia por dominio con caducidad de prueba:

/* @afterpack preset=hard */
if (location.hostname !== "customer-domain.example.com") return;
if (Date.now() > TRIAL_EXPIRES_AT) return;
/* @afterpack end */

Con transforms.comparisonHardening.enabled activado, la comparación de location.hostname se convierte en una comprobación de resumen, así que el host permitido nunca aparece en el artefacto. Un grep para el dominio no encuentra nada. preset=hard pone la lógica circundante en el objetivo de complejidad 12 con el suelo de constantes activado, y una semilla nueva la reconfigura en cada release.

Esto eleva el coste y el ritmo de parchear la comprobación; no elimina la comprobación de la superficie de ataque. Un atacante decidido que ejecuta el código todavía puede encontrar y parchear la comparación, pero tiene que rehacer ese trabajo en cada release. Trata esto como fricción para la prueba, y combínalo con una llamada de derecho de acceso del lado del servidor para cualquier comprobación que condicione ingresos reales.

No gastes protección en código de terceros

Los bundles de proveedores, como React, Lodash o un runtime CSS-in-JS, valen poca protección. Ya son públicos, inflan tu bundle, y suelen usar reflexión en tiempo de ejecución que hay que reconocer.

`paths.exclude` es la exclusión a nivel de archivo

paths.exclude salta un archivo que coincide por completo, antes de analizarlo, y lo devuelve byte a byte. Un archivo excluido se reporta como DIAG_PATH_EXCLUDED. Funciona a nivel de archivo: cuando un build de librería concatena tu código y una dependencia en un único archivo de salida, no hay un archivo separado que excluir. Usa identifiers.reserved en su lugar.

Cuatro formas de elegir qué ve AfterPack:

  • Excluye el archivo con paths.exclude, por ejemplo ["**/*.min.js", "dist/vendor/**"]. * y ? coinciden dentro de un segmento de ruta, un segmento ** completo coincide con cero o más segmentos, y la coincidencia distingue mayúsculas de minúsculas.
  • Apunta la CLI al directorio que quieres proteger. Recorre el directorio de build recursivamente y toma cada .js/.mjs/.cjs que encuentra, saltando los node_modules/ anidados (inclúyelos con paths.include) y sus propias copias .backup.<hash>. Un chunk de proveedor fuera de ese directorio nunca se toca.
  • Divide el código de proveedores en su propio chunk en tu bundler, y luego ejecuta AfterPack contra un directorio que lo excluya. Un chunk de proveedor se ofusca sin problemas si prefieres aceptar el coste.
  • Marca regiones como skip donde el código de una dependencia empaquetada está en el mismo archivo que el tuyo y necesita seguir siendo reconocible.

Tus imports siguen resolviéndose.

AfterPack admite por completo los campos y métodos privados de clase de ES2022: #field y #method() se analizan correctamente y se renombran y protegen como todo lo demás.

Preserva lo que debe seguir siendo legible

Parte del código debe mantener sus nombres originales para que el runtime funcione. Los presets de framework cubren los casos comunes. Para el resto, preserva de forma acotada. Nunca desactives el renombrado globalmente, o tirarías la capa de protección más barata en todo tu bundle.

SituaciónQué hacer
Exports públicos que los llamadores importan por nombre/* @afterpack skip */ … /* @afterpack end */ alrededor del export, o fija los nombres con identifiers.reserved
Stack traces que realmente depurasSimboliza desde source maps que mantienes fuera del árbol de despliegue. identifiers.rename=false mantiene cada identificador tal cual en todo el bundle
DI basada en reflexión (Angular, NestJS, Inversify)reflection.allow en la configuración para reconocer un patrón personalizado; un patrón no reconocido es un error de build
Conmutación por instanceof / nombre de claseidentifiers.reserved con los nombres de las clases, o /* @afterpack skip */ alrededor de la declaración
Símbolos de API de terceros empaquetados en el mismo archivo que tu propio código (por ejemplo, un build de librería con Rollup/Vite que inlinea una dependencia)identifiers.reserved, con un nombre plano para todo el build o una entrada { glob, names } para los archivos que coinciden
Un script en el que nada más entraidentifiers.globals.rename: true. Activa el renombrado de la superficie global ambiental que el motor preserva por defecto. Desactivado por defecto, y solo seguro cuando nada externo lee esos nombres

identifiers.reserved preserva solo los nombres que enumeras, dondequiera que aparezcan, comparados contra el nombre a nivel de código fuente antes de cualquier renombrado: exacto, de identificador completo, sensible a mayúsculas. Disponible en Free. Recurre a él cuando no puedas marcar una región entera porque tu código y una dependencia empaquetada comparten un archivo.

Para mantener nombres en un solo lugar, usa identifiers.reserved o /* @afterpack skip */; consulta Qué puedes fijar.

Cada preservación reduce la protección en esas líneas. Aplícala solo donde el runtime lo exija.

Estrategia de semilla

La semilla determina la forma de la salida ofuscada. Una semilla aleatoria por build es el valor por defecto. Mantenlo así en producción: un build que cambia en cada release es la defensa principal.

Fija la semilla en exactamente un lugar: en CI, cuando necesitas una salida reproducible para aciertos de caché o pruebas de snapshot dorado.

$ npx afterpack@latest dist/ --seed=git

--seed=git deriva la semilla de git rev-parse HEAD. Sin repositorio, recurre a una semilla aleatoria nueva e imprime un aviso en vez de fallar el build. Eso mantiene la salida determinista por commit y a la vez sigue rotando en cada release.

La semilla también tiene una variable de entorno, AFTERPACK_seed, con los mismos valores que --seed. Los builds multi-bundle comparten una semilla automáticamente, dentro de un proceso y entre procesos, a través de esta variable. Fíjala tú mismo solo para reproducir un build concreto; un --seed u opción seed explícito sigue ganándole.

Construye producción una vez y promueve los mismos bytes a través de qa → preprod → prod. Los builds de desarrollo no están ofuscados, así que el desarrollo del día a día no se ve afectado.

No envíes una semilla constante a producción. Eso detiene el cambio de forma de la salida entre releases.

Mantén los artefactos fuera del despliegue

AfterPack escribe tres tipos de artefacto local. Cada uno es más sensible que el bundle que describe.

  • El Protection Map (protectionMap.html) incorpora tu código fuente original. Siempre se escribe en un directorio .afterpack/ ignorado por git en la raíz del proyecto, nunca junto al build servido, y un build de producción avisa cuando está activado. Desactívalo con --protectionMap.enabled=false.
  • La copia de seguridad de tus originales. Antes de que la CLI reescriba un archivo en su sitio, copia el original a .afterpack/backup/, junto con un manifiesto de a qué se ofuscó cada archivo. Ese directorio es tu código fuente tal cual, se queda en la máquina de build, y npx afterpack@latest restore devuelve los originales a su lugar. Desactívalo con --build.backup=false. Un plugin de framework no escribe copia por defecto; build.backup fijado a true ahí escribe un .backup.<hash> hermano junto a la salida en su lugar, donde se enviaría con el build.
  • Los source maps encadenan la salida ofuscada directamente de vuelta al código fuente original. El .map hermano está desactivado por defecto en producción, y el comentario //# sourceMappingURL= también está desactivado por defecto en producción.

Los plugins y la CLI mantienen .afterpack/ fuera de git con un .gitignore dentro de él, nunca editan el tuyo, y avisan cuando un artefacto cae bajo una ruta servida. Te corresponde a ti mantener fuera de git un archivo .map o .backup.<hash> que quede junto al build.

Nunca despliegues un source map ni una copia de seguridad

Un source map o una copia de seguridad contiene tu código fuente completo. Nunca despliegues ninguno de los dos.

@afterpack/next también elimina cada .js.map del árbol servido después del build.

Depura trazas de producción sin publicar el map

Un build de producción no escribe ningún .map ni comentario //# sourceMappingURL=, que es el valor por defecto correcto y te deja simbolicando stack traces a mano. Si tu equipo necesita trazas reales de producción, construye el map y mantenlo privado en vez de omitirlo.

Activa el map para el build y mantén el puntero desactivado, así nada en el JavaScript enviado anuncia que existe un map:

{
  "sourceMap": { "enabled": true, "emitUrl": false, "sourcesContent": true }
}

Luego mantén los archivos .map fuera del directorio desplegado: súbelos a tu rastreador de errores o consérvalos como artefacto de CI, y simboliza ahí. Si tu despliegue copia todo el directorio de build, deniega también los archivos en el borde, para que una petición de uno nunca lo devuelva.

location ~ \.map$ {
  return 404;
}

La misma regla tiene un equivalente en todas partes: una regla de CDN o WAF que coincida con *.map, un deny de proxy, o un bucket de almacenamiento que simplemente nunca reciba los archivos. Compruébalo desde fuera de tu red con curl -I https://yoursite.example.com/assets/app.js.map, esperando un 404, y con npx afterpack@latest audit, que reporta un map alcanzable como un hallazgo.

Una regla de denegación es la segunda línea de defensa, no la primera

Una regla en el borde protege archivos que están en el servidor. Cualquier cosa que la sortee, una ruta mal configurada, una URL de bucket directa, un despliegue de vista previa, sirve el map con tu código fuente original dentro. No desplegar los maps es la versión que no se puede configurar mal.

Si enviaste un secreto por accidente

Una clave de API, un token o una contraseña que llegó a tu bundle es un incidente activo. AfterPack cambia lo que un lector encuentra en el archivo enviado. No cambia el hecho de que la credencial salió.

Lo que AfterPack le hace. En el preset por defecto, los literales de cadena se codifican, así que una clave guardada en uno de ellos no es legible en el archivo que despliegas. Los pocos literales que siempre quedan legibles se enumeran en Presets. Buscar en el bundle la clave, o el texto alrededor de ella, no devuelve nada. Los escáneres automáticos de secretos y los scrapers de bundles tampoco encuentran nada, y así es como se descubren la mayoría de las claves filtradas.

Lo que AfterPack no hace. La credencial sigue en el build, y tu código sigue usándola. Cualquiera que ejecute tu aplicación en un navegador que controla puede alcanzar el valor en el momento en que el código lo usa. Codificarlo te compra tiempo para reaccionar. No hace que un secreto del lado del cliente sea seguro.

Qué hacer, en orden:

  1. Rota la clave. Trátala como comprometida desde el momento en que salió el build. Este es el paso que cierra el incidente.
  2. Mueve la autoridad al lado del servidor. Haz que tu propio backend guarde la credencial y decida si un llamador puede recibir el resultado. Una clave que el navegador nunca recibe no puede filtrarse.
  3. Comprueba qué está en producción. Ejecuta npx afterpack@latest audit contra tu sitio desplegado. Reporta secretos expuestos, source maps filtrados y el nivel de protección del JavaScript que el sitio realmente sirve, así sabes qué puede extraer un lector de producción ahora mismo.
  4. Reconstruye y vuelve a desplegar, para que el bundle que lleva el valor antiguo deje de servirse.
La ofuscación no sustituye a la rotación

Una credencial que se envió tiene que rotarse, sea cual sea el preset con el que se construyó.

Prueba el build ofuscado

Ejecuta siempre tus pruebas de integración contra el build ofuscado. Probar el build previo a la ofuscación no te dice nada sobre lo que se envía.

Un fallo en el build ofuscado que pasó en el plano señala o bien una brecha de preservación (un nombre que el runtime esperaba se renombró) o un patrón de reflexión no reconocido. El diagnóstico suele nombrar la clave de configuración que lo arregla. Ejecuta con --diagnostics.level=all para ver cada diagnóstico completo en vez del resumen condensado.

Un build en verde es toda la señal

Cualquier cosa que hubiera enviado una protección más débil de la que configuraste detiene el build en su lugar, incluidos un archivo que el motor no puede ofuscar y un build Pro que no puede alcanzar la nube. Diagnósticos tiene el contrato de salida y cada código detrás de él.

Siguiente

  • Build y CI: el ciclo de vida de promover los mismos bytes, la higiene de artefactos, y los rebuilds periódicos.
  • Modelo de amenaza: la escalera de adversarios contra la que se calibran estas directivas.
  • Configuración: cada clave de configuración en un solo lugar.
  • Directivas: la gramática completa de marcadores de región detrás de la tabla anterior.
  • Presets: la escalera de cinco niveles que estas directivas sobrescriben por región.
  • Protection Map: verifica qué clase alcanzó realmente las regiones que marcaste.
  • Builds en el panel: los mismos números, por build, para tus builds Pro Cloud.