Esempio n. 1
0
    def do_GET(self):
        path = urllib.parse.unquote(self.path)

        self.log(logging.INFO, "GET Header: {}".format(path))

        if self.struggle_check(path):
            return

        # split the path by '/', ignoring empty string
        url_path = list(filter(None, path.split('/')))

        # if url is empty or path is /vpn/, display fake login page
        if len(url_path) == 0 or (len(url_path) == 1 and url_path[0] == 'vpn'):
            return self.send_response(self.get_page('login.html'))

        # only proceed if a directory traversal was attempted
        if path.find('/../') != -1:
            # flatten path to ease parsing
            collapsed_path = server._url_collapse_path(path)
            url_path = list(filter(None, collapsed_path.split('/')))

            # check if the directory traversal bug has been tried
            if len(url_path) >= 1 and url_path[0] == 'vpns':
                # collapse path to ignore extra / and .. for proper formatting
                collapsed_path = server._url_collapse_path(path)

                # 403 on /vpn/../vpns/ is used by some scanners to detect vulnerable hosts
                # Ex: https://github.com/cisagov/check-cve-2019-19781/blob/develop/src/check_cve/check.py
                if len(url_path) == 1 and url_path[0] == 'vpns':
                    self.log(logging.WARN,
                             "Detected type 1 CVE-2019-19781 scan attempt!")
                    page_403 = self.get_page('403.html').replace(
                        '{url}', collapsed_path)
                    return self.send_response(page_403)

                if len(url_path) >= 2 and url_path[0] == 'vpns' and url_path[
                        1] == 'portal':
                    self.log(logging.CRITICAL,
                             "Detected CVE-2019-19781 completion!")
                    return self.send_response("")

                # some scanners try to fetch smb.conf to detect vulnerable hosts
                # Ex: https://github.com/trustedsec/cve-2019-19781/blob/master/cve-2019-19781_scanner.py
                elif collapsed_path == '/vpns/cfg/smb.conf':
                    self.log(logging.WARN,
                             "Detected type 2 CVE-2019-19781 scan attempt!")
                    return self.send_response(self.get_page('smb.conf'))

                # we got a request that sort of matches CVE-2019-19781, but it's not a know scan attempt
                else:
                    self.log(
                        logging.DEBUG,
                        "Error: unhandled CVE-2019-19781 scan attempt: {}".
                        format(path))
                    self.send_response("")

        # if all else fails return nothing
        return self.send_response("")
Esempio n. 2
0
    def is_cgi(self):
        """Test whether self.path corresponds to a local CGI script or a CGI script.

        Returns True and updates the cgi_info attribute to the tuple
        (dir, rest) if self.path requires running a CGI script.
        Returns False otherwise.

        Returns True and set local_cgi to True if it's a local CI script.

        If any exception is raised, the caller should assume that
        self.path was rejected as invalid and act accordingly.

        The default implementation tests whether the normalized url
        path begins with one of the strings in self.cgi_directories
        (and the next character is a '/' or the end of the string).

        """
        collapsed_path = _url_collapse_path(self.path)
        dir_sep = collapsed_path.find('/', 1)
        head, tail = collapsed_path[:dir_sep], collapsed_path[dir_sep + 1:]
        if head in self.local_cgi_path:
            self.cgi_info = head, tail
            self.local_cgi = True
            return True
        else:
            self.local_cgi = False
            return super().is_cgi()
Esempio n. 3
0
    def do_POST(self):
        self.log(logging.INFO, "POST Header: {}".format(self.path))

        if 'Content-Length' in self.headers:
            collapsed_path = server._url_collapse_path(self.path)
            content_length = int(self.headers['Content-Length'])
            post_data = self.rfile.read(content_length).decode('utf-8')
            self.log(logging.INFO, "POST body: {}".format(post_data))

            # RCE path is /vpns/portal/scripts/newbm.pl and payload is contained in POST data
            if content_length != 0 and collapsed_path == '/vpns/portal/scripts/newbm.pl':
                payload = urllib.parse.parse_qs(post_data)['title'][0]
                self.log(logging.CRITICAL,
                         "Detected CVE-2019-19781 payload: {}".format(payload))

        if self.struggle_check(self.path):
            return

        # send empty response as we're now done
        return self.send_response('')
