diff --git a/changes-entries/mod_env_setenvfromfile.txt b/changes-entries/mod_env_setenvfromfile.txt new file mode 100644 index 00000000000..be93867d3e9 --- /dev/null +++ b/changes-entries/mod_env_setenvfromfile.txt @@ -0,0 +1,5 @@ + *) mod_env: Add the SetEnvFromFile directive to set internal environment + variables from a file of name=value lines, read at configuration time. + Allowed only in the main server configuration (not in .htaccess), so an + untrusted author cannot read arbitrary server-readable files into the + environment. [Cornel Isbiceanu] diff --git a/docs/log-message-tags/next-number b/docs/log-message-tags/next-number index 5c74e1d8650..afe86b7fa49 100644 --- a/docs/log-message-tags/next-number +++ b/docs/log-message-tags/next-number @@ -1 +1 @@ -10624 +10625 diff --git a/docs/manual/mod/mod_env.xml b/docs/manual/mod/mod_env.xml index 5116b39f419..668f44e06c7 100644 --- a/docs/manual/mod/mod_env.xml +++ b/docs/manual/mod/mod_env.xml @@ -118,4 +118,65 @@ SSI pages + +SetEnvFromFile +Sets environment variables from a file +SetEnvFromFile file-path +server configvirtual host +directory +Available in version 2.5.1 and later. + + +

Sets internal environment variables read from a file, which are then + available to Apache HTTP Server modules, and passed on to CGI scripts and + SSI pages. This is equivalent to a series of SetEnv directives, one per variable, but keeps + the values in a separate file.

+ +

The file-path is either an absolute path or a path relative + to the ServerRoot. The file is read + once when the configuration is parsed; changes to it take effect only after + the server is restarted.

+ +

Each line of the file has the form name=value. Blank lines + and lines beginning with # are ignored, and leading and + trailing whitespace is stripped. If a line contains no =, the + variable is set to an empty string. A line ending in a backslash + (\) is continued on the following line, just as in the main + configuration files.

+ + Example + + SetEnvFromFile conf/app.env + + + + Example file (<code>conf/app.env</code>) + + # Application settings + APP_MODE=production + SPECIAL_PATH=/foo/bin + + + +

As with SetEnv, the + variables are set after most early request processing directives + are run, such as access control and URI-to-filename mapping.

+
+ + Security +

Unlike SetEnv, this directive + reads the contents of a file into the internal environment, where + they may be exposed through CGI, SSI, logging and other consumers. To + prevent an untrusted author from disclosing the contents of any file + readable by the server (for example configuration or credential files + elsewhere on the host), SetEnvFromFile is + not permitted in .htaccess files; it may only + be used in the main server configuration.

