From 5894d7f21e3adf3e6ca2552a1855c933968b42f3 Mon Sep 17 00:00:00 2001 From: Daniel Scherzer Date: Tue, 29 Sep 2026 19:18:08 -0700 Subject: [PATCH] Add cgi.security_limit_extensions --- sapi/cgi/cgi_main.c | 63 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/sapi/cgi/cgi_main.c b/sapi/cgi/cgi_main.c index 929c3db42733..824fb151aa63 100644 --- a/sapi/cgi/cgi_main.c +++ b/sapi/cgi/cgi_main.c @@ -164,6 +164,7 @@ static const opt_struct OPTIONS[] = { typedef struct _php_cgi_globals_struct { HashTable user_config_cache; char *redirect_status_env; + char *security_limit_extensions; bool rfc2616_headers; bool nph; bool check_shebang_line; @@ -1505,6 +1506,7 @@ PHP_INI_BEGIN() STD_PHP_INI_BOOLEAN("cgi.check_shebang_line", "1", PHP_INI_SYSTEM, OnUpdateBool, check_shebang_line, php_cgi_globals_struct, php_cgi_globals) STD_PHP_INI_BOOLEAN("cgi.force_redirect", "1", PHP_INI_SYSTEM, OnUpdateBool, force_redirect, php_cgi_globals_struct, php_cgi_globals) STD_PHP_INI_ENTRY("cgi.redirect_status_env", NULL, PHP_INI_SYSTEM, OnUpdateString, redirect_status_env, php_cgi_globals_struct, php_cgi_globals) + STD_PHP_INI_ENTRY("cgi.security_limit_extensions", ".php .phar", PHP_INI_PERDIR, OnUpdateString, security_limit_extensions, php_cgi_globals_struct, php_cgi_globals) STD_PHP_INI_BOOLEAN("cgi.fix_pathinfo", "1", PHP_INI_SYSTEM, OnUpdateBool, fix_pathinfo, php_cgi_globals_struct, php_cgi_globals) STD_PHP_INI_BOOLEAN("cgi.discard_path", "0", PHP_INI_SYSTEM, OnUpdateBool, discard_path, php_cgi_globals_struct, php_cgi_globals) STD_PHP_INI_BOOLEAN("fastcgi.logging", "1", PHP_INI_SYSTEM, OnUpdateBool, fcgi_logging, php_cgi_globals_struct, php_cgi_globals) @@ -1524,6 +1526,7 @@ static void php_cgi_globals_ctor(php_cgi_globals_struct *php_cgi_globals_ptr) php_cgi_globals_ptr->check_shebang_line = 1; php_cgi_globals_ptr->force_redirect = 1; php_cgi_globals_ptr->redirect_status_env = NULL; + php_cgi_globals_ptr->security_limit_extensions = NULL; php_cgi_globals_ptr->fix_pathinfo = 1; php_cgi_globals_ptr->discard_path = 0; php_cgi_globals_ptr->fcgi_logging = 1; @@ -1684,6 +1687,61 @@ static void add_response_header(sapi_header_struct *h, zval *return_value) /* {{ } /* }}} */ +zend_result cgi_limit_extensions(char *path) { + const char *allowed_extensions = CGIG(security_limit_extensions); + if (!path || !allowed_extensions || strlen(allowed_extensions) == 0) { + // No path, or no filter configured + return SUCCESS; + } + + const char *dot = strrchr(path, '.'); + if (!dot) { + // No file extension + return FAILURE; + } + +#ifndef PHP_WIN32 + const char dir_separator = '\\'; +#else + const char dir_separator = '/'; +#endif + + if (strchr(dot, dir_separator) != NULL) { + /* Dot is followed by a directory separator at some point, so this is + * a dot in a directory name, rather than the actual file extension; + * strrchr() above means that this was the last dot, so the actual file + * has no extension. */ + return FAILURE; + } + + /* `dot` is now the pointer to the start of a null-terminated string for + * the file extension, *including* the dot, e.g. `.php`. We need that string + * to be present in the allowed extensions, and + * - preceded by either a space or tab, or be at the start of the string, + * so that `.template.php` can be allowed without allowing all `.php` + * files + * - followed by either a space or tab, or be at the end of the string, for + * the same reason, `.php.trusted` should not allow all of `.php` + */ + char *next = strstr(allowed_extensions, dot); + const size_t extension_len = strlen(dot); + while (next && *next) { + /* We have a pointer to within the allowed extensions, which is followed + * by the extension; adding extension_len will bring us to either the + * character after the extension, or the null terminating byte. */ + char *after = next + extension_len; + if ( + (next == allowed_extensions || (*(next - 1) == ' ') || (*(next - 1) == '\t')) + && (*after == '\0' || *after == ' ' || *after == '\t') + ) { + return SUCCESS; + } + next = strstr(next + 1, dot); + } + + return FAILURE; +} + PHP_FUNCTION(apache_response_headers) /* {{{ */ { ZEND_PARSE_PARAMETERS_NONE(); @@ -2475,9 +2533,10 @@ consult the installation file that came with this distribution, or visit \n\ 2. we are running as cgi or fastcgi */ if (cgi || fastcgi || SG(request_info).path_translated) { - if (php_fopen_primary_script(&file_handle) == FAILURE) { + bool limited_extension = (cgi_limit_extensions(SG(request_info).path_translated) == FAILURE); + if (limited_extension || php_fopen_primary_script(&file_handle) == FAILURE) { zend_try { - if (errno == EACCES) { + if (limited_extension || errno == EACCES) { SG(sapi_headers).http_response_code = 403; PUTS("Access denied.\n"); } else {