Repository: mesonbuild/meson
Issue category: Security Vulnerability

'ninja install' attempts to gain elevated privileges
It is disturbing for 'ninja install' to prompt for a sudo password with polkit.

This breaks user expectations, and introduces a security risk.

This has evidently been meson's behavior for a very long time, but it's distressing, and it's surprising because meson is usually so keen on simplicity; the last thing I expect when invoking a build tool is for it to take over the screen and request a password.

(It would be equally distressing for 'sudo ninja install' to clutter the local directory with root-owned files.)
pkexec logic is a bad idea
This is a bit related to https://github.com/mesonbuild/meson/issues/4758

Basically, what you're doing with polkit doesn't make sense.  You're allowing to execute arbitrary code (in the Meson build logic) as root; the permission isn't actually limited in any way.  So rather than having meson contain logic to run `pkexec` it's just much saner to tell people to do `sudo meson install` (or `pkexec meson install`) etc.

IOW you should just delete the polkit rules and print an error message telling people how to use `sudo/pkexec` (this is quite commonly documented for other build systems of course).
'ninja install' attempts to gain elevated privileges
It is disturbing for 'ninja install' to prompt for a sudo password with polkit.

This breaks user expectations, and introduces a security risk.

This has evidently been meson's behavior for a very long time, but it's distressing, and it's surprising because meson is usually so keen on simplicity; the last thing I expect when invoking a build tool is for it to take over the screen and request a password.

(It would be equally distressing for 'sudo ninja install' to clutter the local directory with root-owned files.)
pkexec logic is a bad idea
This is a bit related to https://github.com/mesonbuild/meson/issues/4758

Basically, what you're doing with polkit doesn't make sense.  You're allowing to execute arbitrary code (in the Meson build logic) as root; the permission isn't actually limited in any way.  So rather than having meson contain logic to run `pkexec` it's just much saner to tell people to do `sudo meson install` (or `pkexec meson install`) etc.

IOW you should just delete the polkit rules and print an error message telling people how to use `sudo/pkexec` (this is quite commonly documented for other build systems of course).


Functions to edit:
mesonbuild/minstall.py:Installer.do_install
mesonbuild/minstall.py:rebuild_all

Patch:
diff --git a/docs/markdown/Installing.md b/docs/markdown/Installing.md
index a692afe7deea..2d18c178fccd 100644
--- a/docs/markdown/Installing.md
+++ b/docs/markdown/Installing.md
@@ -102,6 +102,22 @@ Telling Meson to run this script at install time is a one-liner.
 The argument is the name of the script file relative to the current
 subdirectory.
 
+## Installing as the superuser
+
+When building as a non-root user, but installing to root-owned locations via
+e.g. `sudo ninja install`, ninja will attempt to rebuild any out of date
+targets as root. This results in various bad behaviors due to build outputs and
+ninja internal files being owned by root.
+
+Running `meson install` is preferred for several reasons. It can rebuild out of
+date targets and then re-invoke itself as root. *(since 1.1.0)* Additionally,
+running `sudo meson install` will drop permissions and rebuild out of date
+targets as the original user, not as root.
+
+*(since 1.1.0)* Re-invoking as root will try to guess the user's preferred method for
+re-running commands as root. The order of precedence is: sudo, doas, pkexec
+(polkit). An elevation tool can be forced by setting `$MESON_ROOT_CMD`.
+
 ## DESTDIR support
 
 Sometimes you need to install to a different directory than the
diff --git a/docs/markdown/snippets/meson_install_drop_privs.md b/docs/markdown/snippets/meson_install_drop_privs.md
new file mode 100644
index 000000000000..e08dfc000535
--- /dev/null
+++ b/docs/markdown/snippets/meson_install_drop_privs.md
@@ -0,0 +1,16 @@
+## `sudo meson install` now drops privileges when rebuilding targets
+
+It is common to install projects using sudo, which should not affect build
+outputs but simply install the results. Unfortunately, since the ninja backend
+updates a state file when run, it's not safe to run ninja as root at all.
+
+It has always been possible to carefully build with:
+
+```
+ninja && sudo meson install --no-rebuild
+```
+
+Meson now tries to be extra safe as a general solution. `sudo meson install`
+will attempt to rebuild, but has learned to run `ninja` as the original
+(pre-sudo or pre-doas) user, ensuring that build outputs are generated/compiled
+as non-root.
diff --git a/docs/markdown/snippets/meson_install_elevate.md b/docs/markdown/snippets/meson_install_elevate.md
new file mode 100644
index 000000000000..2ba92e631a83
--- /dev/null
+++ b/docs/markdown/snippets/meson_install_elevate.md
@@ -0,0 +1,9 @@
+## `meson install` now supports user-preferred root elevation tools
+
+Previously, when installing a project, if any files could not be installed due
+to insufficient permissions the install process was automatically re-run using
+polkit. Now it prompts to ask whether that is desirable, and checks for
+CLI-based tools such as sudo or opendoas or `$MESON_ROOT_CMD`, first.
+
+Meson will no longer attempt privilege elevation at all, when not running
+interactively.
diff --git a/mesonbuild/minstall.py b/mesonbuild/minstall.py
index 40c2d2d418e3..077823cb7929 100644
--- a/mesonbuild/minstall.py
+++ b/mesonbuild/minstall.py
@@ -18,6 +18,7 @@
 import argparse
 import errno
 import os
