krb5 commit: Add ksu test script

[email protected] Mon, 2 Oct 2023 20:03:22 -0400 (EDT)
Newsgroups gmane.comp.encryption.kerberos.cvs
Message-ID <[email protected]>
https://github.com/krb5/krb5/commit/036f861b657f63852732a4c19d84a4f18a3938ec
commit 036f861b657f63852732a4c19d84a4f18a3938ec
Author: Greg Hudson <[email protected]>
Date:   Tue Aug 29 16:40:58 2023 -0400

    Add ksu test script
    
    Add a test script for ksu, which must be run under sudo and makes
    potentially disruptive temporary changes to the host environment.  Do
    not run it as part of "make check", but do run it as part of the
    Github Actions CI.

 .github/workflows/build.yml |   5 +
 src/clients/ksu/Makefile.in |   8 ++
 src/clients/ksu/t_ksu.py    | 271 ++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 284 insertions(+)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 68a4788ad..e62f3fe34 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -41,12 +41,17 @@ jobs:
                 MAKEVARS: ${{ matrix.makevars }}
                 CONFIGURE_OPTS:  ${{ matrix.configureopts }}
               run: |
+                # For the ksu tests, allow homedir access from other users.
+                umask 022
+                chmod a+rx $HOME
+                chmod -R a+rX src
                 cd src
                 autoreconf
                 ./configure --enable-maintainer-mode --with-ldap $CONFIGURE_OPTS --prefix=$HOME/inst
                 make $MAKEVARS
                 make check
                 make install
+                (cd clients/ksu && make check-ksu)
             - name: Display skipped tests
               run: cat src/skiptests
             - name: Check for files unexpectedly not removed by make distclean
diff --git a/src/clients/ksu/Makefile.in b/src/clients/ksu/Makefile.in
index 8b4edce4d..9a892e665 100644
--- a/src/clients/ksu/Makefile.in
+++ b/src/clients/ksu/Makefile.in
@@ -33,3 +33,11 @@ install:
 	  $(INSTALL_SETUID) $$f \
 		$(DESTDIR)$(CLIENT_BINDIR)/`echo $$f|sed '$(transform)'`; \
 	done
