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.
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.
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
RichTextoff for it, or show the player's text in its own label without rich text.
Vulnerable example
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
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 = { ["&"] = "&", ["<"] = "<", [">"] = ">", ['"'] = """, ["'"] = "'", } 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.