+
+
+Environment Variables +SetEnv +
+ diff --git a/modules/metadata/mod_env.c b/modules/metadata/mod_env.c index 52594e9cf20..4177ef0aad0 100644 --- a/modules/metadata/mod_env.c +++ b/modules/metadata/mod_env.c @@ -146,6 +146,59 @@ static const char *add_env_module_vars_unset(cmd_parms *cmd, void *sconf_, return NULL; } +static const char *add_env_module_vars_from_file(cmd_parms *cmd, void *sconf_, + const char *arg) +{ + env_dir_config_rec *sconf = sconf_; + const char *fname; + ap_configfile_t *file; + apr_status_t rv; + char line[MAX_STRING_LEN]; + + fname = ap_server_root_relative(cmd->temp_pool, arg); + if (!fname) { + return apr_pstrcat(cmd->pool, cmd->cmd->name, + ": Invalid file path ", arg, NULL); + } + + rv = ap_pcfg_openfile(&file, cmd->temp_pool, fname); + if (rv != APR_SUCCESS) { + return apr_psprintf(cmd->pool, "%s: Could not open file %s: %pm", + cmd->cmd->name, fname, &rv); + } + + /* Each line is "name=value"; blank lines and '#' comments are ignored. + * ap_cfg_getline() strips surrounding whitespace and handles line + * continuations. Names are read at config time and stored in the same + * table used by SetEnv, so they merge and reach r->subprocess_env the + * same way. + */ + while (!ap_cfg_getline(line, sizeof(line), file)) { + const char *rest = line; + const char *name, *value; + + if (line[0] == '#' || line[0] == '\0') { + continue; + } + + name = ap_getword(cmd->pool, &rest, '='); + if (!name[0]) { + ap_log_error(APLOG_MARK, APLOG_WARNING, 0, cmd->server, + APLOGNO(10624) "%s: Skipping malformed line " + "(no variable name) in %s", cmd->cmd->name, fname); + continue; + } + + /* rest points just past the '='; no '=' means an empty value. */ + value = apr_pstrdup(cmd->pool, rest); + apr_table_setn(sconf->vars, name, value); + } + + ap_cfg_closefile(file); + + return NULL; +} + static const command_rec env_module_cmds[] = { AP_INIT_ITERATE("PassEnv", add_env_module_vars_passed, NULL, @@ -154,6 +207,9 @@ AP_INIT_TAKE12("SetEnv", add_env_module_vars_set, NULL, OR_FILEINFO, "an environment variable name and optional value to pass to CGI."), AP_INIT_ITERATE("UnsetEnv", add_env_module_vars_unset, NULL, OR_FILEINFO, "a list of variables to remove from the CGI environment."), +AP_INIT_TAKE1("SetEnvFromFile", add_env_module_vars_from_file, NULL, + RSRC_CONF | ACCESS_CONF, + "the path to a file of name=value lines to pass to CGI."), {NULL}, }; diff --git a/test/modules/metadata/env.py b/test/modules/metadata/env.py index 038d478968a..797f089466a 100644 --- a/test/modules/metadata/env.py +++ b/test/modules/metadata/env.py @@ -12,14 +12,14 @@ class MetadataTestSetup(HttpdTestSetup): def __init__(self, env: 'HttpdTestEnv'): super().__init__(env=env) self.add_source_dir(os.path.dirname(inspect.getfile(MetadataTestSetup))) - self.add_modules(["mime", "mime_magic"]) + self.add_modules(["mime", "mime_magic", "env", "include"]) class MetadataTestEnv(HttpdTestEnv): def __init__(self, pytestconfig=None): super().__init__(pytestconfig=pytestconfig) - self.add_httpd_log_modules(["mime_magic", "core"]) + self.add_httpd_log_modules(["mime_magic", "env", "include", "core"]) def setup_httpd(self, setup: HttpdTestSetup = None): super().setup_httpd(setup=MetadataTestSetup(env=self)) diff --git a/test/modules/metadata/test_002_setenvfromfile.py b/test/modules/metadata/test_002_setenvfromfile.py new file mode 100644 index 00000000000..9cdb4515d94 --- /dev/null +++ b/test/modules/metadata/test_002_setenvfromfile.py @@ -0,0 +1,184 @@ +import os +import re +import pytest + +from pyhttpd.conf import HttpdConf + + +def _write_ssi_echo(path, names): + """An SSI page echoing each variable as NAME=[value]. An unset + variable echoes the default "(none)", so a set-but-empty variable + ("[]") is distinguishable from one that never made it into the table. + """ + with open(path, "w") as f: + for name in names: + f.write(f'{name}=[]\n') + + +class TestSetEnvFromFile: + """Parsing behaviour of a well-formed file.""" + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + # A plain name=value pair, an explicitly empty value, a line with + # no '=' (also empty), a backslash line continuation, and a line + # padded with surrounding whitespace that must be stripped. + env_file = os.path.join(env.gen_dir, "setenv.env") + with open(env_file, "w") as f: + f.write("# metadata SetEnvFromFile happy-path fixture\n") + f.write("\n") + f.write("ENV_SIMPLE=simple value\n") + f.write("ENV_EMPTY=\n") + f.write("ENV_NOEQ\n") + f.write("ENV_CONT=first \\\n") + f.write("second\n") + f.write(" ENV_WS=trimmed value \n") + + doc_dir = os.path.join(env.server_dir, "htdocs", "test1") + os.makedirs(doc_dir, exist_ok=True) + _write_ssi_echo(os.path.join(doc_dir, "fromfile.shtml"), + ["ENV_SIMPLE", "ENV_EMPTY", "ENV_NOEQ", "ENV_CONT", + "ENV_WS", "ENV_UNDEFINED"]) + + conf = HttpdConf(env, extras={ + 'base': f""" + SetEnvFromFile "{env_file}" + + Options +Includes + AddType text/html .shtml + AddOutputFilter INCLUDES .shtml + + """, + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + def test_metadata_002_01_parsing(self, env): + url = env.mkurl("http", "test1", "/fromfile.shtml") + r = env.curl_get(url) + assert r.response, "no response: server may have crashed" + assert r.response["status"] == 200 + body = r.response["body"].decode("utf-8") + # a plain name=value pair + assert "ENV_SIMPLE=[simple value]" in body + # an explicitly empty value is set, not absent + assert "ENV_EMPTY=[]" in body + # a line with no '=' yields an empty value + assert "ENV_NOEQ=[]" in body + # a backslash continues the value on the next line (no space added + # by the join; the space here is the one before the backslash) + assert "ENV_CONT=[first second]" in body + # leading/trailing whitespace on the line is stripped, from both + # the name (else the echo would be "(none)") and the value (else a + # trailing space would remain) + assert "ENV_WS=[trimmed value]" in body + # a variable never named in the file is left unset + assert "ENV_UNDEFINED=[(none)]" in body + + +class TestSetEnvFromFileMalformed: + """A line with no variable name is skipped with a warning, and later + lines are still parsed.""" + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + env_file = os.path.join(env.gen_dir, "setenv-malformed.env") + with open(env_file, "w") as f: + f.write("# a line beginning with '=' has an empty name\n") + f.write("=orphan value\n") + f.write("ENV_OK=present\n") + + doc_dir = os.path.join(env.server_dir, "htdocs", "test1") + os.makedirs(doc_dir, exist_ok=True) + _write_ssi_echo(os.path.join(doc_dir, "malformed.shtml"), ["ENV_OK"]) + + conf = HttpdConf(env, extras={ + 'base': f""" + SetEnvFromFile "{env_file}" + + Options +Includes + AddType text/html .shtml + AddOutputFilter INCLUDES .shtml + + """, + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + def test_metadata_002_02_malformed_line(self, env): + # The malformed line is skipped with a warning. The file is read + # while the configuration is parsed, before the error log is open, + # so the AH10624 warning goes to stderr rather than the error log. + assert "AH10624" in env.apachectl_stderr + assert "Skipping malformed line" in env.apachectl_stderr + # ... and the well-formed line after it is still applied + url = env.mkurl("http", "test1", "/malformed.shtml") + r = env.curl_get(url) + assert r.response, "no response: server may have crashed" + assert r.response["status"] == 200 + assert "ENV_OK=[present]" in r.response["body"].decode("utf-8") + + +class TestSetEnvFromFileHtaccess: + """SetEnvFromFile is not permitted in .htaccess (RSRC_CONF|ACCESS_CONF), + even where AllowOverride FileInfo would allow SetEnv itself.""" + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + ht_dir = os.path.join(env.server_dir, "htdocs", "test1", "htaccess") + os.makedirs(ht_dir, exist_ok=True) + with open(os.path.join(ht_dir, "index.html"), "w") as f: + f.write("hello\n") + # SetEnv (FileInfo) would be accepted here; SetEnvFromFile must not. + with open(os.path.join(ht_dir, ".htaccess"), "w") as f: + f.write('SetEnvFromFile "conf/whatever.env"\n') + + conf = HttpdConf(env, extras={ + 'base': f""" + + AllowOverride FileInfo + + """, + }) + conf.add_vhost_test1() + conf.install() + # the server starts fine; .htaccess is parsed per request + assert env.apache_restart() == 0 + + def test_metadata_002_03_htaccess_rejected(self, env): + url = env.mkurl("http", "test1", "/htaccess/index.html") + r = env.curl_get(url) + assert r.response, "no response: server may have crashed" + # the illegal directive makes .htaccess processing fail -> 500 + assert r.response["status"] == 500 + # logged (at alert level, so check_error_log does not flag it, but + # guard anyway) with the config parser's context rejection + assert env.httpd_error_log.scan_recent( + re.compile(r'.*SetEnvFromFile not allowed here.*')) + env.httpd_error_log.ignore_recent(matches=[r'.*SetEnvFromFile not allowed here.*']) + + +class TestSetEnvFromFileMissing: + """Pointing at a file that cannot be opened is a fatal config error.""" + + def test_metadata_002_04_missing_file(self, env): + missing = os.path.join(env.gen_dir, "does-not-exist.env") + conf = HttpdConf(env, extras={ + 'base': f'SetEnvFromFile "{missing}"', + }) + conf.add_vhost_test1() + conf.install() + # httpd must refuse to start ... + assert env.apache_fail() == 0 + # ... reporting why + assert "Could not open file" in env.apachectl_stderr + + # restore a working, running server so the log check and package + # teardown are clean + env.httpd_error_log.clear_log() + good = HttpdConf(env) + good.add_vhost_test1() + good.install() + assert env.apache_restart() == 0