Commit c1bb5c6ab3 for freeswitch.com

commit c1bb5c6ab35d948885e71e632c100889bc1b93ba
Author: Andrey Volk <andywolk@gmail.com>
Date:   Wed Aug 5 18:44:33 2026 +0300

    [Core, mod_commands] Interface allowlist (#3086)

    * [core] Add interface allowlist to gate module app/api registration

    Adds an optional, presence-activated allowlist in switch.conf.xml that
    controls which modules may register application / api / json_api /
    chat-application interfaces. With no <interface-allowlist> configured
    nothing is enforced; when at least one <allow> entry is present, only
    listed interfaces register at load time and all others are refused (the
    module still loads and switch_loadable_module_process still returns
    SUCCESS -- the blocked interface is simply never exposed).

    Entries match at three levels of precision:
      mod_commands            - whole module
      mod_commands.system     - any interface named "system"
      mod_commands.system.api - a specific type (app|api|json_api|chat_app)

    Enforcement lives in switch_loadable_module_process() so every module, at
    boot and at runtime `load`, is subject to the same policy. This gives
    operators a way to disable the "system"/"spawn" shell-exec API commands
    (and equivalents) system-wide.

    Also adds the `interface_allowlist_dump [modules] [plain]` API, which
    walks the loaded modules and prints their interfaces in the allowlist key
    format so the current state can be captured and pruned offline into config.

    * [mod_commands] Add tests for the interface allowlist

    New test_interface_allowlist boots the core with an active
    <interface-allowlist> (conf_interface_allowlist/) that permits only a
    couple of mod_commands interfaces, then loads mod_commands and verifies:

    - listed commands register and run (status, version) while unlisted and
      shell-exec commands are refused (system, spawn, uptime) -- refusal
      surfaces as switch_api_execute returning FALSE / command-not-found,
      with the command function never invoked;
    - a "module.name.type" entry gates by type: the API "status" loads while
      the JSON API of the same name stays blocked;
    - interface_allowlist_dump prints the config format in its xml, modules
      and plain variants, and reflects module capabilities (system appears in
      the dump even though it was blocked from registering).

    * [config] Fix interior -- in interface-allowlist comment breaking XML parse

    The explanatory comment used -- as em-dash pairs. The XML parser treats
    -- inside a comment as the comment close, causing an "unclosed <!--"
    error that prevents the whole freeswitch.xml from parsing (boot and
    reloadxml both fail). Replace the -- pairs with ordinary punctuation.

    * update .gitignore

    * [core] Warn when interface-allowlist section is present but parses no entries

    Co-authored-by: Chris Rienzo <chris@signalwire.com>

diff --git a/.gitignore b/.gitignore
index 2c92e39b59..1b9dcb1551 100644
--- a/.gitignore
+++ b/.gitignore
@@ -282,5 +282,6 @@ images/test_text.png

 src/mod/codecs/mod_amrwb/test/test_amrwb
 src/mod/endpoints/mod_sofia/test/sipp-based-tests
+src/mod/applications/mod_commands/test/test_interface_allowlist

 .DS_Store
diff --git a/conf/vanilla/autoload_configs/switch.conf.xml b/conf/vanilla/autoload_configs/switch.conf.xml
index 29ac39976f..4c7cb9c0b2 100644
--- a/conf/vanilla/autoload_configs/switch.conf.xml
+++ b/conf/vanilla/autoload_configs/switch.conf.xml
@@ -208,4 +208,39 @@

   </settings>

+  <!--
+      Interface allowlist (optional security hardening).
+
+      When this section contains at least one <allow> entry, ONLY the interfaces listed here
+      are permitted to register; every other application / api / json_api / chat-application
+      from ANY module is refused at load time (the module still loads, it just does not expose
+      the blocked interface). With this section absent or empty, nothing is enforced and all
+      interfaces load normally.
+
+      An interface is permitted if it matches ANY entry. Entries may be written at three
+      levels of precision:
+
+        <allow name="mod_commands"/>            - permit every interface in mod_commands
+        <allow name="mod_commands.status"/>     - permit any interface named "status" (all types)
+        <allow name="mod_commands.status.api"/> - permit ONLY the api named "status"
+
+      The optional trailing type token is one of: app, api, json_api, chat_app. This is how
+      you permit, e.g., the APP named "system" while still blocking an API/JSON-API of the
+      same name.
+
+      WARNING: this is an aggressive, system-wide allowlist. Once enabled you must list every
+      module (or interface) whose apps/APIs you rely on, including management commands from
+      mod_commands (load, unload, reload, status, ...) and your dialplan apps; otherwise the
+      system will not function. It only affects app/api/json_api/chat-app registration; codecs,
+      endpoints, timers, formats, etc. are never gated.
+  -->
+  <!--
+  <interface-allowlist>
+    <allow name="mod_commands"/>
+    <allow name="mod_dptools"/>
+    <allow name="mod_dialplan_xml"/>
+    <allow name="mod_sofia"/>
+  </interface-allowlist>
+  -->
+
 </configuration>
diff --git a/src/include/switch_loadable_module.h b/src/include/switch_loadable_module.h
index 77e7b20e23..f72958aa58 100644
--- a/src/include/switch_loadable_module.h
+++ b/src/include/switch_loadable_module.h
@@ -156,6 +156,14 @@ SWITCH_DECLARE(switch_status_t) switch_loadable_module_enumerate_available(const
  */
 SWITCH_DECLARE(switch_status_t) switch_loadable_module_enumerate_loaded(switch_modulename_callback_func_t callback, void *user_data);

+/*!
+  \brief Dump loaded module application/api/json_api/chat-application interfaces in interface-allowlist key format
+  \param stream stream to write the dump to
+  \param by_module if true emit one entry per module, otherwise one fully qualified entry (module.name.type) per interface
+  \param xml if true wrap entries as <allow/> tags inside an <interface-allowlist> element, otherwise emit raw keys
+ */
+SWITCH_DECLARE(void) switch_loadable_module_dump_interface_allowlist(switch_stream_handle_t *stream, switch_bool_t by_module, switch_bool_t xml);
+
 /*!
   \brief build a dynamic module object and register it (for use in double embeded modules)
   \param filename the name of the modules source file
diff --git a/src/mod/applications/mod_commands/Makefile.am b/src/mod/applications/mod_commands/Makefile.am
index 18d5832063..7e2fc80924 100644
--- a/src/mod/applications/mod_commands/Makefile.am
+++ b/src/mod/applications/mod_commands/Makefile.am
@@ -12,8 +12,13 @@ libmodcommands_la_SOURCES = $(mod_commands_la_SOURCES)
 libmodcommands_la_CFLAGS = $(mod_commands_la_CFLAGS)

 noinst_PROGRAMS = test/test_mod_commands
+noinst_PROGRAMS += test/test_interface_allowlist
 test_test_mod_commands_CFLAGS = $(SWITCH_AM_CFLAGS) -I../ -DSWITCH_TEST_BASE_DIR_FOR_CONF=\"${abs_builddir}/test\" -DSWITCH_TEST_BASE_DIR_OVERRIDE=\"${abs_builddir}/test\"
 test_test_mod_commands_LDFLAGS = -avoid-version -no-undefined $(SWITCH_AM_LDFLAGS)
 test_test_mod_commands_LDADD = libmodcommands.la $(switch_builddir)/libfreeswitch.la

+test_test_interface_allowlist_CFLAGS = $(SWITCH_AM_CFLAGS) -I../ -DSWITCH_TEST_BASE_DIR_FOR_CONF=\"${abs_builddir}/test\" -DSWITCH_TEST_BASE_DIR_OVERRIDE=\"${abs_builddir}/test\"
+test_test_interface_allowlist_LDFLAGS = -avoid-version -no-undefined $(SWITCH_AM_LDFLAGS)
+test_test_interface_allowlist_LDADD = libmodcommands.la $(switch_builddir)/libfreeswitch.la
+
 TESTS = $(noinst_PROGRAMS)
diff --git a/src/mod/applications/mod_commands/mod_commands.c b/src/mod/applications/mod_commands/mod_commands.c
index b44035044a..a7f67c52d6 100644
--- a/src/mod/applications/mod_commands/mod_commands.c
+++ b/src/mod/applications/mod_commands/mod_commands.c
@@ -6665,6 +6665,26 @@ SWITCH_STANDARD_API(bg_spawn_function)
 	return SWITCH_STATUS_SUCCESS;
 }

+#define INTERFACE_ALLOWLIST_DUMP_SYNTAX "[modules] [plain]"
+SWITCH_STANDARD_API(interface_allowlist_dump_function)
+{
+	switch_bool_t by_module = SWITCH_FALSE;
+	switch_bool_t xml = SWITCH_TRUE;
+
+	if (!zstr(cmd)) {
+		if (switch_stristr("module", cmd)) {
+			by_module = SWITCH_TRUE;
+		}
+		if (switch_stristr("plain", cmd) || switch_stristr("list", cmd)) {
+			xml = SWITCH_FALSE;
+		}
+	}
+
+	switch_loadable_module_dump_interface_allowlist(stream, by_module, xml);
+
+	return SWITCH_STATUS_SUCCESS;
+}
+
 SWITCH_STANDARD_API(strftime_tz_api_function)
 {
 	char *format = NULL;
@@ -7616,6 +7636,11 @@ SWITCH_MODULE_LOAD_FUNCTION(mod_commands_load)
 		SWITCH_ADD_API(commands_api_interface, "spawn_stream", "Execute a spawn command and capture it's output", spawn_stream_function, SPAWN_SYNTAX);
 	}

+	SWITCH_ADD_API(commands_api_interface, "interface_allowlist_dump", "Dump loaded interfaces in interface-allowlist config format", interface_allowlist_dump_function, INTERFACE_ALLOWLIST_DUMP_SYNTAX);
+	switch_console_set_complete("add interface_allowlist_dump");
+	switch_console_set_complete("add interface_allowlist_dump modules");
+	switch_console_set_complete("add interface_allowlist_dump plain");
+
 	SWITCH_ADD_API(commands_api_interface, "acl", "Compare an ip to an acl list", acl_function, "<ip> <list_name>");
 	SWITCH_ADD_API(commands_api_interface, "alias", "Alias", alias_function, ALIAS_SYNTAX);	SWITCH_ADD_API(commands_api_interface, "coalesce", "Return first nonempty parameter", coalesce_function, COALESCE_SYNTAX);
 	SWITCH_ADD_API(commands_api_interface, "banner", "Return the system banner", banner_function, "");
diff --git a/src/mod/applications/mod_commands/test/conf_interface_allowlist/freeswitch.xml b/src/mod/applications/mod_commands/test/conf_interface_allowlist/freeswitch.xml
new file mode 100644
index 0000000000..96a4be17a7
--- /dev/null
+++ b/src/mod/applications/mod_commands/test/conf_interface_allowlist/freeswitch.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0"?>
+<document type="freeswitch/xml">
+
+  <section name="configuration" description="Various Configuration">
+    <configuration name="modules.conf" description="Modules">
+      <modules>
+        <load module="mod_console"/>
+        <load module="mod_loopback"/>
+        <load module="mod_sndfile"/>
+      </modules>
+    </configuration>
+
+    <configuration name="console.conf" description="Console Logger">
+      <mappings>
+        <map name="all" value="console,debug,info,notice,warning,err,crit,alert"/>
+      </mappings>
+      <settings>
+        <param name="colorize" value="true"/>
+        <param name="loglevel" value="debug"/>
+      </settings>
+    </configuration>
+
+    <configuration name="switch.conf" description="Core Configuration">
+      <!--
+        Active interface allowlist used by the test. Because at least one <allow> entry
+        is present, enforcement is ON: only the interfaces below may register.
+      -->
+      <interface-allowlist>
+        <!-- Autoloaded helper modules permitted wholesale. -->
+        <allow name="mod_console"/>
+        <allow name="mod_loopback"/>
+        <allow name="mod_sndfile"/>
+
+        <!-- Type-qualified: only the API named "status" (the JSON API "status" stays blocked). -->
+        <allow name="mod_commands.status.api"/>
+        <!-- Name only: any type named "version". -->
+        <allow name="mod_commands.version"/>
+        <!-- Needed so the test can invoke the dump API itself. -->
+        <allow name="mod_commands.interface_allowlist_dump"/>
+      </interface-allowlist>
+    </configuration>
+
+    <configuration name="timezones.conf" description="Timezones">
+      <timezones>
+          <zone name="GMT" value="GMT0" />
+      </timezones>
+    </configuration>
+  </section>
+
+</document>
diff --git a/src/mod/applications/mod_commands/test/test_interface_allowlist.c b/src/mod/applications/mod_commands/test/test_interface_allowlist.c
new file mode 100644
index 0000000000..d36d29a12d
--- /dev/null
+++ b/src/mod/applications/mod_commands/test/test_interface_allowlist.c
@@ -0,0 +1,143 @@
+/*
+ * FreeSWITCH Modular Media Switching Software Library / Soft-Switch Application
+ * Copyright (C) 2005-2024, Anthony Minessale II <anthm@freeswitch.org>
+ *
+ * Version: MPL 1.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is FreeSWITCH Modular Media Switching Software Library / Soft-Switch Application
+ *
+ * Contributor(s):
+ * Chris Rienzo <chris@signalwire.com>
+ *
+ * test_interface_allowlist -- tests the switch.conf.xml <interface-allowlist>
+ *
+ * The core is booted with conf_interface_allowlist/freeswitch.xml, whose allowlist permits only
+ * a couple of mod_commands interfaces. mod_commands is then loaded and we verify that unlisted /
+ * dangerous commands are refused registration while listed ones load, that a "module.name.type"
+ * entry gates by interface type, and that interface_allowlist_dump prints the config format.
+ *
+ */
+
+#include <test/switch_test.h>
+#include <string.h>
+
+FST_CORE_BEGIN("conf_interface_allowlist")
+{
+	FST_MODULE_BEGIN(mod_commands, interface_allowlist_test)
+	{
+		FST_SETUP_BEGIN()
+		{
+		}
+		FST_SETUP_END()
+
+		/* Listed commands register and run; unlisted (including the shell-exec commands) are refused.
+		   A refused command is not in the api hash, so switch_api_execute returns FALSE without ever
+		   invoking the command function (empty arg means system/spawn would only print usage anyway). */
+		FST_TEST_BEGIN(allowlist_blocks_unlisted_apis)
+		{
+			switch_stream_handle_t stream = { 0 };
+
+			/* allowed: mod_commands.status.api */
+			SWITCH_STANDARD_STREAM(stream);
+			fst_check(switch_api_execute("status", "", NULL, &stream) == SWITCH_STATUS_SUCCESS);
+			switch_safe_free(stream.data);
+
+			/* allowed: mod_commands.version */
+			SWITCH_STANDARD_STREAM(stream);
+			fst_check(switch_api_execute("version", "", NULL, &stream) == SWITCH_STATUS_SUCCESS);
+			switch_safe_free(stream.data);
+
+			/* blocked: the shell-exec API this whole feature exists to disable */
+			SWITCH_STANDARD_STREAM(stream);
+			fst_check(switch_api_execute("system", "", NULL, &stream) == SWITCH_STATUS_FALSE);
+			switch_safe_free(stream.data);
+
+			/* blocked: spawn family */
+			SWITCH_STANDARD_STREAM(stream);
+			fst_check(switch_api_execute("spawn", "", NULL, &stream) == SWITCH_STATUS_FALSE);
+			switch_safe_free(stream.data);
+
+			/* blocked: an ordinary unlisted command, proving default-deny once the list is active */
+			SWITCH_STANDARD_STREAM(stream);
+			fst_check(switch_api_execute("uptime", "", NULL, &stream) == SWITCH_STATUS_FALSE);
+			switch_safe_free(stream.data);
+		}
+		FST_TEST_END()
+
+		/* "mod_commands.status.api" permits only the API type; the JSON API of the same name stays blocked. */
+		FST_TEST_BEGIN(allowlist_type_qualified_entry)
+		{
+			switch_stream_handle_t stream = { 0 };
+			cJSON *jcmd, *reply = NULL;
+			switch_status_t status;
+
+			/* API "status" is permitted. */
+			SWITCH_STANDARD_STREAM(stream);
+			fst_check(switch_api_execute("status", "", NULL, &stream) == SWITCH_STATUS_SUCCESS);
+			switch_safe_free(stream.data);
+
+			/* JSON API "status" is NOT permitted (only the .api type was allowed) -> not found. */
+			jcmd = cJSON_Parse("{\"command\":\"status\",\"data\":\"\"}");
+			fst_requires(jcmd);
+			status = switch_json_api_execute(jcmd, NULL, &reply);
+			fst_check(status == SWITCH_STATUS_FALSE);
+			if (reply) {
+				cJSON_Delete(reply);
+			}
+			cJSON_Delete(jcmd);
+		}
+		FST_TEST_END()
+
+		/* interface_allowlist_dump emits keys in the config format. It reflects module capabilities
+		   (every interface the module registered), independent of what enforcement blocked -- that is
+		   what makes it useful for generating a starting allowlist. */
+		FST_TEST_BEGIN(interface_allowlist_dump_format)
+		{
+			switch_stream_handle_t stream = { 0 };
+
+			/* Default: XML, one fully qualified entry per interface. */
+			SWITCH_STANDARD_STREAM(stream);
+			fst_check(switch_api_execute("interface_allowlist_dump", "", NULL, &stream) == SWITCH_STATUS_SUCCESS);
+			fst_requires(stream.data);
+			fst_check(strstr((char *) stream.data, "<interface-allowlist>") != NULL);
+			fst_check(strstr((char *) stream.data, "mod_commands.status.api") != NULL);
+			fst_check(strstr((char *) stream.data, "mod_commands.status.json_api") != NULL);
+			/* system is present in the dump even though it was blocked from registering. */
+			fst_check(strstr((char *) stream.data, "mod_commands.system.api") != NULL);
+			switch_safe_free(stream.data);
+
+			/* "modules" variant: one entry per module. */
+			SWITCH_STANDARD_STREAM(stream);
+			fst_check(switch_api_execute("interface_allowlist_dump", "modules", NULL, &stream) == SWITCH_STATUS_SUCCESS);
+			fst_requires(stream.data);
+			fst_check(strstr((char *) stream.data, "<allow name=\"mod_commands\"/>") != NULL);
+			switch_safe_free(stream.data);
+
+			/* "plain" variant: raw keys, no XML wrapper. */
+			SWITCH_STANDARD_STREAM(stream);
+			fst_check(switch_api_execute("interface_allowlist_dump", "plain", NULL, &stream) == SWITCH_STATUS_SUCCESS);
+			fst_requires(stream.data);
+			fst_check(strstr((char *) stream.data, "<interface-allowlist>") == NULL);
+			fst_check(strstr((char *) stream.data, "mod_commands.version.api") != NULL);
+			switch_safe_free(stream.data);
+		}
+		FST_TEST_END()
+
+		FST_TEARDOWN_BEGIN()
+		{
+		}
+		FST_TEARDOWN_END()
+	}
+	FST_MODULE_END()
+}
+FST_CORE_END()
diff --git a/src/switch_loadable_module.c b/src/switch_loadable_module.c
index bacd2f012a..ff95f9bf3d 100644
--- a/src/switch_loadable_module.c
+++ b/src/switch_loadable_module.c
@@ -98,6 +98,8 @@ struct switch_loadable_module_container {
 	switch_hash_t *limit_hash;
 	switch_hash_t *database_hash;
 	switch_hash_t *secondary_recover_hash;
+	switch_hash_t *interface_allowlist;
+	switch_bool_t interface_allowlist_enabled;
 	switch_mutex_t *mutex;
 	switch_thread_rwlock_t *chat_rwlock;
 	switch_memory_pool_t *pool;
@@ -153,6 +155,57 @@ static void switch_loadable_module_runtime(void)
 	switch_mutex_unlock(loadable_modules.mutex);
 }

+/*
+   When an interface allowlist is configured in switch.conf.xml, only interfaces that are
+   explicitly permitted are allowed to register. An <allow> entry may be written at three
+   levels of precision (an interface is permitted if it matches ANY configured entry):
+
+     "mod_commands"            - every interface in the module
+     "mod_commands.system"     - any interface named "system" (type omitted matches all)
+     "mod_commands.system.api" - only the given interface TYPE named "system"
+
+   The optional trailing type token is one of: "app" (dialplan application), "api",
+   "json_api", "chat_app" (chat application). This lets you permit, say, the APP named
+   "system" while still blocking an API or JSON API of the same name. With no allowlist
+   configured, everything is permitted (nothing is enforced).
+*/
+static switch_bool_t switch_loadable_module_interface_allowed(const char *modname, const char *name, const char *type)
+{
+	char full[512];
+
+	/* No allowlist configured: enforce nothing. */
+	if (!loadable_modules.interface_allowlist_enabled) {
+		return SWITCH_TRUE;
+	}
+
+	if (zstr(modname)) {
+		return SWITCH_TRUE;
+	}
+
+	/* Whole module permitted, e.g. "mod_commands". */
+	if (switch_core_hash_find(loadable_modules.interface_allowlist, modname)) {
+		return SWITCH_TRUE;
+	}
+
+	if (!zstr(name)) {
+		/* Any type of this name permitted, e.g. "mod_commands.system". */
+		switch_snprintf(full, sizeof(full), "%s.%s", modname, name);
+		if (switch_core_hash_find(loadable_modules.interface_allowlist, full)) {
+			return SWITCH_TRUE;
+		}
+
+		/* Only this specific type of this name permitted, e.g. "mod_commands.system.api". */
+		if (!zstr(type)) {
+			switch_snprintf(full, sizeof(full), "%s.%s.%s", modname, name, type);
+			if (switch_core_hash_find(loadable_modules.interface_allowlist, full)) {
+				return SWITCH_TRUE;
+			}
+		}
+	}
+
+	return SWITCH_FALSE;
+}
+
 static switch_status_t switch_loadable_module_process(char *key, switch_loadable_module_t *new_module, switch_hash_t *event_hash)
 {
 	switch_event_t *event;
@@ -325,6 +378,8 @@ static switch_status_t switch_loadable_module_process(char *key, switch_loadable
 		for (ptr = new_module->module_interface->application_interface; ptr; ptr = ptr->next) {
 			if (!ptr->interface_name) {
 				switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_CRIT, "Failed to load application interface from %s due to no interface name.\n", key);
+			} else if (!switch_loadable_module_interface_allowed(key, ptr->interface_name, "app")) {
+				switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_WARNING, "Skipping Application '%s' from %s: not permitted by interface allowlist\n", ptr->interface_name, key);
 			} else {
 				switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_NOTICE, "Adding Application '%s'\n", ptr->interface_name);
 				if (switch_event_create(&event, SWITCH_EVENT_MODULE_LOAD) == SWITCH_STATUS_SUCCESS) {
@@ -355,6 +410,8 @@ static switch_status_t switch_loadable_module_process(char *key, switch_loadable
 		for (ptr = new_module->module_interface->chat_application_interface; ptr; ptr = ptr->next) {
 			if (!ptr->interface_name) {
 				switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_CRIT, "Failed to load application interface from %s due to no interface name.\n", key);
+			} else if (!switch_loadable_module_interface_allowed(key, ptr->interface_name, "chat_app")) {
+				switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_WARNING, "Skipping Chat Application '%s' from %s: not permitted by interface allowlist\n", ptr->interface_name, key);
 			} else {
 				switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_NOTICE, "Adding Chat Application '%s'\n", ptr->interface_name);
 				if (switch_event_create(&event, SWITCH_EVENT_MODULE_LOAD) == SWITCH_STATUS_SUCCESS) {
@@ -385,6 +442,8 @@ static switch_status_t switch_loadable_module_process(char *key, switch_loadable
 		for (ptr = new_module->module_interface->api_interface; ptr; ptr = ptr->next) {
 			if (!ptr->interface_name) {
 				switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_CRIT, "Failed to load api interface from %s due to no interface name.\n", key);
+			} else if (!switch_loadable_module_interface_allowed(key, ptr->interface_name, "api")) {
+				switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_WARNING, "Skipping API '%s' from %s: not permitted by interface allowlist\n", ptr->interface_name, key);
 			} else {
 				switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_NOTICE, "Adding API Function '%s'\n", ptr->interface_name);
 				if (switch_event_create(&event, SWITCH_EVENT_MODULE_LOAD) == SWITCH_STATUS_SUCCESS) {
@@ -415,6 +474,8 @@ static switch_status_t switch_loadable_module_process(char *key, switch_loadable
 		for (ptr = new_module->module_interface->json_api_interface; ptr; ptr = ptr->next) {
 			if (!ptr->interface_name) {
 				switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_CRIT, "Failed to load JSON api interface from %s due to no interface name.\n", key);
+			} else if (!switch_loadable_module_interface_allowed(key, ptr->interface_name, "json_api")) {
+				switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_WARNING, "Skipping JSON API '%s' from %s: not permitted by interface allowlist\n", ptr->interface_name, key);
 			} else {
 				switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_NOTICE, "Adding JSON API Function '%s'\n", ptr->interface_name);
 				if (switch_event_create(&event, SWITCH_EVENT_MODULE_LOAD) == SWITCH_STATUS_SUCCESS) {
@@ -1984,6 +2045,91 @@ SWITCH_DECLARE(switch_status_t) switch_loadable_module_enumerate_loaded(switch_m
 	return SWITCH_STATUS_SUCCESS;
 }

+static void dump_allowlist_entry(switch_stream_handle_t *stream, switch_bool_t xml, const char *modname, const char *name, const char *type)
+{
+	if (xml) {
+		stream->write_function(stream, "  <allow name=\"%s.%s.%s\"/>\n", modname, name, type);
+	} else {
+		stream->write_function(stream, "%s.%s.%s\n", modname, name, type);
+	}
+}
+
+/*
+   Dump every loaded module's application / api / json_api / chat-application interfaces in the
+   interface-allowlist key format, so the current state can be captured and pruned offline into a
+   switch.conf.xml <interface-allowlist> section. Keys use module->key (the same name enforcement
+   matches on). by_module emits one entry per module ("mod_commands"); otherwise one fully
+   qualified entry per interface ("mod_commands.system.api"). xml wraps entries as <allow/> tags.
+*/
+SWITCH_DECLARE(void) switch_loadable_module_dump_interface_allowlist(switch_stream_handle_t *stream, switch_bool_t by_module, switch_bool_t xml)
+{
+	switch_hash_index_t *hi;
+	void *val;
+	switch_loadable_module_t *module;
+
+	if (xml) {
+		stream->write_function(stream, "<!-- generated by 'interface_allowlist_dump'; prune entries you want to block, then paste into switch.conf.xml -->\n");
+		stream->write_function(stream, "<interface-allowlist>\n");
+	}
+
+	switch_mutex_lock(loadable_modules.mutex);
+	for (hi = switch_core_hash_first(loadable_modules.module_hash); hi; hi = switch_core_hash_next(&hi)) {
+		switch_core_hash_this(hi, NULL, NULL, &val);
+		module = (switch_loadable_module_t *) val;
+
+		if (!module || zstr(module->key)) {
+			continue;
+		}
+
+		if (by_module) {
+			if (xml) {
+				stream->write_function(stream, "  <allow name=\"%s\"/>\n", module->key);
+			} else {
+				stream->write_function(stream, "%s\n", module->key);
+			}
+			continue;
+		}
+
+		if (!module->module_interface) {
+			continue;
+		}
+
+		if (xml) {
+			stream->write_function(stream, "  <!-- %s -->\n", module->key);
+		}
+
+		{
+			const switch_application_interface_t *ptr;
+			for (ptr = module->module_interface->application_interface; ptr; ptr = ptr->next) {
+				if (ptr->interface_name) dump_allowlist_entry(stream, xml, module->key, ptr->interface_name, "app");
+			}
+		}
+		{
+			const switch_api_interface_t *ptr;
+			for (ptr = module->module_interface->api_interface; ptr; ptr = ptr->next) {
+				if (ptr->interface_name) dump_allowlist_entry(stream, xml, module->key, ptr->interface_name, "api");
+			}
+		}
+		{
+			const switch_json_api_interface_t *ptr;
+			for (ptr = module->module_interface->json_api_interface; ptr; ptr = ptr->next) {
+				if (ptr->interface_name) dump_allowlist_entry(stream, xml, module->key, ptr->interface_name, "json_api");
+			}
+		}
+		{
+			const switch_chat_application_interface_t *ptr;
+			for (ptr = module->module_interface->chat_application_interface; ptr; ptr = ptr->next) {
+				if (ptr->interface_name) dump_allowlist_entry(stream, xml, module->key, ptr->interface_name, "chat_app");
+			}
+		}
+	}
+	switch_mutex_unlock(loadable_modules.mutex);
+
+	if (xml) {
+		stream->write_function(stream, "</interface-allowlist>\n");
+	}
+}
+
 SWITCH_DECLARE(switch_status_t) switch_loadable_module_build_dynamic(char *filename,
 																	 switch_module_load_t switch_module_load,
 																	 switch_module_runtime_t switch_module_runtime,
@@ -2136,7 +2282,46 @@ SWITCH_DECLARE(switch_status_t) switch_loadable_module_init(switch_bool_t autolo
 	switch_core_hash_init(&loadable_modules.secondary_recover_hash);
 	switch_mutex_init(&loadable_modules.mutex, SWITCH_MUTEX_NESTED, loadable_modules.pool);
 	switch_thread_rwlock_create(&loadable_modules.chat_rwlock, loadable_modules.pool);
-
+
+	/*
+	   Build the optional interface allowlist from switch.conf.xml. When at least one <allow>
+	   entry is present, ONLY the permitted modules (e.g. "mod_commands") or specific interfaces
+	   (e.g. "mod_commands.system") may register application/api/json_api/chat-application
+	   interfaces; every other such interface is refused at load time. With no allowlist
+	   configured, nothing is enforced (all interfaces load as before). Built before the
+	   autoload check so runtime module loads are subject to the same policy.
+	*/
+	switch_core_hash_init_nocase(&loadable_modules.interface_allowlist);
+	loadable_modules.interface_allowlist_enabled = SWITCH_FALSE;
+	{
+		switch_xml_t cfg_al = NULL, xml_al = NULL, list = NULL, item = NULL;
+		if ((xml_al = switch_xml_open_cfg("switch.conf", &cfg_al, NULL))) {
+			if ((list = switch_xml_child(cfg_al, "interface-allowlist"))) {
+				for (item = switch_xml_child(list, "allow"); item; item = item->next) {
+					const char *aname = switch_xml_attr_soft(item, "name");
+					if (!zstr(aname)) {
+						char *entry = switch_core_strdup(loadable_modules.pool, aname);
+						switch_core_hash_insert(loadable_modules.interface_allowlist, entry, entry);
+						loadable_modules.interface_allowlist_enabled = SWITCH_TRUE;
+					}
+				}
+				if (!loadable_modules.interface_allowlist_enabled) {
+					/* Section present but nothing usable parsed (e.g. a typo'd tag or missing name= attribute).
+					   Enforcement is boot-only, so a silent miss would leave the box unhardened with no runtime
+					   safety net; make the misconfiguration loud instead of failing open quietly. */
+					switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_ERROR,
+									  "Interface allowlist section <interface-allowlist> is present but no valid <allow name=\"...\"/> "
+									  "entries were found; enforcement is DISABLED. Check for typos (lowercase <allow> with a name= attribute).\n");
+				}
+			}
+			switch_xml_free(xml_al);
+		}
+		if (loadable_modules.interface_allowlist_enabled) {
+			switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_WARNING,
+							  "Interface allowlist ACTIVE: only explicitly permitted application/api/json_api interfaces will load.\n");
+		}
+	}
+
 	if (!autoload) return SWITCH_STATUS_SUCCESS;

 	/*
@@ -2472,6 +2657,7 @@ SWITCH_DECLARE(void) switch_loadable_module_shutdown(void)
 	switch_core_hash_destroy(&loadable_modules.database_hash);
 	switch_core_hash_destroy(&loadable_modules.dialplan_hash);
 	switch_core_hash_destroy(&loadable_modules.secondary_recover_hash);
+	switch_core_hash_destroy(&loadable_modules.interface_allowlist);

 	switch_core_destroy_memory_pool(&loadable_modules.pool);
 }