Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changes-entries/mod_env_setenvfromfile.txt
Original file line number Diff line number Diff line change
@@ -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]
2 changes: 1 addition & 1 deletion docs/log-message-tags/next-number
Original file line number Diff line number Diff line change
@@ -1 +1 @@
10624
10625
61 changes: 61 additions & 0 deletions docs/manual/mod/mod_env.xml
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,65 @@ SSI pages</description>
</usage>
</directivesynopsis>

<directivesynopsis>
<name>SetEnvFromFile</name>
<description>Sets environment variables from a file</description>
<syntax>SetEnvFromFile <var>file-path</var></syntax>
<contextlist><context>server config</context><context>virtual host</context>
<context>directory</context></contextlist>
<compatibility>Available in version 2.5.1 and later.</compatibility>

<usage>
<p>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 <directive
module="mod_env">SetEnv</directive> directives, one per variable, but keeps
the values in a separate file.</p>

<p>The <var>file-path</var> is either an absolute path or a path relative
to the <directive module="core">ServerRoot</directive>. The file is read
once when the configuration is parsed; changes to it take effect only after
the server is restarted.</p>

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

<example><title>Example</title>
<highlight language="config">
SetEnvFromFile conf/app.env
</highlight>
</example>

<example><title>Example file (<code>conf/app.env</code>)</title>
<highlight language="config">
# Application settings
APP_MODE=production
SPECIAL_PATH=/foo/bin
</highlight>
</example>

<note><p>As with <directive module="mod_env">SetEnv</directive>, the
variables are set <em>after</em> most early request processing directives
are run, such as access control and URI-to-filename mapping.</p>
</note>

<note type="warning"><title>Security</title>
<p>Unlike <directive module="mod_env">SetEnv</directive>, this directive
reads the <em>contents</em> 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), <directive>SetEnvFromFile</directive> is
<strong>not</strong> permitted in <code>.htaccess</code> files; it may only
be used in the main server configuration.</p>
</note>
</usage>
<seealso><a href="../env.html">Environment Variables</a></seealso>
<seealso><directive module="mod_env">SetEnv</directive></seealso>
</directivesynopsis>

</modulesynopsis>
56 changes: 56 additions & 0 deletions modules/metadata/mod_env.c
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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},
};

Expand Down
4 changes: 2 additions & 2 deletions test/modules/metadata/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
184 changes: 184 additions & 0 deletions test/modules/metadata/test_002_setenvfromfile.py
Original file line number Diff line number Diff line change
@@ -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}=[<!--#echo var="{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}"
<Directory "{doc_dir}">
Options +Includes
AddType text/html .shtml
AddOutputFilter INCLUDES .shtml
</Directory>
""",
})
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}"
<Directory "{doc_dir}">
Options +Includes
AddType text/html .shtml
AddOutputFilter INCLUDES .shtml
</Directory>
""",
})
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"""
<Directory "{ht_dir}">
AllowOverride FileInfo
</Directory>
""",
})
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