Esempio n. 4
0
 def test_url_collapse_path(self):
     # verify tail is the last portion and head is the rest on proper urls
     test_vectors = {
         '': '//',
         '..': IndexError,
         '/.//..': IndexError,
         '/': '//',
         '//': '//',
         '/\\': '//\\',
         '/.//': '//',
         'cgi-bin/file1.py': '/cgi-bin/file1.py',
         '/cgi-bin/file1.py': '/cgi-bin/file1.py',
         'a': '//a',
         '/a': '//a',
         '//a': '//a',
         './a': '//a',
         './C:/': '/C:/',
         '/a/b': '/a/b',
         '/a/b/': '/a/b/',
         '/a/b/.': '/a/b/',
         '/a/b/c/..': '/a/b/',
         '/a/b/c/../d': '/a/b/d',
         '/a/b/c/../d/e/../f': '/a/b/d/f',
         '/a/b/c/../d/e/../../f': '/a/b/f',
         '/a/b/c/../d/e/.././././..//f': '/a/b/f',
         '../a/b/c/../d/e/.././././..//f': IndexError,
         '/a/b/c/../d/e/../../../f': '/a/f',
         '/a/b/c/../d/e/../../../../f': '//f',
         '/a/b/c/../d/e/../../../../../f': IndexError,
         '/a/b/c/../d/e/../../../../f/..': '//',
         '/a/b/c/../d/e/../../../../f/../.': '//',
     }
     for path, expected in test_vectors.items():
         if isinstance(expected, type) and issubclass(expected, Exception):
             self.assertRaises(expected,
                               server._url_collapse_path, path)
         else:
             actual = server._url_collapse_path(path)
             self.assertEqual(expected, actual,
                              msg='path = %r\nGot:    %r\nWanted: %r' %
                              (path, actual, expected))
Esempio n. 5
0
 def test_url_collapse_path(self):
     # verify tail is the last portion and head is the rest on proper urls
     test_vectors = {
         '': '//',
         '..': IndexError,
         '/.//..': IndexError,
         '/': '//',
         '//': '//',
         '/\\': '//\\',
         '/.//': '//',
         'cgi-bin/file1.py': '/cgi-bin/file1.py',
         '/cgi-bin/file1.py': '/cgi-bin/file1.py',
         'a': '//a',
         '/a': '//a',
         '//a': '//a',
         './a': '//a',
         './C:/': '/C:/',
         '/a/b': '/a/b',
         '/a/b/': '/a/b/',
         '/a/b/.': '/a/b/',
         '/a/b/c/..': '/a/b/',
         '/a/b/c/../d': '/a/b/d',
         '/a/b/c/../d/e/../f': '/a/b/d/f',
         '/a/b/c/../d/e/../../f': '/a/b/f',
         '/a/b/c/../d/e/.././././..//f': '/a/b/f',
         '../a/b/c/../d/e/.././././..//f': IndexError,
         '/a/b/c/../d/e/../../../f': '/a/f',
         '/a/b/c/../d/e/../../../../f': '//f',
         '/a/b/c/../d/e/../../../../../f': IndexError,
         '/a/b/c/../d/e/../../../../f/..': '//',
         '/a/b/c/../d/e/../../../../f/../.': '//',
     }
     for path, expected in test_vectors.items():
         if isinstance(expected, type) and issubclass(expected, Exception):
             self.assertRaises(expected,
                               server._url_collapse_path, path)
         else:
             actual = server._url_collapse_path(path)
             self.assertEqual(expected, actual,
                              msg='path = %r\nGot:    %r\nWanted: %r' %
                              (path, actual, expected))
