From 57db2c1cd1fec838faabc4318f64256fdcc5f46d Mon Sep 17 00:00:00 2001 From: Kyle Spier-Swenson Date: Tue, 7 Mar 2023 14:26:00 -0800 Subject: [PATCH] Add config to validate admin discord commands with discord links and admin ranks (#73818) This adds a config to secure discord chat commands used by admins. When enabled it compares the discord id the chat command came from with the linked discords db to find their ckey, then checks they have the correct admin rights. The check automatically self disables if the db is down or if legacy admin ranks are enabled. (There is no config for discord account linking or i'd just use that.) Moved non-admin discord commands out of the admin modules folder and into the discord modules folder. Deleted some defunct shit. There was a global list and admin only notify command that was used by nothing. There was a whole discord config section that was used by nothing. --- .../configuration/entries/general.dm | 4 + code/controllers/subsystem/discord.dm | 37 ++++- code/datums/world_topic.dm | 10 +- code/modules/admin/chat_commands.dm | 135 +++++++++--------- code/modules/discord/tgs_commands.dm | 34 +++++ config/config.txt | 19 +-- 6 files changed, 140 insertions(+), 99 deletions(-) diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm index c3a1f6054ed..f6a109df72b 100644 --- a/code/controllers/configuration/entries/general.dm +++ b/code/controllers/configuration/entries/general.dm @@ -541,6 +541,10 @@ /datum/config_entry/string/chat_new_game_notifications default = null +/// validate ownership of admin flags for chat commands +/datum/config_entry/flag/secure_chat_commands + default = FALSE + /datum/config_entry/flag/debug_admin_hrefs /datum/config_entry/number/mc_tick_rate/base_mc_tick_rate diff --git a/code/controllers/subsystem/discord.dm b/code/controllers/subsystem/discord.dm index 56345846dde..a711b0eabed 100644 --- a/code/controllers/subsystem/discord.dm +++ b/code/controllers/subsystem/discord.dm @@ -100,7 +100,7 @@ SUBSYSTEM_DEF(discord) * * lookup_ckey A string representing the ckey to search on */ /datum/controller/subsystem/discord/proc/lookup_id(lookup_ckey) - var/datum/discord_link_record/link = find_discord_link_by_ckey(lookup_ckey) + var/datum/discord_link_record/link = find_discord_link_by_ckey(lookup_ckey, only_valid = TRUE) if(link) return link.discord_id @@ -113,7 +113,7 @@ SUBSYSTEM_DEF(discord) * * lookup_id The discord id as a string */ /datum/controller/subsystem/discord/proc/lookup_ckey(lookup_id) - var/datum/discord_link_record/link = find_discord_link_by_discord_id(lookup_id) + var/datum/discord_link_record/link = find_discord_link_by_discord_id(lookup_id, only_valid = TRUE) if(link) return link.ckey @@ -219,12 +219,15 @@ SUBSYSTEM_DEF(discord) * * Returns a [/datum/discord_link_record] */ -/datum/controller/subsystem/discord/proc/find_discord_link_by_ckey(ckey, timebound = FALSE) +/datum/controller/subsystem/discord/proc/find_discord_link_by_ckey(ckey, timebound = FALSE, only_valid = FALSE) var/timeboundsql = "" if(timebound) timeboundsql = "AND timestamp >= Now() - INTERVAL 4 HOUR" + var/validsql = "" + if(only_valid) + validsql = "AND valid = 1" - var/query = "SELECT CAST(discord_id AS CHAR(25)), ckey, MAX(timestamp), one_time_token FROM [format_table_name("discord_links")] WHERE ckey = :ckey [timeboundsql] GROUP BY ckey, discord_id, one_time_token LIMIT 1" + var/query = "SELECT CAST(discord_id AS CHAR(25)), ckey, MAX(timestamp), one_time_token FROM [format_table_name("discord_links")] WHERE ckey = :ckey [timeboundsql] [validsql] GROUP BY ckey, discord_id, one_time_token LIMIT 1" var/datum/db_query/query_get_discord_link_record = SSdbcore.NewQuery( query, list("ckey" = ckey) @@ -254,12 +257,15 @@ SUBSYSTEM_DEF(discord) * * Returns a [/datum/discord_link_record] */ -/datum/controller/subsystem/discord/proc/find_discord_link_by_discord_id(discord_id, timebound = FALSE) +/datum/controller/subsystem/discord/proc/find_discord_link_by_discord_id(discord_id, timebound = FALSE, only_valid = FALSE) var/timeboundsql = "" if(timebound) timeboundsql = "AND timestamp >= Now() - INTERVAL 4 HOUR" + var/validsql = "" + if(only_valid) + validsql = "AND valid = 1" - var/query = "SELECT CAST(discord_id AS CHAR(25)), ckey, MAX(timestamp), one_time_token FROM [format_table_name("discord_links")] WHERE discord_id = :discord_id [timeboundsql] GROUP BY ckey, discord_id, one_time_token LIMIT 1" + var/query = "SELECT CAST(discord_id AS CHAR(25)), ckey, MAX(timestamp), one_time_token FROM [format_table_name("discord_links")] WHERE discord_id = :discord_id [timeboundsql] [validsql] GROUP BY ckey, discord_id, one_time_token LIMIT 1" var/datum/db_query/query_get_discord_link_record = SSdbcore.NewQuery( query, list("discord_id" = discord_id) @@ -274,3 +280,22 @@ SUBSYSTEM_DEF(discord) //Make sure we clean up the query qdel(query_get_discord_link_record) + + +/** + * Extract a discord id from a mention string + * + * This will regex out the mention <@num> block to extract the discord id + * + * Arguments: + * * discord_id The users discord mention string (string) + * + * Returns a text string with the discord id or null + */ +/datum/controller/subsystem/discord/proc/get_discord_id_from_mention(mention) + var/static/regex/discord_mention_extraction_regex = regex(@"<@([0-9]+)>") + discord_mention_extraction_regex.Find(mention) + if (length(discord_mention_extraction_regex.group) == 1) + return discord_mention_extraction_regex.group[1] + return null + diff --git a/code/datums/world_topic.dm b/code/datums/world_topic.dm index ee59609c9a0..128e59d815e 100644 --- a/code/datums/world_topic.dm +++ b/code/datums/world_topic.dm @@ -176,12 +176,10 @@ require_comms_key = TRUE /datum/world_topic/namecheck/Run(list/input) - //Oh this is a hack, someone refactor the functionality out of the chat command PLS - var/datum/tgs_chat_command/namecheck/NC = new - var/datum/tgs_chat_user/user = new - user.friendly_name = input["sender"] - user.mention = user.friendly_name - return NC.Run(user, input["namecheck"]) + log_admin("world/Topic Name Check: [input["sender"]] on [input["namecheck"]]") + message_admins("Name checking [input["namecheck"]] from [input["sender"]] (World topic)") + + return keywords_lookup(input["namecheck"], 1) /datum/world_topic/adminwho keyword = "adminwho" diff --git a/code/modules/admin/chat_commands.dm b/code/modules/admin/chat_commands.dm index 064892c90d3..67e2fe3e457 100644 --- a/code/modules/admin/chat_commands.dm +++ b/code/modules/admin/chat_commands.dm @@ -1,57 +1,62 @@ -#define TGS_STATUS_THROTTLE 5 - -/datum/tgs_chat_command/tgsstatus - name = "status" - help_text = "Gets the admincount, playercount, gamemode, and true game mode of the server" +/// Reload admins tgs chat command. Intentionally not validated. +/datum/tgs_chat_command/reload_admins + name = "reload_admins" + help_text = "Forces the server to reload admins." admin_only = TRUE -/datum/tgs_chat_command/tgsstatus/Run(datum/tgs_chat_user/sender, params) - var/list/adm = get_admin_counts() - var/list/allmins = adm["total"] - var/status = "Admins: [allmins.len] (Active: [english_list(adm["present"])] AFK: [english_list(adm["afk"])] Stealth: [english_list(adm["stealth"])] Skipped: [english_list(adm["noflags"])]). " - status += "Players: [GLOB.clients.len] (Active: [get_active_player_count(0,1,0)]). Round has [SSticker.HasRoundStarted() ? "" : "not "]started." - return status +/datum/tgs_chat_command/reload_admins/Run(datum/tgs_chat_user/sender, params) + ReloadAsync() + log_admin("[sender.friendly_name] reloaded admins via chat command.") + message_admins("[sender.friendly_name] reloaded admins via chat command.") + return "Admins reloaded." -/datum/tgs_chat_command/tgscheck - name = "check" - help_text = "Gets the playercount, gamemode, and address of the server" +/datum/tgs_chat_command/reload_admins/proc/ReloadAsync() + set waitfor = FALSE + load_admins() -/datum/tgs_chat_command/tgscheck/Run(datum/tgs_chat_user/sender, params) - var/server = CONFIG_GET(string/server) - return "[GLOB.round_id ? "Round #[GLOB.round_id]: " : ""][GLOB.clients.len] players on [SSmapping.config.map_name]; Round [SSticker.HasRoundStarted() ? (SSticker.IsRoundInProgress() ? "Active" : "Finishing") : "Starting"] -- [server ? server : "[world.internet_address]:[world.port]"]" +/// subtype tgs chat command with validated admin ranks. Only supports discord. +/datum/tgs_chat_command/validated + var/required_rights = 0 //! validate discord userid is linked to a game admin with these flags. + admin_only = TRUE -/datum/tgs_chat_command/gameversion - name = "gameversion" - help_text = "Gets the version details from the show-server-revision verb, basically" -/datum/tgs_chat_command/gameversion/Run(datum/tgs_chat_user/sender, params) - var/list/msg = list("") - msg += "BYOND Server Version: [world.byond_version].[world.byond_build] (Compiled with: [DM_VERSION].[DM_BUILD])\n" +/// called by tgs +/datum/tgs_chat_command/validated/Run(datum/tgs_chat_user/sender, params) + if (!CONFIG_GET(flag/secure_chat_commands) || CONFIG_GET(flag/admin_legacy_system) || !SSdbcore.Connect()) + return Validated_Run(sender, params) - if (!GLOB.revdata) - msg += "No revision information found." + var/discord_id = SSdiscord.get_discord_id_from_mention(sender.mention) || sender.id + if (!discord_id) + return "Error: Unknown error trying to get your discord id." + + var/datum/admins/linked_admin + var/admin_ckey = ckey(SSdiscord.lookup_ckey(discord_id)) + + if (admin_ckey) + linked_admin = GLOB.admin_datums[admin_ckey] || GLOB.deadmins[admin_ckey] else - msg += "Revision [copytext_char(GLOB.revdata.commit, 1, 9)]" - if (GLOB.revdata.date) - msg += " compiled on '[GLOB.revdata.date]'" - - if(GLOB.revdata.originmastercommit) - msg += ", from origin commit: <[CONFIG_GET(string/githuburl)]/commit/[GLOB.revdata.originmastercommit]>" + return "Error: Could not find a linked ckey for your discord id." - if(GLOB.revdata.testmerge.len) - msg += "\n" - for(var/datum/tgs_revision_information/test_merge/PR as anything in GLOB.revdata.testmerge) - msg += "PR #[PR.number] at [copytext_char(PR.head_commit, 1, 9)] [PR.title].\n" - if (PR.url) - msg += "<[PR.url]>\n" - return msg.Join("") + if (!linked_admin) + return "Error: Your linked ckey (`[admin_ckey]`) was not found in the admin list. If this is a mistake you can try `reload_admins`" -/datum/tgs_chat_command/ahelp + if (!linked_admin.check_for_rights(required_rights)) + return "Error: Your linked ckey (`[admin_ckey]`) does not have sufficient rights to do that. You require one of the following flags: `[rights2text(required_rights," ")]`" + + return Validated_Run(sender, params) + + +/// Called if the sender passes validation checks or if those checks are disabled. +/datum/tgs_chat_command/validated/proc/Validated_Run(datum/tgs_chat_user/sender, params) + CRASH("[type] has no implementation for Validated_Run()") + +/datum/tgs_chat_command/validated/ahelp name = "ahelp" help_text = " |list>>" admin_only = TRUE + required_rights = R_ADMIN -/datum/tgs_chat_command/ahelp/Run(datum/tgs_chat_user/sender, params) +/datum/tgs_chat_command/validated/ahelp/Validated_Run(datum/tgs_chat_user/sender, params) var/list/all_params = splittext(params, " ") if(all_params.len < 2) return "Insufficient parameters" @@ -66,12 +71,13 @@ return "Ticket #[id] not found!" return TgsPm(target, all_params.Join(" "), sender.friendly_name) -/datum/tgs_chat_command/namecheck +/datum/tgs_chat_command/validated/namecheck name = "namecheck" help_text = "Returns info on the specified target" admin_only = TRUE + required_rights = R_ADMIN -/datum/tgs_chat_command/namecheck/Run(datum/tgs_chat_user/sender, params) +/datum/tgs_chat_command/validated/namecheck/Validated_Run(datum/tgs_chat_user/sender, params) params = trim(params) if(!params) return "Insufficient parameters" @@ -79,34 +85,22 @@ message_admins("Name checking [params] from [sender.friendly_name]") return keywords_lookup(params, 1) -/datum/tgs_chat_command/adminwho +/datum/tgs_chat_command/validated/adminwho name = "adminwho" help_text = "Lists administrators currently on the server" admin_only = TRUE + required_rights = 0 -/datum/tgs_chat_command/adminwho/Run(datum/tgs_chat_user/sender, params) +/datum/tgs_chat_command/validated/adminwho/Validated_Run(datum/tgs_chat_user/sender, params) return tgsadminwho() -GLOBAL_LIST(round_end_notifiees) - -/datum/tgs_chat_command/endnotify - name = "endnotify" - help_text = "Pings the invoker when the round ends" - admin_only = TRUE - -/datum/tgs_chat_command/endnotify/Run(datum/tgs_chat_user/sender, params) - if(!SSticker.IsRoundInProgress() && SSticker.HasRoundStarted()) - return "[sender.mention], the round has already ended!" - LAZYINITLIST(GLOB.round_end_notifiees) - GLOB.round_end_notifiees[sender.mention] = TRUE - return "I will notify [sender.mention] when the round ends." - -/datum/tgs_chat_command/sdql +/datum/tgs_chat_command/validated/sdql name = "sdql" help_text = "Runs an SDQL query" admin_only = TRUE + required_rights = R_DEBUG -/datum/tgs_chat_command/sdql/Run(datum/tgs_chat_user/sender, params) +/datum/tgs_chat_command/validated/sdql/Validated_Run(datum/tgs_chat_user/sender, params) var/list/results = HandleUserlessSDQL(sender.friendly_name, params) if(!results) return "Query produced no output" @@ -114,16 +108,15 @@ GLOBAL_LIST(round_end_notifiees) var/list/refs = results.len > 3 ? results.Copy(4) : null . = "[text_res.Join("\n")][refs ? "\nRefs: [refs.Join(" ")]" : ""]" -/datum/tgs_chat_command/reload_admins - name = "reload_admins" - help_text = "Forces the server to reload admins." +/datum/tgs_chat_command/validated/tgsstatus + name = "status" + help_text = "Gets the admincount, playercount, gamemode, and true game mode of the server" admin_only = TRUE + required_rights = R_ADMIN -/datum/tgs_chat_command/reload_admins/Run(datum/tgs_chat_user/sender, params) - ReloadAsync() - log_admin("[sender.friendly_name] reloaded admins via chat command.") - return "Admins reloaded." - -/datum/tgs_chat_command/reload_admins/proc/ReloadAsync() - set waitfor = FALSE - load_admins() +/datum/tgs_chat_command/validated/tgsstatus/Validated_Run(datum/tgs_chat_user/sender, params) + var/list/adm = get_admin_counts() + var/list/allmins = adm["total"] + var/status = "Admins: [allmins.len] (Active: [english_list(adm["present"])] AFK: [english_list(adm["afk"])] Stealth: [english_list(adm["stealth"])] Skipped: [english_list(adm["noflags"])]). " + status += "Players: [GLOB.clients.len] (Active: [get_active_player_count(0,1,0)]). Round has [SSticker.HasRoundStarted() ? "" : "not "]started." + return status diff --git a/code/modules/discord/tgs_commands.dm b/code/modules/discord/tgs_commands.dm index 908b2da6964..b1b57a8f430 100644 --- a/code/modules/discord/tgs_commands.dm +++ b/code/modules/discord/tgs_commands.dm @@ -1,3 +1,37 @@ +/datum/tgs_chat_command/tgscheck + name = "check" + help_text = "Gets the playercount, gamemode, and address of the server" + +/datum/tgs_chat_command/tgscheck/Run(datum/tgs_chat_user/sender, params) + var/server = CONFIG_GET(string/server) + return "[GLOB.round_id ? "Round #[GLOB.round_id]: " : ""][GLOB.clients.len] players on [SSmapping.config.map_name]; Round [SSticker.HasRoundStarted() ? (SSticker.IsRoundInProgress() ? "Active" : "Finishing") : "Starting"] -- [server ? server : "[world.internet_address]:[world.port]"]" + +/datum/tgs_chat_command/gameversion + name = "gameversion" + help_text = "Gets the version details from the show-server-revision verb, basically" + +/datum/tgs_chat_command/gameversion/Run(datum/tgs_chat_user/sender, params) + var/list/msg = list("") + msg += "BYOND Server Version: [world.byond_version].[world.byond_build] (Compiled with: [DM_VERSION].[DM_BUILD])\n" + + if (!GLOB.revdata) + msg += "No revision information found." + else + msg += "Revision [copytext_char(GLOB.revdata.commit, 1, 9)]" + if (GLOB.revdata.date) + msg += " compiled on '[GLOB.revdata.date]'" + + if(GLOB.revdata.originmastercommit) + msg += ", from origin commit: <[CONFIG_GET(string/githuburl)]/commit/[GLOB.revdata.originmastercommit]>" + + if(GLOB.revdata.testmerge.len) + msg += "\n" + for(var/datum/tgs_revision_information/test_merge/PR as anything in GLOB.revdata.testmerge) + msg += "PR #[PR.number] at [copytext_char(PR.head_commit, 1, 9)] [PR.title].\n" + if (PR.url) + msg += "<[PR.url]>\n" + return msg.Join("") + // Notify /datum/tgs_chat_command/notify name = "notify" diff --git a/config/config.txt b/config/config.txt index 8d4b7d86e68..9ee615a63ed 100644 --- a/config/config.txt +++ b/config/config.txt @@ -501,6 +501,9 @@ MINUTE_CLICK_LIMIT 400 ## Ping users who use the `notify` command when a new game starts. #CHAT_NEW_GAME_NOTIFICATIONS +## Uncomment this to validate admin commands from discord by requiring they come from linked discord accounts and that those discord accounts link to a ckey with the right admin permissions. +# SECURE_CHAT_COMMANDS + ## Allow admin hrefs that don't use the new token system, will eventually be removed #DEBUG_ADMIN_HREFS @@ -559,22 +562,6 @@ DEFAULT_VIEW_SQUARE 15x15 ## of their connection attempt. #ADMIN_2FA_URL https://example.com/id/%ID% -#### DISCORD STUFFS #### -## MAKE SURE ALL SECTIONS OF THIS ARE FILLED OUT BEFORE ENABLING -## Discord IDs can be obtained by following this guide: https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID- - -## Uncomment to enable discord auto-roling when users link their BYOND and Discord accounts -#ENABLE_DISCORD_AUTOROLE - -## Add your discord bot token here. Make sure it has the ability to manage roles -#DISCORD_TOKEN someDiscordToken - -## Add the ID of your guild (server) here -#DISCORD_GUILDID 000000000000000000 - -## Add the ID of the role you want assigning here -#DISCORD_ROLEID 000000000000000000 - ## How long in seconds after which a hard delete is treated as causing lag. This can be a float and supports a precision as low as nanoseconds. #HARD_DELETES_OVERRUN_THRESHOLD 0.5