How to stop Discord webhook spam from your Roblox game

Bug reports and feedback sent to a webhook with no cooldown or mention limits can be flooded or ping everyone. How to add a cooldown, a length cap and allowed_mentions.

warningLuaudit check: player-text-to-webhooks

What it looks like

A bug report, feedback form, appeal or report-a-player feature takes text from the client and posts it to a webhook or another outside service. The server is missing at least one of: a per-player cooldown, a length limit, or, for Discord webhooks, a setting that stops the message pinging anyone.

Luau
remote.OnServerEvent:Connect(function(player, text)
	HttpService:PostAsync(webhook, HttpService:JSONEncode({ content = text }))
end)

How to detect it

  • Find remote handlers that pass a client string into the body of a PostAsync or RequestAsync call, directly or through a helper module.
  • Look for a cooldown kept on the server per player. A cooldown in the LocalScript or the button doesn't count.
  • Look for a length check or string.sub on the text before it's sent.
  • For Discord webhooks, look for allowed_mentions = { parse = {} } in the payload, or text cleaned of @ mentions, when the text goes into content or an embed that pings. If the address comes from the secrets store and you can't see it, treat a payload with content as a Discord webhook.
  • Report one finding per handler and list everything it's missing.

What an exploiter could do

They can flood the channel with messages, and ping everyone in your Discord server if mentions aren't turned off. Each request uses the server's HTTP budget of 500 external requests a minute, shared by every script, so a flood can make the game's other requests fail. The receiving service may also start rejecting requests, so real reports get lost.

How to fix it

  • Keep a per-player cooldown on the server, and drop or merge reports that arrive too quickly.
  • Check the text is a string and cut it to a fixed length with string.sub, or reject it if it's too long.
  • For Discord, send allowed_mentions = { parse = {} } with every message so player text can't ping anyone.
  • Keep the webhook address in the secrets store and make the request only on the server.

Vulnerable example

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

ReplicatedStorage.Remotes.SendFeedback.OnServerEvent:Connect(function(player: Player, text: unknown)
	if typeof(text) ~= "string" or text == "" then
		return
	end
	local found, webhook = pcall(HttpService.GetSecret, HttpService, "FeedbackWebhook")
	if not found then
		return
	end
	pcall(HttpService.PostAsync, HttpService, webhook, HttpService:JSONEncode({
		content = `{player.Name}: {text}`,
	}))
end)

Fixed example

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

local cooldown = 120
local maxLength = 500
local lastSent: { [Player]: number } = {}

ReplicatedStorage.Remotes.SendFeedback.OnServerEvent:Connect(function(player: Player, text: unknown)
	if typeof(text) ~= "string" or text == "" then
		return
	end
	local now = os.clock()
	if now - (lastSent[player] or -math.huge) < cooldown then
		return
	end
	lastSent[player] = now
	local found, webhook = pcall(HttpService.GetSecret, HttpService, "FeedbackWebhook")
	if not found then
		return
	end
	pcall(HttpService.PostAsync, HttpService, webhook, HttpService:JSONEncode({
		content = `{player.Name}: {string.sub(text, 1, maxLength)}`,
		allowed_mentions = { parse = {} },
	}))
end)

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

Common false positives

  • Messages built only from server values, such as a server's player count or the game's own error messages, aren't player text. Server details sent out belong to Server details sent to outside addresses.
  • Requests to the team's own backend that enforces its own limits per player are fine, as long as the server still has a cooldown.
  • string.sub counts bytes, so a cut can split a multi-byte character. That's untidy, not a security problem.