From 8fc71ac6af4fbcc54103bec2983ef22e82942688 Mon Sep 17 00:00:00 2001
From: Konrad Pietrzak <konrad@erlang.org>
Date: Mon, 23 Mar 2026 14:42:35 +0100
Subject: [PATCH] inets: Check script_alias when using mod_auth

---
 lib/inets/src/http_server/httpd.erl     |  8 +++
 lib/inets/src/http_server/mod_alias.erl | 10 +++-
 lib/inets/test/httpd_SUITE.erl          | 80 ++++++++++++++++++++++++-
 3 files changed, 94 insertions(+), 4 deletions(-)

Index: otp-OTP-27.1.3/lib/inets/src/http_server/httpd.erl
===================================================================
--- otp-OTP-27.1.3.orig/lib/inets/src/http_server/httpd.erl
+++ otp-OTP-27.1.3/lib/inets/src/http_server/httpd.erl
@@ -435,6 +435,14 @@ property list.
   Access to http://your.server.org/cgi-bin/foo would cause the server to run the
   script /web/cgi-bin/foo.
 
+  > #### Note {: .info }
+  >
+  > When using `script_alias` with directory-based authentication
+  > (see [`directory`](`m:httpd#prop_dri`)), ensure that authentication
+  > rules reference the actual filesystem path (RealName), not the URL path (Alias).
+  > The server correctly resolves script_alias paths for authentication checks.
+  >
+
 - [](){: #prop_script_re_write } **`{script_re_write, {Re, Replacement}}`**  
   `Re = string()` and `Replacement = string()`. Have the same behavior as
   property `re_write`, except that they also mark the target directory as
Index: otp-OTP-27.1.3/lib/inets/src/http_server/mod_alias.erl
===================================================================
--- otp-OTP-27.1.3.orig/lib/inets/src/http_server/mod_alias.erl
+++ otp-OTP-27.1.3/lib/inets/src/http_server/mod_alias.erl
@@ -309,6 +309,13 @@ store({re_write, {Re, Replacement}} = Co
     end;
 store({re_write, _} = Conf, _) ->
     {error, {wrong_type, Conf}};
+
+% When `script_alias` is used in conjunction with `m:mod_auth` for directory-based
+% access control, authentication rules are evaluated against the actual filesystem
+% path where scripts reside, not the aliased URL path. This ensures that CGI scripts
+% mapped outside the document root are properly protected by directory authentication
+% directives.
+
 store({script_alias, {Fake, Real}}, _)
   when is_list(Fake), is_list(Real) ->
     {ok, {script_alias,{"^"++Fake,Real}}};
@@ -339,7 +346,8 @@ is_directory_index_list(_) ->
 %% ---------------------------------------------------------------------
 
 which_alias(ConfigDB) ->
-    httpd_util:multi_lookup(ConfigDB, alias). 
+    httpd_util:multi_lookup(ConfigDB, alias) ++
+        httpd_util:multi_lookup(ConfigDB, script_alias).
 
 which_document_root(ConfigDB) ->
     Root = httpd_util:lookup(ConfigDB, document_root, ""),
Index: otp-OTP-27.1.3/lib/inets/test/httpd_SUITE.erl
===================================================================
--- otp-OTP-27.1.3.orig/lib/inets/test/httpd_SUITE.erl
+++ otp-OTP-27.1.3/lib/inets/test/httpd_SUITE.erl
@@ -79,6 +79,7 @@ all() ->
      {group, http_logging},
      {group, http_post},
      {group, http_rel_path_script_alias},
+     {group, http_script_alias_auth},
      {group, http_not_sup},
      {group, https_alert},
      {group, https_not_sup},
@@ -142,6 +143,7 @@ groups() ->
      {http_1_0, [], [{group, http_1_0_parallel} | load()]},
      {http_1_0_parallel, [parallel], [host, cgi, trace] ++ http_head() ++ http_get()},
      {http_rel_path_script_alias, [], [cgi]},
+     {http_script_alias_auth, [], [script_alias_auth_bypass]},
      {esi, [], [erl_script_timeout_default,
                 erl_script_timeout_option,
                 erl_script_timeout_proplist,
@@ -275,6 +277,9 @@ init_per_group(http_logging, Config) ->
 init_per_group(http_rel_path_script_alias = Group, Config) ->
     ok = start_apps(Group),
     init_httpd(Group, [{type, ip_comm},{http_version, "HTTP/1.1"}| Config]);
+init_per_group(http_script_alias_auth = Group, Config) ->
+    ok = start_apps(Group),
+    init_httpd(Group, [{type, ip_comm},{http_version, "HTTP/1.1"}| Config]);
 init_per_group(not_sup, Config) ->
     [{http_version, "HTTP/1.1"} | Config];
 init_per_group(Group, Config) when Group == esi ->
@@ -296,6 +301,7 @@ end_per_group(Group, _Config)  when  Gro
                                      Group == http_mime_type;
 				     Group == http_mime_and_default_type;
                                      Group == http_mime_types;
+                                     Group == http_script_alias_auth;
                                      Group == esi
 				     ->
     inets:stop();
@@ -1139,6 +1145,34 @@ cgi(Config) when is_list(Config) ->
 		     [{statuscode, 200},
 		      {no_header, "cache-control"}]).
 %%-------------------------------------------------------------------------
+script_alias_auth_bypass() ->
+    [{doc, "Test that mod_auth correctly protects script_alias directories "
+           "outside DocumentRoot (CVE-2026-28808)"}].
+script_alias_auth_bypass(Config) when is_list(Config) ->
+    Version = proplists:get_value(http_version, Config),
+    Host = proplists:get_value(host, Config),
+    Script =
+        case os:type() of
+            {win32, _} -> "printenv.bat";
+            _ -> "printenv.sh"
+        end,
+    %% Unauthenticated request must be rejected with 401
+    ok = http_status("GET /http_script_alias_auth/" ++ Script ++ " ", Config,
+                     [{statuscode, 401},
+                      {header, "WWW-Authenticate"}]),
+    %% Authenticated request must succeed
+    ok = auth_status(
+           auth_request("/http_script_alias_auth/" ++ Script, "one", "onePassword",
+                        Version, Host),
+           Config,
+           [{statuscode, 200}]),
+    %% Wrong password must be rejected
+    ok = auth_status(
+           auth_request("/http_script_alias_auth/" ++ Script, "one", "WrongPassword",
+                        Version, Host),
+           Config,
+           [{statuscode, 401}]).
+%%-------------------------------------------------------------------------
 cgi_chunked_encoding_test() ->  
     [{doc, "Test chunked encoding together with mod_cgi "}].
 cgi_chunked_encoding_test(Config) when is_list(Config) ->
@@ -2075,6 +2109,7 @@ do_max_clients(Config) ->
 
 setup_server_dirs(ServerRoot, DocRoot, DataDir) ->
     CgiDir =  filename:join(ServerRoot, "cgi-bin"),
+    ExtCgiDir = filename:join(ServerRoot, "ext-cgi-bin"),
     AuthDir =  filename:join(ServerRoot, "auth"),
     PicsDir =  filename:join(ServerRoot, "icons"),
     ConfigDir =  filename:join(ServerRoot, "config"),
@@ -2082,6 +2117,7 @@ setup_server_dirs(ServerRoot, DocRoot, D
     ok = file:make_dir(ServerRoot),
     ok = file:make_dir(DocRoot),
     ok = file:make_dir(CgiDir),
+    ok = file:make_dir(ExtCgiDir),
     ok = file:make_dir(AuthDir),
     ok = file:make_dir(PicsDir),
     ok = file:make_dir(ConfigDir),
@@ -2095,6 +2131,7 @@ setup_server_dirs(ServerRoot, DocRoot, D
     inets_test_lib:copy_dirs(DocSrc, DocRoot),
     inets_test_lib:copy_dirs(AuthSrc, AuthDir),
     inets_test_lib:copy_dirs(CgiSrc, CgiDir),
+    inets_test_lib:copy_dirs(CgiSrc, ExtCgiDir),
     inets_test_lib:copy_dirs(PicsSrc, PicsDir),
     inets_test_lib:copy_dirs(ConfigSrc, ConfigDir),
 
@@ -2113,7 +2150,13 @@ setup_server_dirs(ServerRoot, DocRoot, D
     EnvCGI =  filename:join([ServerRoot, "cgi-bin", "printenv.sh"]),
     {ok, FileInfo1} = file:read_file_info(EnvCGI),
     ok = file:write_file_info(EnvCGI,
-			      FileInfo1#file_info{mode = 8#00755}).
+			      FileInfo1#file_info{mode = 8#00755}),
+
+    %% Set permissions for ext-cgi-bin scripts (outside DocumentRoot)
+    ExtEnvCGI = filename:join([ServerRoot, "ext-cgi-bin", "printenv.sh"]),
+    {ok, FileInfo2} = file:read_file_info(ExtEnvCGI),
+    ok = file:write_file_info(ExtEnvCGI,
+                              FileInfo2#file_info{mode = 8#00755}).
 
 setup_tmp_dir(PrivDir) ->
     TmpDir =  filename:join(PrivDir, "tmp"),
@@ -2152,6 +2195,7 @@ start_apps(Group) when  Group == http_ba
                         Group == http_mime_and_default_type;
                         Group == http_mime_types;
                         Group == http_rel_path_script_alias;
+                        Group == http_script_alias_auth;
                         Group == http_not_sup;
                         Group == http_mime_types;
                         Group == esi ->
@@ -2271,6 +2315,20 @@ server_config(http_erl_script_alias_all,
 server_config(http_rel_path_script_alias, Config) ->
     ServerRoot = proplists:get_value(server_root, Config),
     config_template(Config, ServerRoot, "./cgi-bin/", [httpd_example, io]);
+server_config(http_script_alias_auth, Config) ->
+    ServerRoot = proplists:get_value(server_root, Config),
+    %% CGI dir is outside DocumentRoot (sibling under ServerRoot)
+    ExtCgiDir = filename:join(ServerRoot, "ext-cgi-bin") ++ "/",
+    [{modules, [mod_alias, mod_auth, ?MODULE, mod_get, mod_head]},
+     {logger, [{error, httpd_test}]},
+     {script_alias, {"/http_script_alias_auth/", ExtCgiDir}},
+     {directory, {filename:join(ServerRoot, "ext-cgi-bin"),
+                  [{auth_type, plain},
+                   {auth_name, "Protected CGI"},
+                   {auth_user_file, filename:join(ServerRoot, "auth/passwd")},
+                   {auth_group_file, filename:join(ServerRoot, "auth/group")},
+                   {require_user, ["one", "Aladdin"]}]}}
+    ] ++ server_config(http, Config);
 server_config(https, Config) ->
     SSLConf = proplists:get_value(ssl_conf, Config),
     ServerConf = proplists:get_value(server_config, SSLConf),
@@ -2358,9 +2416,25 @@ do(ModData) ->
             ok;
         _ ->
             {already_sent, Status, _Size} = proplists:get_value(response, ModData#mod.data),
-            propagate_test ! {status, Status}              
+            propagate_test ! {status, Status}
     end,
-    {proceed, ModData#mod.data}.
+    case ModData#mod.request_uri of
+        "/http_script_alias_auth/" ++ _ ->
+            case proplists:get_value(status, ModData#mod.data) of
+                {_StatusCode, _PhraseArgs, _Reason} ->
+                    {proceed, ModData#mod.data};
+                undefined ->
+                    case proplists:get_value(response, ModData#mod.data) of
+                        undefined ->
+                            Body = "<html>script_alias_auth_bypass test ok</html>",
+                            {proceed, [{response, {200, Body}} | ModData#mod.data]};
+                        _Response ->
+                            {proceed, ModData#mod.data}
+                    end
+            end;
+        _ ->
+            {proceed, ModData#mod.data}
+    end.
 
 not_sup_conf() ->
     [{modules, [mod_get]}].
