diff --git a/scripts/openbao-attended-exec.py b/scripts/openbao-attended-exec.py new file mode 100755 index 0000000..f97415a --- /dev/null +++ b/scripts/openbao-attended-exec.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Keep Warden containment; supply a scoped browser launcher when WSL lacks one.""" +import os +from pathlib import Path +import shutil +import sys + + +def main(): + args=sys.argv[1:] + if args and args[0]=='--': args=args[1:] + if not args: raise SystemExit('A reviewed silent owner command is required') + env=os.environ.copy() + if not any(shutil.which(x) for x in ('xdg-open','x-www-browser','www-browser')): + if not Path('/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe').is_file(): + raise SystemExit('No supported attended browser launcher is available') + env['PATH']=str(Path(__file__).resolve().parent/'operator-browser')+os.pathsep+env.get('PATH','') + os.execvpe('warden',['warden','access','openbao-platform-admin-login','--exec','--',*args],env) +if __name__=='__main__': main() diff --git a/scripts/operator-browser/xdg-open b/scripts/operator-browser/xdg-open new file mode 100755 index 0000000..bb06f2c --- /dev/null +++ b/scripts/operator-browser/xdg-open @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Contained WSL launcher, limited to the approved OpenBao OIDC callback.""" +import subprocess +import sys +from urllib.parse import urlsplit, parse_qs + + +def allowed(url): + try: + u=urlsplit(url); q=parse_qs(u.query) + return (u.scheme=='https' and u.hostname=='kc.coulomb.social' + and u.port in (None,443) and u.username is None and u.password is None + and not u.fragment and not any(c in url for c in '\r\n\x00') + and q.get('redirect_uri') in (['http://localhost:8250/oidc/callback'],['http://127.0.0.1:8250/oidc/callback'])) + except ValueError: return False + + +def main(): + if len(sys.argv)!=2 or not allowed(sys.argv[1]): return 1 + try: + r=subprocess.run(['/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe', + '-NoProfile','-NonInteractive','-Command', + '$ErrorActionPreference="Stop"; $u=[Console]::In.ReadLine(); Start-Process -FilePath $u'], + input=(sys.argv[1]+'\n').encode(),stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=20) + return int(r.returncode!=0) + except Exception: return 1 +if __name__=='__main__': raise SystemExit(main()) diff --git a/tests/test_operator_browser.py b/tests/test_operator_browser.py new file mode 100644 index 0000000..cbf815e --- /dev/null +++ b/tests/test_operator_browser.py @@ -0,0 +1,15 @@ +import runpy +from pathlib import Path +import unittest +from urllib.parse import urlencode +allowed=runpy.run_path(str(Path(__file__).resolve().parents[1]/'scripts/operator-browser/xdg-open'))['allowed'] +class BrowserBoundary(unittest.TestCase): + def test_approved_flow(self): + self.assertTrue(allowed('https://kc.coulomb.social/authorize?'+urlencode({'redirect_uri':'http://localhost:8250/oidc/callback','state':'test'}))) + def test_other_destination_rejected(self): + for host in ['evil.example','kc.coulomb.social.evil.example','user@kc.coulomb.social']: + self.assertFalse(allowed('https://'+host+'/?'+urlencode({'redirect_uri':'http://localhost:8250/oidc/callback'}))) + def test_unapproved_callback_rejected(self): + self.assertFalse(allowed('https://kc.coulomb.social/?'+urlencode({'redirect_uri':'https://evil.example'}))) + def test_duplicate_callback_rejected(self): + self.assertFalse(allowed('https://kc.coulomb.social/?redirect_uri=http://localhost:8250/oidc/callback&redirect_uri=https://evil.example'))