How to filter player text in Roblox with TextService

Pet names, signs and plot names typed by players must be filtered before others see them. How to use FilterStringAsync and the right TextFilterResult method on the server.

criticalLuaudit check: unfiltered-text

What it looks like

A player types something, such as a pet name, a sign, a plot name or a team name, and the client sends it to the server. The server puts it somewhere other players can see it without passing it through TextService first.

Luau
remote.OnServerEvent:Connect(function(player, text)
	sign.SurfaceGui.TextLabel.Text = text
end)

How to detect it

  • Find handlers that receive a string the player typed and then show it to anyone: a Text property on a SurfaceGui, BillboardGui or ScreenGui, an attribute or value object that clients display, a FireAllClients or FireClient call, or a name set on an Instance that other players can see.
  • Look for TextService:FilterStringAsync(text, player.UserId) before the text is shown, followed by GetNonChatStringForBroadcastAsync() when everyone in the server can see it.
  • GetNonChatStringForUserAsync(userId) filters for one player only. Text filtered that way and then shown to everyone is a finding.
  • Text fetched from the web or generated from random letters needs the same filtering before it's shown. So does text loaded from a data store, even if it was filtered when it was first written.
  • Messages sent through TextChatService are filtered by Roblox. Custom text outside the chat isn't.
  • Filtering on the client doesn't count. The client can skip it and fire the remote with anything.

What an exploiter could do

They can put slurs, abuse or personal information, such as phone numbers, in front of every player in the server. Roblox requires text you didn't write to be filtered, and its documentation says a game found not to filter text is removed until filtering is added. So one player can get the whole game taken down.

How to fix it

  • Check the argument is a string, with a sensible length limit, before filtering.
  • On the server, call FilterStringAsync with the UserId of the player who wrote the text, then GetNonChatStringForBroadcastAsync() for text everyone can see, or GetNonChatStringForUserAsync() for text only one player will see.
  • Both calls yield and can fail, so wrap them in pcall. If filtering fails, don't show the text at all. Roblox's documentation says not to retry FilterStringAsync yourself, as it already retries.
  • FilterStringAsync throws if the author isn't in the server. Filter saved text while its author is in the server, such as when their plot loads, and don't show it if filtering fails or the author has left.
  • Filter once per submission, not on every keystroke.

Vulnerable example

Vulnerable
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")

local board = Workspace:WaitForChild("NoticeBoard")
local label = board:WaitForChild("SurfaceGui"):WaitForChild("TextLabel") :: TextLabel
local maxLength = 80

ReplicatedStorage.Remotes.PostNotice.OnServerEvent:Connect(function(player: Player, text: unknown)
	if typeof(text) ~= "string" or #text == 0 or #text > maxLength then
		return
	end
	label.Text = player.DisplayName .. ": " .. text
end)

Fixed example

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

local board = Workspace:WaitForChild("NoticeBoard")
local label = board:WaitForChild("SurfaceGui"):WaitForChild("TextLabel") :: TextLabel
local maxLength = 80

local function filterForEveryone(text: string, author: Player): string?
	local ok, result = pcall(function()
		return TextService:FilterStringAsync(text, author.UserId):GetNonChatStringForBroadcastAsync()
	end)
	if ok then
		return result
	end
	return nil
end

ReplicatedStorage.Remotes.PostNotice.OnServerEvent:Connect(function(player: Player, text: unknown)
	if typeof(text) ~= "string" or #text == 0 or #text > maxLength then
		return
	end
	local filtered = filterForEveryone(text, player)
	if not filtered then
		return
	end
	label.Text = player.DisplayName .. ": " .. filtered
end)

Common false positives

  • Text the server chose itself, such as a name picked from an allowlist or a number, doesn't need filtering.
  • Text only ever shown back to the player who typed it, and never sent anywhere else, isn't something other players can see. Showing it to anyone else needs filtering.
  • Chat sent through TextChatService is filtered by Roblox.
  • Filtering done in a helper module counts. Read it before reporting, and check which TextFilterResult method it uses.