+import selectors
 import shlex
 import shutil
 import subprocess
@@ -42,7 +43,7 @@
             ExecutableSerialisation, InstallDataBase, InstallEmptyDir,
             InstallSymlinkData, TargetInstallData
     )
-    from .mesonlib import FileMode
+    from .mesonlib import FileMode, EnvironOrDict
 
     try:
         from typing import Protocol
@@ -556,13 +557,36 @@ def do_install(self, datafilename: str) -> None:
                     self.log('Preserved {} unchanged files, see {} for the full list'
                              .format(self.preserved_file_count, os.path.normpath(self.lf.name)))
         except PermissionError:
-            if shutil.which('pkexec') is not None and 'PKEXEC_UID' not in os.environ and destdir == '':
-                print('Installation failed due to insufficient permissions.')
-                print('Attempting to use polkit to gain elevated privileges...')
-                os.execlp('pkexec', 'pkexec', sys.executable, main_file, *sys.argv[1:],
-                          '-C', os.getcwd())
-            else:
+            if is_windows() or destdir != '' or not os.isatty(sys.stdout.fileno()) or not os.isatty(sys.stderr.fileno()):
+                # can't elevate to root except in an interactive unix environment *and* when not doing a destdir install
                 raise
+            rootcmd = os.environ.get('MESON_ROOT_CMD') or shutil.which('sudo') or shutil.which('doas')
+            pkexec = shutil.which('pkexec')
+            if rootcmd is None and pkexec is not None and 'PKEXEC_UID' not in os.environ:
+                rootcmd = pkexec
+
+            if rootcmd is not None:
+                print('Installation failed due to insufficient permissions.')
+                s = selectors.DefaultSelector()
+                s.register(sys.stdin, selectors.EVENT_READ)
+                ans = None
+                for attempt in range(5):
+                    print(f'Attempt to use {rootcmd} to gain elevated privileges? [y/n] ', end='', flush=True)
+                    if s.select(30):
+                        # we waited on sys.stdin *only*
+                        ans = sys.stdin.readline().rstrip('\n')
+                    else:
+                        print()
+                        break
+                    if ans in {'y', 'n'}:
+                        break
+                else:
+                    if ans is not None:
+                        raise MesonException('Answer not one of [y/n]')
+                if ans == 'y':
+                    os.execlp(rootcmd, rootcmd, sys.executable, main_file, *sys.argv[1:],
+                              '-C', os.getcwd(), '--no-rebuild')
+            raise
 
     def do_strip(self, strip_bin: T.List[str], fname: str, outname: str) -> None:
         self.log(f'Stripping target {fname!r}.')
@@ -743,7 +767,41 @@ def rebuild_all(wd: str) -> bool:
         print("Can't find ninja, can't rebuild test.")
         return False
 
-    ret = subprocess.run(ninja + ['-C', wd]).returncode
+    def drop_privileges() -> T.Tuple[T.Optional[EnvironOrDict], T.Optional[T.Callable[[], None]]]:
+        if not is_windows() and os.geteuid() == 0:
+            import pwd
+            env = os.environ.copy()
+
+            if os.environ.get('SUDO_USER') is not None:
+                orig_user = env.pop('SUDO_USER')
+                orig_uid = env.pop('SUDO_UID', 0)
+                orig_gid = env.pop('SUDO_GID', 0)
+                homedir = pwd.getpwuid(int(orig_uid)).pw_dir
+            elif os.environ.get('DOAS_USER') is not None:
+                orig_user = env.pop('DOAS_USER')
+                pwdata = pwd.getpwnam(orig_user)
+                orig_uid = pwdata.pw_uid
+                orig_gid = pwdata.pw_gid
+                homedir = pwdata.pw_dir
+            else:
+                return None, None
+
+            env['USER'] = orig_user
+            env['HOME'] = homedir
+
+            def wrapped() -> None:
+                print(f'Dropping privileges to {orig_user!r} before running ninja...')
+                if orig_gid is not None:
+                    os.setgid(int(orig_gid))
+                if orig_uid is not None:
+                    os.setuid(int(orig_uid))
+
+            return env, wrapped
+        else:
+            return None, None
+
+    env, preexec_fn = drop_privileges()
+    ret = subprocess.run(ninja + ['-C', wd], env=env, preexec_fn=preexec_fn).returncode
     if ret != 0:
         print(f'Could not rebuild {wd}')
         return False