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.
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.
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:
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*.comraise 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),::1and*.localare the targets of a server-side request forgery attempt, and no legitimate page needs them. Override withallowPrivateonly for local development. httpsonly unless you setallowHttp.- Redirects are re-checked against the policy, so a permitted host cannot bounce a request somewhere it is not allowed to go.
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.
Default 2 MiB. A large response replicated back to a client is bandwidth an attacker did not pay for.
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,
})Three paths, in ascending order of trust required.
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.
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.
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.
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
assetMapentries you supply are loaded, so only map assets you control. <a href>navigation calls youronNavigate; 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.
- Allowlist names only the hosts you actually need, with the narrowest methods.
-
allowPrivateis off. - Rate limits are set for your expected page-load pattern.
-
allowLuauis 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
auditLogis wired up so you can see what is being requested.