From aa364b59253e061b141209ad976d80cdfbcb3c8e Mon Sep 17 00:00:00 2001 From: cisbiceanu Date: Thu, 10 Sep 2026 16:36:40 +0300 Subject: [PATCH 1/2] Add a new SetEnvFromFile directive that reads internal environment variables from a file of "name=value" lines at configuration time. Each entry is stored in the same per-directory table used by SetEnv, so the values merge and reach r->subprocess_env identically and are available to CGI, SSI, logging and other consumers. --- changes-entries/mod_env_setenvfromfile.txt | 5 + docs/log-message-tags/next-number | 2 +- docs/manual/mod/mod_env.xml | 61 ++++++ modules/metadata/mod_env.c | 56 ++++++ test/modules/metadata/env.py | 4 +- .../metadata/test_002_setenvfromfile.py | 184 ++++++++++++++++++ 6 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 changes-entries/mod_env_setenvfromfile.txt create mode 100644 test/modules/metadata/test_002_setenvfromfile.py 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 From e52c2ca5fc4d8112c8fb757d7cb5d328542a5070 Mon Sep 17 00:00:00 2001 From: cisbiceanu Date: Fri, 18 Sep 2026 15:18:02 +0300 Subject: [PATCH 2/2] mod_env: adjust SetEnvFromFile tests after merge and for winnt - Expect the renumbered AH10632 log tag: the trunk merge moved it from AH10624, which collided with mod_mime_libmagic. - Make the malformed-line and missing-file tests pass under mpm_winnt. apachectl_stderr is None on the winnt start path, and the AH10632 warning lands in the error log (possibly more than once) rather than on stderr. Guard stderr, accept the warning from either place, and mark it as caught so the teardown log check does not flag it. Co-Authored-By: Claude Opus 4.8 --- .../metadata/test_002_setenvfromfile.py | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/test/modules/metadata/test_002_setenvfromfile.py b/test/modules/metadata/test_002_setenvfromfile.py index 9cdb4515d94..d2fcb0e57f3 100644 --- a/test/modules/metadata/test_002_setenvfromfile.py +++ b/test/modules/metadata/test_002_setenvfromfile.py @@ -108,11 +108,27 @@ def _class_scope(self, env): 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 + # 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) @@ -172,8 +188,11 @@ def test_metadata_002_04_missing_file(self, env): conf.install() # httpd must refuse to start ... assert env.apache_fail() == 0 - # ... reporting why - assert "Could not open file" in env.apachectl_stderr + # ... 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