"""ApacheParser is a member object of the ApacheConfigurator class.""" import os import re from letsencrypt import errors class ApacheParser(object): """Class handles the fine details of parsing the Apache Configuration. :ivar str root: Normalized abosulte path to the server root directory. Without trailing slash. """ def __init__(self, aug, root, ssl_options): # Find configuration root and make sure augeas can parse it. self.aug = aug self.root = os.path.abspath(root) self.loc = self._set_locations(ssl_options) self._parse_file(self.loc["root"]) # Must also attempt to parse sites-available or equivalent # Sites-available is not included naturally in configuration self._parse_file(os.path.join(self.root, "sites-available") + "/*") # This problem has been fixed in Augeas 1.0 self.standardize_excl() def add_dir_to_ifmodssl(self, aug_conf_path, directive, val): """Adds directive and value to IfMod ssl block. Adds given directive and value along configuration path within an IfMod mod_ssl.c block. If the IfMod block does not exist in the file, it is created. :param str aug_conf_path: Desired Augeas config path to add directive :param str directive: Directive you would like to add :param str val: Value of directive ie. Listen 443, 443 is the value """ # TODO: Add error checking code... does the path given even exist? # Does it throw exceptions? if_mod_path = self._get_ifmod(aug_conf_path, "mod_ssl.c") # IfModule can have only one valid argument, so append after self.aug.insert(if_mod_path + "arg", "directive", False) nvh_path = if_mod_path + "directive[1]" self.aug.set(nvh_path, directive) self.aug.set(nvh_path + "/arg", val) def _get_ifmod(self, aug_conf_path, mod): """Returns the path to and creates one if it doesn't exist. :param str aug_conf_path: Augeas configuration path :param str mod: module ie. mod_ssl.c """ if_mods = self.aug.match(("%s/IfModule/*[self::arg='%s']" % (aug_conf_path, mod))) if len(if_mods) == 0: self.aug.set("%s/IfModule[last() + 1]" % aug_conf_path, "") self.aug.set("%s/IfModule[last()]/arg" % aug_conf_path, mod) if_mods = self.aug.match(("%s/IfModule/*[self::arg='%s']" % (aug_conf_path, mod))) # Strip off "arg" at end of first ifmod path return if_mods[0][:len(if_mods[0]) - 3] def add_dir(self, aug_conf_path, directive, arg): """Appends directive to the end fo the file given by aug_conf_path. .. note:: Not added to AugeasConfigurator because it may depend on the lens :param str aug_conf_path: Augeas configuration path to add directive :param str directive: Directive to add :param str arg: Value of the directive. ie. Listen 443, 443 is arg """ self.aug.set(aug_conf_path + "/directive[last() + 1]", directive) if isinstance(arg, list): for i, value in enumerate(arg, 1): self.aug.set( "%s/directive[last()]/arg[%d]" % (aug_conf_path, i), value) else: self.aug.set(aug_conf_path + "/directive[last()]/arg", arg) def find_dir(self, directive, arg=None, start=None): """Finds directive in the configuration. Recursively searches through config files to find directives Directives should be in the form of a case insensitive regex currently .. todo:: Add order to directives returned. Last directive comes last.. .. todo:: arg should probably be a list Note: Augeas is inherently case sensitive while Apache is case insensitive. Augeas 1.0 allows case insensitive regexes like regexp(/Listen/, "i"), however the version currently supported by Ubuntu 0.10 does not. Thus I have included my own case insensitive transformation by calling case_i() on everything to maintain compatibility. :param str directive: Directive to look for :param arg: Specific value directive must have, None if all should be considered :type arg: str or None :param str start: Beginning Augeas path to begin looking """ # Cannot place member variable in the definition of the function so... if not start: start = get_aug_path(self.loc["root"]) # Debug code # print "find_dir:", directive, "arg:", arg, " | Looking in:", start # No regexp code # if arg is None: # matches = self.aug.match(start + # "//*[self::directive='" + directive + "']/arg") # else: # matches = self.aug.match(start + # "//*[self::directive='" + directive + # "']/* [self::arg='" + arg + "']") # includes = self.aug.match(start + # "//* [self::directive='Include']/* [label()='arg']") if arg is None: matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/arg" % (start, directive))) else: matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/*" "[self::arg=~regexp('%s')]" % (start, directive, arg))) incl_regex = "(%s)|(%s)" % (case_i('Include'), case_i('IncludeOptional')) includes = self.aug.match(("%s//* [self::directive=~regexp('%s')]/* " "[label()='arg']" % (start, incl_regex))) # for inc in includes: # print inc, self.aug.get(inc) for include in includes: # start[6:] to strip off /files matches.extend(self.find_dir( directive, arg, self._get_include_path( strip_dir(start[6:]), self.aug.get(include)))) return matches def _get_include_path(self, cur_dir, arg): """Converts an Apache Include directive into Augeas path. Converts an Apache Include directive argument into an Augeas searchable path .. todo:: convert to use os.path.join() :param str cur_dir: current working directory :param str arg: Argument of Include directive :returns: Augeas path string :rtype: str """ # Sanity check argument - maybe # Question: what can the attacker do with control over this string # Effect parse file... maybe exploit unknown errors in Augeas # If the attacker can Include anything though... and this function # only operates on Apache real config data... then the attacker has # already won. # Perhaps it is better to simply check the permissions on all # included files? # check_config to validate apache config doesn't work because it # would create a race condition between the check and this input # TODO: Maybe... although I am convinced we have lost if # Apache files can't be trusted. The augeas include path # should be made to be exact. # Check to make sure only expected characters are used <- maybe remove # validChars = re.compile("[a-zA-Z0-9.*?_-/]*") # matchObj = validChars.match(arg) # if matchObj.group() != arg: # logger.error("Error: Invalid regexp characters in %s", arg) # return [] # Standardize the include argument based on server root if not arg.startswith("/"): arg = cur_dir + arg # conf/ is a special variable for ServerRoot in Apache elif arg.startswith("conf/"): arg = self.root + arg[4:] # TODO: Test if Apache allows ../ or ~/ for Includes # Attempts to add a transform to the file if one does not already exist self._parse_file(arg) # Argument represents an fnmatch regular expression, convert it # Split up the path and convert each into an Augeas accepted regex # then reassemble if "*" in arg or "?" in arg: split_arg = arg.split("/") for idx, split in enumerate(split_arg): # * and ? are the two special fnmatch characters if "*" in split or "?" in split: # Turn it into a augeas regex # TODO: Can this instead be an augeas glob instead of regex split_arg[idx] = ("* [label()=~regexp('%s')]" % self.fnmatch_to_re(split)) # Reassemble the argument arg = "/".join(split_arg) # If the include is a directory, just return the directory as a file if arg.endswith("/"): return get_aug_path(arg[:len(arg)-1]) return get_aug_path(arg) def fnmatch_to_re(self, clean_fn_match): # pylint: disable=no-self-use """Method converts Apache's basic fnmatch to regular expression. :param str clean_fn_match: Apache style filename match, similar to globs :returns: regex suitable for augeas :rtype: str """ # Checkout fnmatch.py in venv/local/lib/python2.7/fnmatch.py regex = "" for letter in clean_fn_match: if letter == '.': regex = regex + r"\." elif letter == '*': regex = regex + ".*" # According to apache.org ? shouldn't appear # but in case it is valid... elif letter == '?': regex = regex + "." else: regex = regex + letter return regex def _parse_file(self, filepath): """Parse file with Augeas Checks to see if file_path is parsed by Augeas If filepath isn't parsed, the file is added and Augeas is reloaded :param str filepath: Apache config file path """ # Test if augeas included file for Httpd.lens # Note: This works for augeas globs, ie. *.conf inc_test = self.aug.match( "/augeas/load/Httpd/incl [. ='%s']" % filepath) if not inc_test: # Load up files # This doesn't seem to work on TravisCI # self.aug.add_transform("Httpd.lns", [filepath]) self._add_httpd_transform(filepath) self.aug.load() def _add_httpd_transform(self, incl): """Add a transform to Augeas. This function will correctly add a transform to augeas The existing augeas.add_transform in python doesn't seem to work for Travis CI as it loads in libaugeas.so.0.10.0 :param str incl: filepath to include for transform """ last_include = self.aug.match("/augeas/load/Httpd/incl [last()]") if last_include: # Insert a new node immediately after the last incl self.aug.insert(last_include[0], "incl", False) self.aug.set("/augeas/load/Httpd/incl[last()]", incl) # On first use... must load lens and add file to incl else: # Augeas uses base 1 indexing... insert at beginning... self.aug.set("/augeas/load/Httpd/lens", "Httpd.lns") self.aug.set("/augeas/load/Httpd/incl", incl) def standardize_excl(self): """Standardize the excl arguments for the Httpd lens in Augeas. Note: Hack! Standardize the excl arguments for the Httpd lens in Augeas Servers sometimes give incorrect defaults Note: This problem should be fixed in Augeas 1.0. Unfortunately, Augeas 0.10 appears to be the most popular version currently. """ # attempt to protect against augeas error in 0.10.0 - ubuntu # *.augsave -> /*.augsave upon augeas.load() # Try to avoid bad httpd files # There has to be a better way... but after a day and a half of testing # I had no luck # This is a hack... work around... submit to augeas if still not fixed excl = ["*.augnew", "*.augsave", "*.dpkg-dist", "*.dpkg-bak", "*.dpkg-new", "*.dpkg-old", "*.rpmsave", "*.rpmnew", "*~", self.root + "/*.augsave", self.root + "/*~", self.root + "/*/*augsave", self.root + "/*/*~", self.root + "/*/*/*.augsave", self.root + "/*/*/*~"] for i, excluded in enumerate(excl, 1): self.aug.set("/augeas/load/Httpd/excl[%d]" % i, excluded) self.aug.load() def _set_locations(self, ssl_options): """Set default location for directives. Locations are given as file_paths .. todo:: Make sure that files are included """ root = self._find_config_root() default = self._set_user_config_file(root) temp = os.path.join(self.root, "ports.conf") if os.path.isfile(temp): listen = temp name = temp else: listen = default name = default return {"root": root, "default": default, "listen": listen, "name": name, "ssl_options": ssl_options} def _find_config_root(self): """Find the Apache Configuration Root file.""" location = ["apache2.conf", "httpd.conf"] for name in location: if os.path.isfile(os.path.join(self.root, name)): return os.path.join(self.root, name) raise errors.NoInstallationError("Could not find configuration root") def _set_user_config_file(self, root): """Set the appropriate user configuration file .. todo:: This will have to be updated for other distros versions :param str root: pathname which contains the user config """ # Basic check to see if httpd.conf exists and # in hierarchy via direct include # httpd.conf was very common as a user file in Apache 2.2 if (os.path.isfile(os.path.join(self.root, 'httpd.conf')) and self.find_dir( case_i("Include"), case_i("httpd.conf"), root)): return os.path.join(self.root, 'httpd.conf') else: return os.path.join(self.root, 'apache2.conf') def case_i(string): """Returns case insensitive regex. Returns a sloppy, but necessary version of a case insensitive regex. Any string should be able to be submitted and the string is escaped and then made case insensitive. May be replaced by a more proper /i once augeas 1.0 is widely supported. :param str string: string to make case i regex """ return "".join(["["+c.upper()+c.lower()+"]" if c.isalpha() else c for c in re.escape(string)]) def get_aug_path(file_path): """Return augeas path for full filepath. :param str file_path: Full filepath """ return "/files%s" % file_path def strip_dir(path): """Returns directory of file path. .. todo:: Replace this with Python standard function :param str path: path is a file path. not an augeas section or directive path :returns: directory :rtype: str """ index = path.rfind("/") if index > 0: return path[:index+1] # No directory return ""