Esempio n. 6
0
 def test_url_collapse_path(self):
     # verify tail is the last portion and head is the rest on proper urls
     test_vectors = {
         "": "//",
         "..": IndexError,
         "/.//..": IndexError,
         "/": "//",
         "//": "//",
         "/\\": "//\\",
         "/.//": "//",
         "cgi-bin/file1.py": "/cgi-bin/file1.py",
         "/cgi-bin/file1.py": "/cgi-bin/file1.py",
         "a": "//a",
         "/a": "//a",
         "//a": "//a",
         "./a": "//a",
         "./C:/": "/C:/",
         "/a/b": "/a/b",
         "/a/b/": "/a/b/",
         "/a/b/.": "/a/b/",
         "/a/b/c/..": "/a/b/",
         "/a/b/c/../d": "/a/b/d",
         "/a/b/c/../d/e/../f": "/a/b/d/f",
         "/a/b/c/../d/e/../../f": "/a/b/f",
         "/a/b/c/../d/e/.././././..//f": "/a/b/f",
         "../a/b/c/../d/e/.././././..//f": IndexError,
         "/a/b/c/../d/e/../../../f": "/a/f",
         "/a/b/c/../d/e/../../../../f": "//f",
         "/a/b/c/../d/e/../../../../../f": IndexError,
         "/a/b/c/../d/e/../../../../f/..": "//",
         "/a/b/c/../d/e/../../../../f/../.": "//",
     }
     for path, expected in test_vectors.items():
         if isinstance(expected, type) and issubclass(expected, Exception):
             self.assertRaises(expected, server._url_collapse_path, path)
         else:
             actual = server._url_collapse_path(path)
             self.assertEqual(expected, actual, msg="path = %r\nGot:    %r\nWanted: %r" % (path, actual, expected))