+
+# The ksu tests must be run as root and may be disruptive to the host
+# system, so they are not included in "make check".  asan's leak
+# checker does not work with setuid binaries (and causes them to
+# always exit with status 1), so it is disabled here.
+check-ksu:
+	sudo LSAN_OPTIONS=detect_leaks=0 $(RUNPYTEST) $(srcdir)/t_ksu.py \
+		$(PYTESTFLAGS)
diff --git a/src/clients/ksu/t_ksu.py b/src/clients/ksu/t_ksu.py
new file mode 100644
index 000000000..9740972ba
--- /dev/null
+++ b/src/clients/ksu/t_ksu.py
@@ -0,0 +1,271 @@
+from k5test import *
+import pwd
+import stat
+
+krb5_conf = '/etc/krb5.conf'
+krb5_conf_save = krb5_conf + '.save-ksutest'
+krb5_conf_nosave = krb5_conf + '.nosave-ksutest'
+ksu = './ksu.ksutest'
+if 'SUDO_UID' not in os.environ or os.geteuid() != 0:
+    fail('this script must be run as root via sudo')
+caller_uid = int(os.environ['SUDO_UID'])
+if caller_uid == 0:
+    fail('the user invoking sudo must not be root')
+caller_username = os.environ['SUDO_USER']
+os.chown('testlog', caller_uid, -1)
+
+# Set the real and effective UIDs to the calling user, but preserve
+# the ability to restore root privileges.
+def be_caller():
+    os.setresuid(caller_uid, caller_uid, 0)
+
+
+# Restore root privileges.
+def be_root():
+    os.setresuid(0, 0, 0)
+
+
+# Remove the ksutest account.
+def cleanup_user():
+    # userdel commonly gives a warning about being unable to delete
+    # the mail spool; filter it out.
+    out = subprocess.check_output(['userdel', '-r', 'ksutest'],
+                                  stderr=subprocess.STDOUT)
+    if out.count(b'\n') > 1 or b'ksutest mail spool' not in out:
+        print(out)
+
+
+# Restore /etc/krb5.conf to the state it was in previously.
+def cleanup_krb5_conf():
+    if os.path.exists(krb5_conf_save):
+        os.unlink(krb5_conf)
+        os.rename(krb5_conf_save, krb5_conf)
+    elif os.path.exists(krb5_conf_nosave):
+        os.unlink(krb5_conf)
+        os.unlink(krb5_conf_nosave)
+
+
+def onexit():
+    if len(sys.argv) >= 2 and sys.argv[1] == 'nocleanup':
+        return
+    be_root()
+    cleanup_user()
+    cleanup_krb5_conf()
+    if os.path.exists(ksu):
+        os.unlink(ksu)
+
+
+# Create a ksutest account and return its home directory.
+def setup_user():
+    try:
+        ent = pwd.getpwnam('ksutest')
+        return ent.pw_dir
+    except KeyError:
+        subprocess.check_call(['useradd', '-m', '-r', 'ksutest'])
+        return pwd.getpwnam('ksutest').pw_dir
+
+
+# Make krb5.conf a copy of realm's krb5.conf file.  Save the old
+# contents in krb5_conf_save, or create krb5_conf_noexist to indicate
+# that the file didn't previously exist.
+def setup_krb5_conf(realm):
+    if not os.path.exists(krb5_conf):
+        open(krb5_conf_nosave, 'w').close()
+    elif not os.path.exists(krb5_conf_save):
+        os.rename(krb5_conf, krb5_conf_save)
+    shutil.copyfile(os.path.join(realm.testdir, 'krb5.conf'), krb5_conf)
+
+
+# Temporarily acting as root, write a file named fname in ksutest's
+# home directory with the given contents.  If wrong_owner is set, make
+# the file owned by the caller uid in order to trip ksu's owner check.
+def write_authz_file(fname, contents, wrong_owner=False):
+    be_root()
+    path = os.path.join(ksutest_home, fname)
+    with open(path, 'w') as f:
+        f.write('\n'.join(contents) + '\n')
+    if wrong_owner:
+        os.chown(path, caller_uid, -1)
+    be_caller()
+
+
+# Temporarily acting as root, remove fname from ksutest's home
+# directory.
+def remove_authz_file(fname):
+    be_root()
+    path = os.path.join(ksutest_home, fname)
+    if os.path.exists(path):
+        os.remove(path)
+    be_caller()
+
+
+be_caller()
+
+# Set up a realm.  Set default_keytab_name since ksu won't respect the
+# KRB5_KTNAME environment variable.
+keytab = os.path.join(os.getcwd(), 'testdir', 'keytab')
+realm = K5Realm(create_user=False,
+                krb5_conf={'libdefaults': {'default_keytab_name': keytab}})
+realm.addprinc('alice', 'pwalice')
+realm.addprinc('ksutest', 'pwksutest')
+realm.addprinc('ksutest/root', 'pwroot')
+realm.addprinc(caller_username, 'pwcaller')
+
+# Root setup:
+# - /etc/krb5.conf is a copy of the test realm krb5.conf
+# - a newly created user named ksutest exists (with homedir ksutest_home)
+# - a setuid copy of ksu exists in the build dir
+# Register an atexit handler to undo these changes.
+atexit.register(onexit)
+be_root()
+ksutest_home = setup_user()
+setup_krb5_conf(realm)
+if os.path.exists(ksu):
+    os.unlink(ksu)
+shutil.copyfile('ksu', ksu)
+os.chmod(ksu, 0o4755)
+be_caller()
+
+mark('no authorization')
+realm.kinit('alice', 'pwalice')
+realm.run([ksu, 'ksutest', '-n', 'alice', '-a', '-c', klist], expected_code=1,
+          expected_msg='authorization of [email protected] failed')
+
+mark('an2ln authorization')
+realm.kinit('ksutest', 'pwksutest')
+realm.run([ksu, 'ksutest', '-a', '-c', klist],
+          expected_msg='authorization for [email protected] successful')
+realm.run([ksu, 'ksutest', '-e', klist], expected_code=1,
+          expected_msg='account ksutest: authorization failed')
+
+mark('.k5login wrong owner')
+write_authz_file('.k5login', ['[email protected]'], wrong_owner=True)
+realm.run([ksu, 'ksutest', '-e', klist], expected_code=1,
+          expected_msg='account ksutest: authorization failed')
+remove_authz_file('.k5login')
+
+mark('.k5users wrong owner')
+write_authz_file('.k5users', ['[email protected]'], wrong_owner=True)
+realm.run([ksu, 'ksutest', '-e', klist], expected_code=1,
+          expected_msg='account ksutest: authorization failed')
+remove_authz_file('.k5users')
+
+mark('.k5login authorization')
+realm.kinit('alice', 'pwalice')
+write_authz_file('.k5login', ['[email protected]'])
+realm.run([ksu, 'ksutest', '-a', '-c', klist],
+          expected_msg='authorization for [email protected] successful')
+realm.run([ksu, 'ksutest', '-e', klist],
+          expected_msg='authorization for [email protected] for execution of')
+write_authz_file('.k5login', ['[email protected]'])
+realm.run([ksu, 'ksutest', '-a', '-c', klist], expected_code=1,
+          expected_msg='account ksutest: authorization failed')
+remove_authz_file('.k5login')
+
+mark('.k5users authorization (no second field)')
+write_authz_file('.k5users', ['[email protected]'])
+realm.run([ksu, 'ksutest', '-a', '-c', klist],
+          expected_msg='authorization for [email protected] successful')
+realm.run([ksu, 'ksutest', '-e', klist], expected_code=1,
+          expected_msg='account ksutest: authorization failed')
+write_authz_file('.k5users', ['[email protected]'])
+realm.run([ksu, 'ksutest', '-a', '-c', klist], expected_code=1,
+          expected_msg='account ksutest: authorization failed')
+
+mark('k5users authorization (wildcard)')
+write_authz_file('.k5users', ['[email protected] *'])
+realm.run([ksu, 'ksutest', '-a', '-c', klist],
+          expected_msg='authorization for [email protected] successful')
+realm.run([ksu, 'ksutest', '-e', klist],
+          expected_msg='authorization for [email protected] for execution of')
+
+mark('k5users authorization (command list)')
+write_authz_file('.k5users', ['[email protected] doesnotexist ' + klist])
+realm.run([ksu, 'ksutest', '-a', '-c', klist], expected_code=1,
+          expected_msg='account ksutest: authorization failed')
+realm.run([ksu, 'ksutest', '-e', klist],
+          expected_msg='authorization for [email protected] for execution of')
+realm.run([ksu, 'ksutest', '-e', kvno], expected_code=1,
+          expected_msg='account ksutest: authorization failed')
+realm.run([ksu, 'ksutest', '-e', 'doesnotexist'], expected_code=1,
+          expected_msg='Error: not found ->')
+remove_authz_file('.k5users')
+
+mark('principal heuristic (no authz files)')
+realm.run([ksu, 'ksutest', '-a', '-c', klist], input='pwksutest\n',
+          expected_msg='Authenticated [email protected]')
+realm.run([ksu, 'ksutest', '-e', klist], expected_code=1,
+          expected_msg='account ksutest: authorization failed')
+
+mark('principal heuristic (empty authz files)')
+write_authz_file('.k5login', [])
+write_authz_file('.k5users', [])
+realm.run([ksu, 'ksutest', '-e', klist], expected_code=1,
+          expected_msg='account ksutest: authorization failed')
+remove_authz_file('.k5login')
+remove_authz_file('.k5users')
+
+# Untested: if the ccache default principal is not authorized,
+# get_best_princ_for_target() looks for a TGT or host service ticket
+# for the target and source users (if authorized) or any other
+# authorized user.  This is not really useful because a ccache usually
+# only contains tickets for its default client principal (aside from
+# caches created for S4U2Proxy).  If the heuristic is ever changed to
+# search the cache collection instead of only the primary cache, we
+# should add tests for that here.
+
+mark('principal heuristic (.k5login)')
+write_authz_file('.k5login', ['[email protected]'])
+realm.run([ksu, 'ksutest', '-a', '-c', klist], input='pwksutest\n',
+          expected_msg='Authenticated [email protected]')
+realm.run([ksu, 'ksutest', '-e', klist], input='pwksutest\n',
+          expected_msg='Authenticated [email protected]')
+write_authz_file('.k5login', [caller_username + '@KRBTEST.COM'])
+realm.run([ksu, 'ksutest', '-e', klist], input='pwcaller\n',
+          expected_msg='Authenticated %[email protected]' % caller_username)
+remove_authz_file('.k5login')
+
+mark('principal heuristic (.k5users)')
+write_authz_file('.k5users', ['[email protected] ' + klist,
+                              '[email protected]',
+                              caller_username + '@KRBTEST.COM *'])
+realm.run([ksu, 'ksutest', '-e', klist],
+          expected_msg='Authenticated [email protected]')
+realm.run([ksu, 'ksutest', '-a', '-c', klist], input='pwksutest\n',
+          expected_msg='Authenticated [email protected]')
+realm.run([ksu, 'ksutest', '-e', kvno, 'alice'], input='pwcaller\n',
+          expected_msg='Authenticated %[email protected]' % caller_username)
+write_authz_file('.k5users', ['[email protected] ' + klist,
+                              'ksutest/[email protected] ' + kvno])
+realm.run([ksu, 'ksutest', '-e', kvno, 'alice'], input='pwroot\n',
+          expected_msg='Authenticated ksutest/[email protected]')
+
+mark('principal heuristic (no authorization)')
+realm.run([ksu, '.', '-e', klist],
+          expected_msg='Default principal: [email protected]')
+be_root()
+realm.run([ksu, 'ksutest', '-e', klist],
+          expected_msg='No credentials cache found')
+be_caller()
+realm.kinit('ksutest', 'pwksutest')
+be_root()
+realm.run([ksu, 'ksutest', '-e', klist],
+          expected_msg='Default principal: [email protected]')
+be_caller()
+realm.run([kdestroy])
+realm.run([ksu, '.', '-e', klist], expected_msg='No credentials cache found')
+
+mark('authentication without authorization')
+realm.run([ksu, '.', '-n', 'ksutest', '-e', klist], input='pwksutest\n',
+          expected_msg='Leaving uid as ' + caller_username)
+
+# It's hard to make this flag do anything detectable, but we can
+# exercise the code.
+mark('-z flag')
+realm.kinit(caller_username, 'pwcaller')
+realm.run([ksu, '.', '-z', '-e', klist],
+          expected_msg='Default principal: ' + caller_username)
+
+realm.run([ksu, '.', '-Z', '-e', klist])
+
+success('ksu tests')