Commit 46292c9a45 for asterisk.org

commit 46292c9a456a339ab53a1ecbecb32a53bf3c981d
Author: Mike Bradeen <mbradeen@sangoma.com>
Date:   Fri Feb 27 12:35:37 2026 -0700

    acl: Add ACL support to http and ari

    Add uri prefix based acl support to the built in http server.
    This allows an acl to be added per uri prefix (ie '/metrics'
    or '/ws') to restrict access.

    Add user based acl support for ARI. This adds new acl options
    to the user section of ari.conf to restrict access on a per
    user basis.

    resolves: #1799

    UserNote: A new section, type=restriction has been added to http.conf
    to allow an uri prefix based acl to be configured. See
    http.conf.sample for examples and more information.
    The user section of ari.conf can now contain an acl configuration
    to restrict users access. See ari.conf.sample for examples and more
    information

diff --git a/configs/samples/ari.conf.sample b/configs/samples/ari.conf.sample
index e50eb39fe5..04973e10b4 100644
--- a/configs/samples/ari.conf.sample
+++ b/configs/samples/ari.conf.sample
@@ -35,6 +35,22 @@ enabled = yes       ; When set to no, ARI support is disabled.
 ; When set to plain, the password is in plaintext.
 ;
 ;password_format = plain
+;
+; The following three options (permit, deny, acl) allow for a per-user acl to be
+; configured.  The format follows the rules as documented in acl.conf.sample
+;
+; If no restriction is defined for a given user, no IPs will be blocked by
+; Asterisk (legacy behavior).
+;
+;deny =             ; Deny acces from the subnet for the given user
+;permit =           ; Permit access from the subnet(s) for the given user
+;acl =              ; Optional name for the acl.
+;
+;Example:
+;deny = 0.0.0.0/0
+;permit = 127.0.0.1,10.0.0.0/24
+;acl = localasteriskuser
+;

 ; Outbound Websocket Connections
 ;
diff --git a/configs/samples/http.conf.sample b/configs/samples/http.conf.sample
index bd9794c5a9..c1c38b21ca 100644
--- a/configs/samples/http.conf.sample
+++ b/configs/samples/http.conf.sample
@@ -130,3 +130,41 @@ bindaddr=127.0.0.1
 ; POST URL: /asterisk/uploads will put files in /var/lib/asterisk/uploads/.
 ;uploads = /var/lib/asterisk/uploads/
 ;
+
+;[uripath]
+;
+;type = restriction ; Specifies acl configuration
+;
+; The following options (permit, deny, acl) allow for an acl to be configured
+; on a per uri prefix basis. The first character should be an '/'
+;
+; The format follows the rules as documented in acl.conf.sample
+;
+; If no restriction is defined for a given prefix, legacy behavior will apply.
+;
+; If multiple restrictions apply, any restriction that denies will supersede
+; any another restrictions that permit.  For example if an /ari restriction
+; results in a deny, but an /ari/channels restriction would permit, the
+; attempt would still be denied.
+;
+;deny =             ; Deny the subnet access for the given user
+;permit =           ; Permit the subnet(s) access for the given user
+;acl =              ; Optional name for the acl.
+;
+;Examples:
+;
+; Only allow ari connections from localhost:
+;
+;[/ari]
+;type = restriction
+;deny = 0.0.0.0/0
+;permit = 127.0.0.1
+;acl = localarionly
+;
+; Only allow metrics to be gathered by 10.0.0.23
+;
+;[/metrics]
+;type = restriction
+;deny = 0.0.0.0/0
+;permit = 10.0.0.23
+;
\ No newline at end of file
diff --git a/main/http.c b/main/http.c
index 9d7ae3d6aa..37d4d08a7e 100644
--- a/main/http.c
+++ b/main/http.c
@@ -51,6 +51,7 @@
 #include <fcntl.h>

 #include "asterisk/paths.h"	/* use ast_config_AST_DATA_DIR */
+#include "asterisk/acl.h"
 #include "asterisk/cli.h"
 #include "asterisk/tcptls.h"
 #include "asterisk/http.h"
