Skip to content

Security: Alexpoopy1/HTMLuau

Security

docs/SECURITY.md

Security

HTMLuau runs markup, styles and scripts. Some of that may be content you did not write. This document says exactly what is trusted, what is not, and where the boundaries are.


Threat model

Two situations, with very different exposure.

Markup you authored, shipped inside your game. Low risk. The scripts are yours; the engine is a rendering layer. You can safely enable everything, including Luau scripts.

Markup from anywhere else — fetched over HTTP, stored in a DataStore, submitted by a player. Treat it as hostile. It will run in the JavaScript engine, and JavaScript is a Turing-complete language with access to whatever you exposed to it.

The engine is built so the second case stays contained, but containment depends on you not handing it capabilities.


The HTTP bridge is the main boundary

Roblox permits HttpService only on the server. A client that needs to fetch something must ask the server, which means every bridged request runs with the server's network identity and reaches whatever the server can reach — including addresses no player can.

An unguarded bridge is an open proxy. Net.Bridge.Server therefore applies four independent guards, all before a request goes anywhere:

1. Allowlist — denies by default

Nothing is permitted until named:

local policy = HTMLuau.Net.allowlist({
    hosts = { "api.yourgame.com", "*.jsdelivr.net" },
    methods = { "GET" },
})
HTMLuau.serve({ allowlist = policy })
  • Wildcards allow one leading *. label only. * and *.com raise at configuration time — a boundary that one careless line can erase is not a boundary.
  • Private and loopback addresses are refused even when explicitly listed. 127.0.0.1, 10/8, 192.168/16, 172.16/12, 169.254/16 (cloud metadata), ::1 and *.local are the targets of a server-side request forgery attempt, and no legitimate page needs them. Override with allowPrivate only for local development.
  • https only unless you set allowHttp.
  • Redirects are re-checked against the policy, so a permitted host cannot bounce a request somewhere it is not allowed to go.

2. Per-player rate limiting

A token bucket keyed by UserId. Roblox caps HttpService at roughly 500 requests per minute per server, so one unbounded client can starve every other player and your own game logic. Bursts are allowed — a page load legitimately fetches several things at once — but sustained flooding is not.

3. Response size cap

Default 2 MiB. A large response replicated back to a client is bandwidth an attacker did not pay for.

4. Payload validation and header filtering

The payload arrives from the network, so every field is validated: type, length, method. Request headers are filtered against a permitted set. Authorization, Cookie and Host are not forwardable — a client that could set them would be borrowing the server's identity or spoofing the target. Only a small set of response fields cross back, so Set-Cookie never reaches the client.

Configure an auditLog to record what is being requested and by whom:

HTMLuau.serve({
    allowlist = policy,
    auditLog = function(player, url, allowed, reason)
        if not allowed then
            warn(("blocked %s for %s: %s"):format(url, player and player.Name, reason))
        end
    end,
})

Script execution

Three paths, in ascending order of trust required.

1. Handler registry — the default, and the one to prefer

runtime:register("app", {
    save = function(payload) ... end,
})

The page calls app.save(...). Nothing arbitrary executes; the page reaches exactly the functions you chose and nothing else. Works on the client. This is the recommended path for anything you did not author.

Arguments arrive as plain Luau data and return values are converted back, so a page cannot hand you a live JavaScript object that behaves unexpectedly. A Luau error inside a registered function becomes a catchable JS exception rather than propagating.

2. <script> JavaScript

Runs in HTMLuau's interpreter, confined to its realm. It can read and mutate the document and call whatever the registry exposes — nothing else. There is no eval, no Function constructor, no filesystem, no Roblox API surface.

Bounded against runaway code: recursion caps at 200 frames, loops abort past 10 million iterations, the regex engine has a backtracking budget, and each realm is fully isolated from every other.

What it can still do: anything you exposed. If you register a function that grants Robux, a hostile page will call it. Expose capabilities, not primitives.

3. <script type="text/luau"> — off by default

Real Luau via loadstring, with real capabilities. Requires both allowLuau = true and a server context, because loadstring is server-only and needs ServerScriptService.LoadStringEnabled.

Only enable this for markup you wrote yourself. There is no sandbox — it is Luau.


Rendering untrusted markup

The renderer itself is not a script execution path: HTML and CSS from an untrusted source cannot escape into Luau. Practical cautions:

  • A page can build a large DOM. Cap the markup size you accept before parsing it.
  • A page can request images. Roblox cannot load remote images at all, so the worst case is a placeholder — but assetMap entries you supply are loaded, so only map assets you control.
  • <a href> navigation calls your onNavigate; validate the URL there before acting on it, and it will still pass the allowlist if you route it through the browser.
  • Form submission hands you the values via onSubmit. They are player input. Treat them accordingly.

Checklist for shipping

  • Allowlist names only the hosts you actually need, with the narrowest methods.
  • allowPrivate is off.
  • Rate limits are set for your expected page-load pattern.
  • allowLuau is off unless every page is authored by you.
  • The registry exposes capabilities (buyItem), not primitives (setPlayerCurrency).
  • Anything a page submits is validated server-side, as with any player input.
  • An auditLog is wired up so you can see what is being requested.

There aren't any published security advisories