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 6e844a987be..577ecbb3ce2 100644 --- a/docs/log-message-tags/next-number +++ b/docs/log-message-tags/next-number @@ -1 +1 @@ -10632 +10633 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..a7e13f37605 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(10632) "%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 608b9e095d5..1d9672d3951 100644 --- a/test/modules/metadata/env.py +++ b/test/modules/metadata/env.py @@ -12,7 +12,7 @@ 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"]) # mod_mime_libmagic needs libmagic at build time, so it must not # be a hard requirement; its tests skip when it is absent. self.add_optional_modules(["mime_libmagic"]) @@ -26,7 +26,7 @@ def __init__(self, pytestconfig=None): # a LogLevel for an unloaded module is a fatal config error, which # would break the whole suite (including the mod_mime_magic tests) # on platforms without libmagic, such as Windows. - log_modules = ["mime_magic", "core"] + log_modules = ["mime_magic", "env", "include", "core"] if self.has_libmagic_module: log_modules.insert(1, "mime_libmagic") self.add_httpd_log_modules(log_modules) diff --git a/test/modules/metadata/test_002_setenvfromfile.py b/test/modules/metadata/test_002_setenvfromfile.py new file mode 100644 index 00000000000..d2fcb0e57f3 --- /dev/null +++ b/test/modules/metadata/test_002_setenvfromfile.py @@ -0,0 +1,203 @@ +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 an AH10632 warning. Where that + # warning surfaces is MPM-dependent: when the config is parsed before + # the error log is open (e.g. the event MPM on Unix) it goes to + # apachectl's stderr; with mpm_winnt the config is parsed again by the + # child with the error log already open, so it lands in the error log + # instead (and possibly more than once). apachectl_stderr is also + # None on the winnt startup path, so guard it. Accept the warning in + # either place. + stderr = env.apachectl_stderr or "" + warn_re = re.compile( + r'.*AH10632: SetEnvFromFile: Skipping malformed line.*') + found = "AH10632" in stderr + if not found: + try: + found = env.httpd_error_log.scan_recent(warn_re) + except TimeoutError: + found = False + assert found, "AH10632 warning not found in stderr or error log" + # The warning is expected here; keep it from failing the teardown log + # check (it may appear in the error log several times under winnt). + env.httpd_error_log.ignore_recent(lognos=["AH10632"]) + # ... 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. The message is emitted at config-parse time; + # apachectl_stderr only captures it on the non-winnt start path (it is + # None under mpm_winnt), so only assert on it where it was captured. + if env.apachectl_stderr: + 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