@@ -177,6 +178,19 @@ struct http_uri_redirect {

 static AST_RWLIST_HEAD_STATIC(uri_redirects, http_uri_redirect);

+/*! \brief Per-path ACL restriction */
+struct http_restriction {
+	AST_LIST_ENTRY(http_restriction) entry;
+	struct ast_acl_list *acl;
+	char path[];
+};
+
+AST_LIST_HEAD_NOLOCK(http_restriction_list, http_restriction);
+
+static AST_RWLIST_HEAD_STATIC(restrictions, http_restriction);
+
+static int check_restriction_acl(struct ast_tcptls_session_instance *ser, const char *uri);
+
 static const struct ast_cfhttp_methods_text {
 	enum ast_http_method method;
 	const char *text;
@@ -1503,6 +1517,13 @@ static int handle_uri(struct ast_tcptls_session_instance *ser, char *uri,
 		}
 	}

+	/* Check path-based ACL restrictions */
+	if (check_restriction_acl(ser, uri) != 0) {
+		ast_http_request_close_on_completion(ser);
+		ast_http_error(ser, 403, "Forbidden", "Access denied by ACL");
+		goto cleanup;
+	}
+
 	AST_RWLIST_RDLOCK(&uri_redirects);
 	AST_RWLIST_TRAVERSE(&uri_redirects, redirect, entry) {
 		if (!strcasecmp(uri, redirect->target)) {
@@ -2127,6 +2148,36 @@ done:
 	return NULL;
 }

+/*!
+ * \brief Check if a URI path is allowed or denied by acl
+ * \param ser TCP/TLS session instance
+ * \param uri The URI path to check
+ * \return 0 if allowed, -1 if denied
+ */
+static int check_restriction_acl(struct ast_tcptls_session_instance *ser, const char *uri)
+{
+	struct http_restriction *restriction;
+	int denied = 0;
+
+	AST_RWLIST_RDLOCK(&restrictions);
+	AST_RWLIST_TRAVERSE(&restrictions, restriction, entry) {
+		if (ast_begins_with(uri, restriction->path)) {
+			if (restriction->acl && !ast_acl_list_is_empty(restriction->acl)) {
+				if (ast_apply_acl(restriction->acl, &ser->remote_address,
+				    "HTTP Path ACL") == AST_SENSE_DENY) {
+					ast_debug(2, "HTTP request for uri '%s' from %s denied by acl by restriction on '%s'\n",
+						uri, ast_sockaddr_stringify(&ser->remote_address), restriction->path);
+					denied = -1;
+					break;
+				}
+			}
+		}
+	}
+	AST_RWLIST_UNLOCK(&restrictions);
+
+	return denied;
+}
+
 /*!
  * \brief Add a new URI redirect
  * The entries in the redirect list are sorted by length, just like the list
@@ -2484,10 +2535,14 @@ static int __ast_http_load(int reload)
 	char newprefix[MAX_PREFIX] = "";
 	char server_name[MAX_SERVER_NAME_LENGTH];
 	struct http_uri_redirect *redirect;
+	struct http_restriction *restriction;
+	struct http_restriction_list new_restrictions = AST_LIST_HEAD_NOLOCK_INIT_VALUE;
+	struct http_restriction_list old_restrictions = AST_LIST_HEAD_NOLOCK_INIT_VALUE;
 	struct ast_flags config_flags = { reload ? CONFIG_FLAG_FILEUNCHANGED : 0 };
 	uint32_t bindport = DEFAULT_PORT;
 	int http_tls_was_enabled = 0;
-	char *bindaddr = NULL;
+	const char *bindaddr = NULL;
+	const char *cat = NULL;

 	cfg = ast_config_load2("http.conf", "http", config_flags);
 	if (!cfg || cfg == CONFIG_STATUS_FILEINVALID) {
@@ -2602,6 +2657,61 @@ static int __ast_http_load(int reload)
 		}
 	}

+	while ((cat = ast_category_browse(cfg, cat))) {
+		const char *type;
+		struct http_restriction *new_restriction;
+		struct ast_acl_list *acl = NULL;
+		int acl_error = 0;
+		int acl_subscription_flag = 0;
+
+		if (strcasecmp(cat, "general") == 0) {
+			continue;
+		}
+
+		type = ast_variable_retrieve(cfg, cat, "type");
+		if (!type || strcasecmp(type, "restriction") != 0) {
+			continue;
+		}
+
+		new_restriction = ast_calloc(1, sizeof(*new_restriction) + strlen(cat) + 1);
+		if (!new_restriction) {
+			continue;
+		}
+
+		/* Safe */
+		strcpy(new_restriction->path, cat);
+
+		/* Parse ACL options for this restriction */
+		for (v = ast_variable_browse(cfg, cat); v; v = v->next) {
+			if (!strcasecmp(v->name, "permit") ||
+				!strcasecmp(v->name, "deny") ||
+				!strcasecmp(v->name, "acl")) {
+				ast_append_acl(v->name, v->value, &acl, &acl_error, &acl_subscription_flag);
+				if (acl_error) {
+					ast_log(LOG_ERROR, "Bad ACL '%s' at line '%d' of http.conf for restriction '%s'\n",
+						v->value, v->lineno, cat);
+				}
+			}
+		}
+
+		new_restriction->acl = acl;
+
+		AST_LIST_INSERT_TAIL(&new_restrictions, new_restriction, entry);
+		ast_debug(2, "HTTP: Added restriction for path '%s'\n", cat);
+	}
+
+	AST_RWLIST_WRLOCK(&restrictions);
+	AST_RWLIST_APPEND_LIST(&old_restrictions, &restrictions, entry);
+	AST_RWLIST_APPEND_LIST(&restrictions, &new_restrictions, entry);
+	AST_RWLIST_UNLOCK(&restrictions);
+
+	while ((restriction = AST_LIST_REMOVE_HEAD(&old_restrictions, entry))) {
+		if (restriction->acl) {
+			ast_free_acl_list(restriction->acl);
+		}
+		ast_free(restriction);
+	}
+
 	ast_config_destroy(cfg);

 	if (strcmp(prefix, newprefix)) {
@@ -2711,13 +2821,31 @@ static char *handle_show_http(struct ast_cli_entry *e, int cmd, struct ast_cli_a

 	ast_cli(a->fd, "\nEnabled Redirects:\n");
 	AST_RWLIST_RDLOCK(&uri_redirects);
-	AST_RWLIST_TRAVERSE(&uri_redirects, redirect, entry)
-		ast_cli(a->fd, "  %s => %s\n", redirect->target, redirect->dest);
 	if (AST_RWLIST_EMPTY(&uri_redirects)) {
 		ast_cli(a->fd, "  None.\n");
+	} else {
+		AST_RWLIST_TRAVERSE(&uri_redirects, redirect, entry)
+			ast_cli(a->fd, "  %s => %s\n", redirect->target, redirect->dest);
 	}
 	AST_RWLIST_UNLOCK(&uri_redirects);

+	ast_cli(a->fd, "\nPath Restrictions:\n");
+	AST_RWLIST_RDLOCK(&restrictions);
+	if (AST_RWLIST_EMPTY(&restrictions)) {
+		ast_cli(a->fd, "  None.\n");
+	} else {
+		struct http_restriction *restriction;
+		AST_RWLIST_TRAVERSE(&restrictions, restriction, entry) {
+			ast_cli(a->fd, "  Path: %s\n", restriction->path);
+			if (restriction->acl && !ast_acl_list_is_empty(restriction->acl)) {
+				ast_acl_output(a->fd, restriction->acl, "    ");
+			} else {
+				ast_cli(a->fd, "    No ACL configured\n");
+			}
+		}
+	}
+	AST_RWLIST_UNLOCK(&restrictions);
+
 	return CLI_SUCCESS;
 }

@@ -2733,6 +2861,7 @@ static struct ast_cli_entry cli_http[] = {
 static int unload_module(void)
 {
 	struct http_uri_redirect *redirect;
+	struct http_restriction *restriction;
 	ast_cli_unregister_multiple(cli_http, ARRAY_LEN(cli_http));

 	ao2_cleanup(global_http_server);
@@ -2760,6 +2889,15 @@ static int unload_module(void)
 	}
 	AST_RWLIST_UNLOCK(&uri_redirects);

+	AST_RWLIST_WRLOCK(&restrictions);
+	while ((restriction = AST_RWLIST_REMOVE_HEAD(&restrictions, entry))) {
+		if (restriction->acl) {
+			ast_free_acl_list(restriction->acl);
+		}
+		ast_free(restriction);
+	}
+	AST_RWLIST_UNLOCK(&restrictions);
+
 	return 0;
 }

diff --git a/res/ari/ari_doc.xml b/res/ari/ari_doc.xml
index f897ecb0f3..dd7e54cdfa 100644
--- a/res/ari/ari_doc.xml
+++ b/res/ari/ari_doc.xml
@@ -103,6 +103,45 @@
 					</since>
 					<synopsis>password_format may be set to plain (the default) or crypt. When set to crypt, crypt(3) is used to validate the password. A crypted password can be generated using mkpasswd -m sha-512. When set to plain, the password is in plaintext</synopsis>
 				</configOption>
+				<configOption name="acl">
+					<since>
+						<version>20.19.0</version>
+						<version>22.9.0</version>
+						<version>23.3.0</version>
+					</since>
+					<synopsis>List of IP ACL section names in acl.conf</synopsis>
+					<description><para>
+						This matches sections configured in <literal>acl.conf</literal>.
+					</para></description>
+				</configOption>
+				<configOption name="deny">
+					<since>
+						<version>20.19.0</version>
+						<version>22.9.0</version>
+						<version>23.3.0</version>
+					</since>
+					<synopsis>List of IP addresses to deny access from</synopsis>
+					<description><para>
+						The value is a comma-delimited list of IP addresses. IP addresses may
+						have a subnet mask appended. The subnet mask may be written in either
+						CIDR or dotted-decimal notation. Separate the IP address and subnet
+						mask with a slash ('/')
+					</para></description>
+				</configOption>
+				<configOption name="permit">
+					<since>
+						<version>20.19.0</version>
+						<version>22.9.0</version>
+						<version>23.3.0</version>
+					</since>
+					<synopsis>List of IP addresses to permit access from</synopsis>
+					<description><para>
+						The value is a comma-delimited list of IP addresses. IP addresses may
+						have a subnet mask appended. The subnet mask may be written in either
+						CIDR or dotted-decimal notation. Separate the IP address and subnet
+						mask with a slash ('/')
+					</para></description>
+				</configOption>
 			</configObject>
 			<configObject name="outbound_websocket">
 				<since>
diff --git a/res/ari/cli.c b/res/ari/cli.c
index 30c5f45c4a..c548e7a52e 100644
--- a/res/ari/cli.c
+++ b/res/ari/cli.c
@@ -78,8 +78,9 @@ static int show_users_cb(void *obj, void *arg, int flags)
 	struct ari_conf_user *user = obj;
 	struct ast_cli_args *a = arg;

-	ast_cli(a->fd, "%-4s  %s\n",
+	ast_cli(a->fd, "%-4s  %-4s  %s\n",
 		AST_CLI_YESNO(user->read_only),
+		AST_CLI_YESNO(user->acl && !ast_acl_list_is_empty(user->acl)),
 		ast_sorcery_object_get_id(user));
 	return 0;
 }
@@ -112,8 +113,8 @@ static char *ari_show_users(struct ast_cli_entry *e, int cmd,
 		return CLI_FAILURE;
 	}

-	ast_cli(a->fd, "r/o?  Username\n");
-	ast_cli(a->fd, "----  --------\n");
+	ast_cli(a->fd, "r/o?  ACL?  Username\n");
+	ast_cli(a->fd, "----  ----  --------\n");

 	ao2_callback(users, OBJ_NODATA, show_users_cb, a);

@@ -173,6 +174,10 @@ static char *ari_show_user(struct ast_cli_entry *e, int cmd, struct ast_cli_args

 	ast_cli(a->fd, "Username: %s\n", ast_sorcery_object_get_id(user));
 	ast_cli(a->fd, "Read only?: %s\n", AST_CLI_YESNO(user->read_only));
+	ast_cli(a->fd, "ACL?: %s\n", AST_CLI_YESNO(user->acl && !ast_acl_list_is_empty(user->acl)));
+	if (!ast_acl_list_is_empty(user->acl)) {
+		ast_acl_output(a->fd, user->acl, NULL);
+	}

 	return CLI_SUCCESS;
 }
diff --git a/res/ari/config.c b/res/ari/config.c
index 56fe4fc411..6575ef7a66 100644
--- a/res/ari/config.c
+++ b/res/ari/config.c
@@ -505,6 +505,7 @@ static void user_dtor(void *obj)
 {
 	struct ari_conf_user *user = obj;
 	ast_string_field_free_memory(user);
+	user->acl = ast_free_acl_list(user->acl);
 	ast_debug(3, "%s: Disposing of user\n", ast_sorcery_object_get_id(user));
 }

@@ -558,6 +559,23 @@ static int user_password_format_from_str(const struct aco_option *opt,
 	return 0;
 }

+/*! \brief Handler for user ACL options */
+static int user_acl_handler(const struct aco_option *opt,
+	struct ast_variable *var, void *obj)
+{
+	struct ari_conf_user *user = obj;
+	int error = 0;
+	int ignore;
+
+	ast_append_acl(var->name, var->value, &user->acl, &error, &ignore);
+	if (error) {
+		ast_log(LOG_ERROR, "Bad ACL '%s' at line '%d' of ari.conf\n",
+			var->value, var->lineno);
+	}
+
+	return error;
+}
+
 static int user_password_format_to_str(const void *obj, const intptr_t *args, char **buf)
 {
 	const struct ari_conf_user *user = obj;
@@ -729,6 +747,9 @@ static int ari_conf_init(void)
 	ast_sorcery_register_sf(user, ari_conf_user, password, password, "");
 	ast_sorcery_register_bool(user, ari_conf_user, read_only, read_only, "no");
 	ast_sorcery_register_cust(user, password_format, "plain");
+	ast_sorcery_object_field_register_custom(sorcery, "user", "permit", "", user_acl_handler, NULL, NULL, 0, 0);
+	ast_sorcery_object_field_register_custom(sorcery, "user", "deny", "", user_acl_handler, NULL, NULL, 0, 0);
+	ast_sorcery_object_field_register_custom(sorcery, "user", "acl", "", user_acl_handler, NULL, NULL, 0, 0);

 	ast_sorcery_object_field_register(sorcery, "outbound_websocket", "type", "", OPT_NOOP_T, 0, 0);
 	ast_sorcery_register_cust(outbound_websocket, websocket_client_id, "");
diff --git a/res/ari/internal.h b/res/ari/internal.h
index 2a5850f468..abc3381cd0 100644
--- a/res/ari/internal.h
+++ b/res/ari/internal.h
@@ -25,6 +25,7 @@
  * \author David M. Lee, II <dlee@digium.com>
  */

+#include "asterisk/acl.h"
 #include "asterisk/http.h"
 #include "asterisk/json.h"
 #include "asterisk/md5.h"
@@ -91,6 +92,8 @@ struct ari_conf_user {
 	enum ari_user_password_format password_format;
 	/*! If true, user cannot execute change operations */
 	int read_only;
+	/*! ACL setting */
+	struct ast_acl_list *acl;
 };

 enum ari_conf_owc_fields {
diff --git a/res/res_ari.c b/res/res_ari.c
index cb0a7248df..c38451be6f 100644
--- a/res/res_ari.c
+++ b/res/res_ari.c
@@ -575,6 +575,11 @@ enum ast_ari_invoke_result ast_ari_invoke(struct ast_tcptls_session_instance *se
 			general->auth_realm);
 		SCOPE_EXIT_RTN_VALUE(ARI_INVOKE_RESULT_ERROR_CONTINUE, "Response: %d : %s\n",
 			response->response_code, response->response_text);
+	} else if (user && user->acl && !ast_acl_list_is_empty(user->acl) &&
+		   ast_apply_acl(user->acl, &ser->remote_address, "ARI User ACL") == AST_SENSE_DENY) {
+		ast_ari_response_error(response, 403, "Forbidden", "Access denied by ACL");
+		SCOPE_EXIT_RTN_VALUE(ARI_INVOKE_RESULT_ERROR_CONTINUE, "Response: %d : %s\n",
+			response->response_code, response->response_text);
 	} else if (!ast_fully_booted) {
 		ast_ari_response_error(response, 503, "Service Unavailable", "Asterisk not booted");
 		SCOPE_EXIT_RTN_VALUE(ARI_INVOKE_RESULT_ERROR_CLOSE, "Response: %d : %s\n",