Esempio n. 7
0
    def is_cgi(self):
        """Test whether self.path corresponds to a CGI script.

        Returns True and updates the cgi_info attribute to the tuple
        (dir, rest) if self.path requires running a CGI script.
        Returns False otherwise.

        If any exception is raised, the caller should assume that
        self.path was rejected as invalid and act accordingly.

        The default implementation tests whether the normalized url
        path begins with one of the strings in self.cgi_directories
        (and the next character is a '/' or the end of the string).
        """
        collapsed_path = _url_collapse_path(self.path)
        for path in self.cgi_directories:
            if path in collapsed_path:
                dir_sep_index = collapsed_path.rfind(path) + len(path)
                head, tail = collapsed_path[:dir_sep_index], collapsed_path[
                    dir_sep_index + 1:]
                self.cgi_info = head, tail
                return True
        return False
 def send_head(self):
     """This version delegates to the original when (1) authorization
     doesn't apply to a particular path or (2) credentials check out.
     Otherwise, it responds with UNAUTHORIZED or FORBIDDEN. The
     original passes GET requests to the SimpleHTTPRequestHandler,
     which requires a trailing slash for dirs below DOCROOT, if an
     html directory listing is to be generated and returned.
     Otherwise, it responds with a 301 MOVED_PERMANENTLY.
     """
     if not self.auth_dict:
         return super().send_head()
     #
     # self.dlog("send_head - auth_dict", **self.auth_dict)
     collapsed_path = _url_collapse_path(self.path)
     is_protected = False
     # XXX iter var name too long, need below
     for restricted_path in self.auth_dict:
         if (collapsed_path.startswith(restricted_path.rstrip("/") + "/")
                 or collapsed_path == restricted_path.rstrip("/")):
             is_protected = True
             break
     # This is just the entry for the path; unrelated to "description" field
     realm_info = self.auth_dict[restricted_path]
     privaterepo = realm_info.get('privaterepo', False)
     if (is_protected is False or privaterepo is False
             and "service=git-receive-pack" not in collapsed_path):
         return super().send_head()
     description = realm_info.get('description', "Basic auth requested")
     # XXX - this option is currently bunk, although it does trigger the
     # exporting of REMOTE_USER below, which the git exes seem to ignore.
     # If implementing, it would most likely be limited to unix systems
     # with read access to /etc/passwd and /etc/group. The actual modified
     # files would still end up being owned by the server process UID.
     realaccount = realm_info.get('realaccount', REQURE_ACCOUNT)
     try:
         secretsfile = realm_info.get('secretsfile')
         with open(secretsfile) as f:
             secretlines = f.readlines()
     except TypeError:
         self.send_error(HTTPStatus.EXPECTATION_FAILED,
                         "Could not read .htpasswd file")
         return None
     else:
         secdict = {}
         for line in secretlines:
             if ':' not in line:
                 continue
             u, p = line.split(":")
             if p.startswith("$apr1") and self.has_openssl is None:
                 try:
                     check_output(("openssl", "version"))
                 except (FileNotFoundError, CalledProcessError):
                     self.log_error("send_head - Apache md5 support needed"
                                    " but not found. See usage note.")
                     self.has_openssl = False
                     continue
                 else:
                     self.has_openssl = True
             elif p.startswith("$2y"):
                 # Placeholder for passlib integration
                 self.log_error("send_head - bcrypt support requested but "
                                "not found. See usage note.")
                 continue
             secdict.update({u.strip(): p.strip()})
         del line, u, p
         # self.dlog("send_head - secdict", **secdict)
     authorization = self.headers.get("authorization")
     #
     if authorization:
         self.dlog("send_head - auth string sent: %r" % authorization)
         authorization = authorization.split()
         if len(authorization) == 2:
             import base64
             import binascii
             os.environ.update(AUTH_TYPE=authorization[0])
             if authorization[0].lower() != "basic":
                 self.send_error(
                     HTTPStatus.NOT_ACCEPTABLE,
                     "Auth type %r not supported!" % authorization[0])
                 return None
             #
             try:
                 authorization = authorization[1].encode('ascii')
                 authorization = base64.decodebytes(authorization).decode(
                     'ascii')
             except (binascii.Error, UnicodeError):
                 pass
             else:
                 authorization = authorization.split(':')
                 self.dlog("send_head - processed auth: "
                           "{!r}".format(authorization))
                 if (len(authorization) == 2 and authorization[0] in secdict
                         and self.verify_pass(secdict[authorization[0]],
                                              authorization[1])):
                     if realaccount:
                         os.environ.update(REMOTE_USER=authorization[0])
                     return super().send_head()
                 else:
                     self.send_error(
                         HTTPStatus.FORBIDDEN, "Problem authenticating "
                         "{!r}".format(authorization[0]))
                     return None
         #
         # Auth string had > 1 space or exception was raised
         self.send_error(
             HTTPStatus.UNPROCESSABLE_ENTITY,
             "Problem reading authorization: "
             "{!r}".format(authorization[0]))
         return None
     else:
         self.send_response(HTTPStatus.UNAUTHORIZED)
         self.send_header("WWW-Authenticate",
                          'Basic realm="%s"' % description)
         self.end_headers()
         return None
    def is_cgi(self):
        """This modified version of ``is_cgi`` still performs the same
        basic function as its Super, but the ancillary ``cgi_info`` var
        has been renamed to ``repo_info`` to better distinguish between
        the aliased Git CGI scripts dir (``/usr/libexec/git-core``) and
        the Git repo itself.

        Namespaces
        ----------
        Initial cloning from a remote repo with existing namespaces
        takes some extra setup, which is another way of saying
        something's broken here.  When including an existing namespace
        as the repo's prefix in the url arg to ``git clone``, this
        warning appears: ``warning: remote HEAD refers to nonexistent
        ref, unable to checkout.`` And the cloned work tree is empty
        until issuing a ``git pull origin master``.

        Upon updating ``remote.origin.url`` with a new namespace prefix,
        and pushing, everything seems okay. The remote HEAD is even
        updated to point to the new namespaced ref. It's as if ``git
        symbolic-ref`` were issued manually on the server side.

        When cloning without any (existing) namespace prefixing the repo
        component of the url, a familiar refrain appears::

            Note: checking out '2641d08..'
            You are in 'detached HEAD' state. You can look around ...
            ...
            git checkout -b <new-branch-name>

        And ``status`` says ``not on any branch``. Checking out master
        without the ``-b`` miraculously puts everything in sync. After
        updating the remote url and issuing a ``push -u origin master``,
        the new namespace is created successfully on the remote.

        Update 1. -- it seems most of the above only applies to remotes
        that were initialized without the normal refs/heads/master but
        whose HEAD still pointed thus before being pushed to.
        """
        # TODO migrate all parsing business from ``run_cgi()`` up here.  The
        # basic purpose of this function, which is to split the path into head
        # and tail components, is redundant because ``run_cgi`` does it again.
        #
        # XXX ``SimpleHTTPRequestHandler`` calls ``posixpath.normpath``, which
        # seems pretty similar to ``http.server._url_collapse_path``. Might be
        # worth checking out.  Guessing this one has to do with deterring
        # pardir URI mischief, but not certain.
        collapsed_path = _url_collapse_path(self.path)
        git_root, tail = self.find_repo(collapsed_path)
        # Attempt to accommodate namespaced setups. This must occur before
        # non-existent dirs are interpreted as "missing."
        ns = None
        if USE_NAMESPACES:
            ns_test = self.find_repo(collapsed_path, allow_fake=True)
            self.dlog("is_cgi - ns_test:",
                      USE_NAMESPACES=USE_NAMESPACES,
                      ns_test=ns_test,
                      git_root=git_root)
            if ns_test[0] != git_root:
                git_root, tail = ns_test
                git_root, ns = self.find_repo(git_root)
                #
                # Here, the intent is surely to initialize a new repo.
                if git_root == "/" and "/" in ns:
                    tail = ns + "/" + tail
                    ns = None
                # For GET requests, ``self.path`` must be rewritten to prevent
                # 404s by aiding ``SimpleHTTPRequestHandler`` find the repo.
                else:
                    self.path = collapsed_path = (git_root.rstrip("/") + "/" +
                                                  tail.lstrip("/"))
        self.dlog("is_cgi - top", git_root=git_root, ns=ns, tail=tail)
        if ns:
            nsrepo = os.path.join(git_root.lstrip("/"), tail.partition("/")[0])
            nspath = os.path.join(self.docroot, nsrepo)
            try:
                nshead = check_output(
                    ("git -C %s symbolic-ref HEAD" % nspath).split())
            except CalledProcessError as e:
                self.log_error("{!r}".format(e))
            else:
                self.dlog("is_cgi: %s/HEAD:" % nsrepo, nshead=nshead.decode())
        #
        # Disqualify GET requests for static resources in ``$GIT_DIR/objects``.
        if self.get_RE.match(collapsed_path):
            return False
        # XXX a temporary catch-all to handle requests for extant paths that
        # don't resolve to ``$GIT_DIR/objects``. Separating this block from the
        # RE block above is a lazy way of acknowledging that simply dropping
        # such requests outright or throwing errors might be preferable to
        # having ``send_head`` shunt them to ``SimpleHTTPRequestHandler``. Any
        # such logic, if/when needed, should go here.
        cgi_cand = os.path.join(self.docroot, collapsed_path.strip('/'))
        if os.path.exists(cgi_cand):
            return False
        #
        # Enforce a "CGI-bin present" policy to allow for easier integration of
        # external authorization facilities.  Permissions problems may arise if
        # overridden, i.e., if ``FIRST_CHILD_OK == True``.
        if git_root == "/":
            # This should only run if all components have yet to be created or
            # if the topmost (1st child) is an existing repo.
            #
            gr_test = self.find_repo(collapsed_path, allow_fake=True)
            gr_test = self.find_repo(gr_test[0])
            msg = None
            mutate_path = False
            if gr_test[0] == "/" and not self.is_repo(
                    os.path.join(self.docroot,
                                 tail.lstrip("/").split("/")[0])):
                # This is for dry clones, so no component can actually exist...
                #
                if CREATE_MISSING is False:
                    # ... and none will be created
                    msg = "The requested path could not be found " \
                        "and the env var _CREATE_MISSING is not set"
                elif ('/info/refs?service=' in tail and '/'
                      not in tail.split("/info/refs?service=")[0].lstrip("/")):
                    # A lone, first-child of docroot has been requested
                    if FIRST_CHILD_OK is True:
                        # Let ``CREATE_MISSING`` logic below christen it a repo
                        mutate_path = True
                    else:
                        msg = "CREATE_MISSING is set but path is only one" \
                            " level deep; set _FIRST_CHILD_OK to override"
                else:
                    # Multiple components wanted, so this can fall through.
                    #
                    # XXX note this doesn't check for the presence of a
                    # ``service`` query string or that the method is ``GET``.
                    pass
            else:
                # First component indeed exists and is a git repo
                if FIRST_CHILD_OK:
                    mutate_path = True
                else:
                    msg = """\n
                    =============== WARNING ===============
                    The requested Git repo should not be a
                    first child of the root URI, "/"; to
                    override, export "_FIRST_CHILD_OK=1";
                    see usage; hit Ctrl-C (SIGINT) to exit
                    """
            self.dlog("is_cgi - git_root missing",
                      gr_test=gr_test,
                      tail=tail,
                      mutate_path=mutate_path,
                      docroot=self.docroot,
                      collapsed_path=collapsed_path)
            if msg is not None:
                self.send_error(HTTPStatus.FORBIDDEN, msg)
                raise ConnectionAbortedError(msg)
            elif mutate_path is True:
                # Nest everything by a level (break out of DOCROOT)
                self.docroot, git_root = os.path.split(self.docroot)
                collapsed_path = '/' + git_root + collapsed_path
        #
        dir_sep = collapsed_path.find('/', 1)
        #
        # NOTE - this resets everything -- the stuff above merely weeds out the
        # corner cases.
        #
        # ``head`` = 1st component of ``self.path`` w/o trailing slash
        # ``tail`` = the rest, no leading slash
        #
        # This split is only a starting point, or baseline, to allow the
        # setting of initial values for ``root``, ``repo``, etc.
        head, tail = collapsed_path[:dir_sep], collapsed_path[dir_sep + 1:]
        #
        self.repo_info = head, tail, ns
        #
        # Attempt to create repo if it doesn't exist; applies to both upload
        # and receive requests
        if (CREATE_MISSING is True and '/info/refs?service=' in tail):
            uri = os.path.join(
                self.docroot,
                collapsed_path.split('/info/refs?service=')[0].strip('/'))
            try:
                # Assume mode is set according to umask
                os.makedirs(uri)
            except FileExistsError:
                pass
            # Target repo be empty
            if len(os.listdir(uri)) == 0:
                try:
                    cp = check_output(('git -C %s init --bare' % uri).split())
                except CalledProcessError as e:
                    self.log_error('%r', e)
                else:
                    self.dlog('is_cgi - created new repo', cp=cp)
        self.dlog(
            "is_cgi:", **{
                "self.raw_requestline": self.raw_requestline,
                "collapsed_path": collapsed_path,
                "git_root": self.find_repo(collapsed_path)[0],
                "cgi_cand": cgi_cand,
                "self.repo_info": self.repo_info,
                "returned": True
            })
        return True