How to escape player text in Roblox rich text labels

Filtered text can still carry rich text tags that fake staff titles or cover the screen. How to escape player text before it goes into a rich text label.

warningLuaudit check: unescaped-rich-text

What it looks like

Text a player wrote, such as a crew tag, a pet name, a sign or a bio, is shown in a TextLabel, TextButton or TextBox with RichText turned on, often wrapped in the game's own colour or size tags. The text is filtered but never escaped, so any <, > or & it contains is read as rich text markup.

Luau
label.RichText = true
label.Text = `<font color="#ffd75e">[{filteredTag}]</font> {player.DisplayName}`

What an exploiter could do

They put markup in their text. A crew tag can close the game's colour tag and add its own, so their nameplate shows a fake "[MOD]" or "[DEV]" tag in the staff colour, or text so large it covers other players' screens. They can make a sign or bio look like an official game message, hide words with transparency, or break the label's layout for everyone who sees it.

How to fix it

  • Escape player text before putting it into rich text, with a helper that replaces &, <, >, " and ' with their escape forms.
  • Escape after filtering, since the filter works on the text the player wrote.
  • Where a label needs no formatting, turn RichText off for it, or show the player's text in its own label without rich text.

Vulnerable example

Vulnerable
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TextService = game:GetService("TextService")

local board = workspace:WaitForChild("BioBoard")
local lastBio: { [Player]: number } = {}

ReplicatedStorage.Remotes.SetBio.OnServerEvent:Connect(function(player: Player, bio: unknown)
	if typeof(bio) ~= "string" or #bio > 80 or utf8.len(bio) == nil then
		return
	end
	local now = os.clock()
	if now - (lastBio[player] or -60) < 60 then
		return
	end
	lastBio[player] = now
	local ok, result = pcall(TextService.FilterStringAsync, TextService, bio, player.UserId)
	if not ok then
		return
	end
	local shown, text = pcall(result.GetNonChatStringForBroadcastAsync, result)
	local card = board:FindFirstChild(tostring(player.UserId))
	if shown and card then
		local label = card:FindFirstChildWhichIsA("TextLabel", true)
		if label then
			label.RichText = true
			label.Text = `<b>{player.DisplayName}</b>\n{text}`
		end
	end
end)

Players.PlayerRemoving:Connect(function(player)
	lastBio[player] = nil
end)

Fixed example

Fixed
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TextService = game:GetService("TextService")

local board = workspace:WaitForChild("BioBoard")
local lastBio: { [Player]: number } = {}
local escapes = {
	["&"] = "&amp;",
	["<"] = "&lt;",
	[">"] = "&gt;",
	['"'] = "&quot;",
	["'"] = "&apos;",
}

local function escape(text: string): string
	return (string.gsub(text, "[&<>\"']", escapes))
end

ReplicatedStorage.Remotes.SetBio.OnServerEvent:Connect(function(player: Player, bio: unknown)
	if typeof(bio) ~= "string" or #bio > 80 or utf8.len(bio) == nil then
		return
	end
	local now = os.clock()
	if now - (lastBio[player] or -60) < 60 then
		return
	end
	lastBio[player] = now
	local ok, result = pcall(TextService.FilterStringAsync, TextService, bio, player.UserId)
	if not ok then
		return
	end
	local shown, text = pcall(result.GetNonChatStringForBroadcastAsync, result)
	local card = board:FindFirstChild(tostring(player.UserId))
	if shown and card then
		local label = card:FindFirstChildWhichIsA("TextLabel", true)
		if label then
			label.RichText = true
			label.Text = `<b>{escape(player.DisplayName)}</b>\n{escape(text)}`
		end
	end
end)

Players.PlayerRemoving:Connect(function(player)
	lastBio[player] = nil
end)

How to spot this in your own scripts, and the cases that only look like it, are part of the Luaudit check your AI runs.