99 lines
7.8 KiB
Python
99 lines
7.8 KiB
Python
import pathlib,subprocess,json,sqlite3,os,shlex,time
|
|
w=pathlib.Path('/tmp/custodian-forgejo-successor-attempt2/cli-work');s='/tmp/forgejo-private-input-r1';r='/tmp/forgejo-independent-r1'
|
|
fixtures=w/'fixtures'; fixtures.mkdir(exist_ok=True)
|
|
A='AbCdEf012345678-'*4;B='ZyXwVu_987654321'*4 # synthetic exact64
|
|
assert len(A)==64 and len(B)==64
|
|
for name,data,mode in [('good',A,0o400),('replacement',B,0o440),('newline',A+'\n',0o400),('short',A[:-1],0o400),('long',A+'A',0o400),('badbyte',A[:-1]+'=',0o400),('world',A,0o444),('writable',A,0o600),('executable',A,0o500),('empty','',0o400),('CLIENT_SECRET','',0o400)]:
|
|
p=fixtures/name
|
|
if p.exists():p.chmod(0o600)
|
|
p.write_text(data);p.chmod(mode)
|
|
for name in ['symlink']:
|
|
p=fixtures/name
|
|
if not p.exists():p.symlink_to('good')
|
|
if not (fixtures/'fifo').exists():os.mkfifo(fixtures/'fifo',0o400)
|
|
(fixtures/'directory').mkdir(exist_ok=True)
|
|
(fixtures/'badparent').mkdir(exist_ok=True);(fixtures/'badparent').chmod(0o777)
|
|
if (fixtures/'badparent/value').exists():(fixtures/'badparent/value').chmod(0o600)
|
|
(fixtures/'badparent/value').write_text(A);(fixtures/'badparent/value').chmod(0o400)
|
|
if not (fixtures/'parentlink').exists():(fixtures/'parentlink').symlink_to('directory',target_is_directory=True)
|
|
(w/'runtime').mkdir(exist_ok=True)
|
|
dbfile=w/'runtime/test.db'
|
|
if dbfile.exists():dbfile.unlink() # isolated synthetic test database only
|
|
ini=w/'runtime/app.ini';ini.write_text('''APP_NAME = synthetic-private-input-test
|
|
RUN_USER = worsch
|
|
WORK_PATH = /tmp/custodian-forgejo-successor-attempt2/cli-work/runtime
|
|
[security]
|
|
INSTALL_LOCK = true
|
|
SECRET_KEY = SYNTHETIC-TEST-KEY
|
|
[database]
|
|
DB_TYPE = sqlite3
|
|
SLOW_QUERY_THRESHOLD = 1ns
|
|
PATH = /tmp/custodian-forgejo-successor-attempt2/cli-work/runtime/test.db
|
|
[server]
|
|
APP_DATA_PATH = /tmp/custodian-forgejo-successor-attempt2/cli-work/runtime/data
|
|
OFFLINE_MODE = true
|
|
[repository]
|
|
ROOT = /tmp/custodian-forgejo-successor-attempt2/cli-work/runtime/repos
|
|
[log]
|
|
MODE = console
|
|
LEVEL = Error
|
|
''')
|
|
env={'PATH':'/usr/bin:/bin','HOME':str(w/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':str(w/'home/test.gitconfig'),'GIT_TERMINAL_PROMPT':'0','USER':'worsch','GITEA_WORK_DIR':str(w/'runtime')}
|
|
base=['bwrap','--unshare-pid','--unshare-net','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib','--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev','--tmpfs','/tmp','--bind',str(w),str(w),'--dir','/run','--ro-bind',str(fixtures),'/run/forgejo-oidc','--ro-bind',str(fixtures/'good'),'/run/forgejo-oidc/CLIENT_SECRET','--bind',str(fixtures),'/run/writable-input','--chdir',str(w/'runtime'),'--','env','-i']+[k+'='+v for k,v in env.items()]+[str(w/'forgejo'),'--config',str(ini)]
|
|
results=[]
|
|
def run(args,ok=True,reason=None):
|
|
p=subprocess.run(base+args,capture_output=True,text=True,timeout=60); output=p.stdout+p.stderr
|
|
assert A not in output and B not in output, 'synthetic input leaked in output'
|
|
assert all(A not in arg and B not in arg for arg in base+args), 'synthetic input in argv'
|
|
assert all(A not in v and B not in v for v in env.values()), 'synthetic input in env'
|
|
results.append({'operation':args,'returncode':p.returncode,'stdout_stderr_secret_absent':True})
|
|
(w/'cli-attempts.json').write_text(json.dumps(results,indent=2))
|
|
if (p.returncode==0)!=ok:raise AssertionError('CLI unexpected status: '+repr(args)+'\n'+output[-5000:])
|
|
if reason:assert reason in output,output[-2000:]
|
|
return p
|
|
run(['migrate'])
|
|
clean_ini=ini.read_text()
|
|
ini.write_text(clean_ini.replace('DB_TYPE = sqlite3', 'DB_TYPE = sqlite3\nLOG_SQL = true'))
|
|
run(['admin','auth','add-oauth','--name','refused-debug-logging','--provider','github','--secret-file','/run/forgejo-oidc/CLIENT_SECRET'],False,'OAuth configuration requires SQL logging disabled')
|
|
ini.write_text(clean_ini)
|
|
def rows():
|
|
with sqlite3.connect(w/'runtime/test.db') as db:return db.execute('select id,name,cfg from login_source order by id').fetchall()
|
|
def cfg():return json.loads(rows()[0][2])
|
|
run(['admin','auth','add-oauth','--name','synthetic-oauth','--provider','github','--key','synthetic-public-id','--secret-file','/run/forgejo-oidc/CLIENT_SECRET','--group-claim-name','groups','--admin-group','unchanged-admin','--scopes','read:user,user:email'])
|
|
assert len(rows())==1 and cfg()['ClientSecret']==A
|
|
id=str(rows()[0][0]); before=cfg()
|
|
run(['admin','auth','update-oauth','--id',id,'--icon-url','https://synthetic.invalid/icon'])
|
|
assert cfg()['ClientSecret']==A
|
|
preserved=cfg()
|
|
run(['admin','auth','update-oauth','--id',id,'--secret-file','/run/forgejo-oidc/replacement'])
|
|
after=cfg();assert after['ClientSecret']==B
|
|
assert {k:v for k,v in after.items() if k!='ClientSecret'}=={k:v for k,v in preserved.items() if k!='ClientSecret'},'secret replacement changed unrelated config'
|
|
# Guarded inactive source exercises actual update omission and strict list semantics
|
|
# without discovery/network or claiming a browser login.
|
|
with sqlite3.connect(w/'runtime/test.db') as db:
|
|
guarded=after|{'Provider':'openidConnect','OpenIDConnectAutoDiscoveryURL':'https://synthetic.invalid/.well-known/openid-configuration','RequiredClaimName':'groups','RequiredClaimValues':['forge-admins','forge-coulomb-owners','forge-coulomb-readers']}
|
|
db.execute('update login_source set is_active=0,cfg=? where id=?',(json.dumps(guarded),id))
|
|
run(['admin','auth','update-oauth','--id',id,'--name','synthetic-guarded'])
|
|
assert cfg()['RequiredClaimValues']==guarded['RequiredClaimValues'] and cfg()['ClientSecret']==B
|
|
run(['admin','auth','update-oauth','--id',id,'--secret-file','/run/forgejo-oidc/good'])
|
|
assert cfg()['ClientSecret']==A and cfg()['RequiredClaimValues']==guarded['RequiredClaimValues']
|
|
for path in ['/run/forgejo-oidc/'+n for n in ['newline','short','long','badbyte','world','writable','executable','empty','symlink','fifo','directory','missing','badparent/value','parentlink/value']]+['relative','','/run/forgejo-oidc/../forgejo-oidc/good','/run/writable-input/good']:
|
|
before=rows()
|
|
for cmd in [['admin','auth','add-oauth','--name','refused','--provider','github'],['admin','auth','update-oauth','--id',id,'--name','refused']]:
|
|
run(cmd+['--secret-file',path],False,'secret-file input refused')
|
|
assert rows()==before,'failure changed DB'
|
|
for action in [['add-oauth','--name','refused'],['update-oauth','--id',id]]:
|
|
before=rows();run(['admin','auth']+action+['--secret','','--secret-file','/run/forgejo-oidc/good'],False,'mutually exclusive');assert rows()==before
|
|
# Failure to clear/ambiguously replace gate keeps stored application bytes unchanged.
|
|
before=rows();run(['admin','auth','update-oauth','--id',id,'--required-claim-values',''],False);assert rows()==before
|
|
# Actual XORM error hook under a failing SQLite trigger must not disclose cfg.
|
|
before=rows()
|
|
with sqlite3.connect(w/'runtime/test.db') as database:
|
|
database.execute("create trigger synthetic_private_error before update on login_source begin select raise(abort, 'synthetic-db-failure'); end")
|
|
run(['admin','auth','update-oauth','--id',id,'--secret-file','/run/forgejo-oidc/replacement'],False,'OAuth source update failed')
|
|
assert rows()==before
|
|
with sqlite3.connect(w/'runtime/test.db') as database:database.execute('drop trigger synthetic_private_error')
|
|
# Legacy explicit secret remains supported; synthetic legacy string is not private input.
|
|
run(['admin','auth','update-oauth','--id',id,'--secret','synthetic-legacy-literal'])
|
|
assert cfg()['ClientSecret']=='synthetic-legacy-literal' and cfg()['RequiredClaimValues']==guarded['RequiredClaimValues']
|
|
(w/'cli-results.json').write_text(json.dumps({'passed':True,'subprocesses':len(results),'real_backend_cli_and_sqlite':True,'network':'disabled by bwrap','secret_inputs':'synthetic only','values_never_in_argv_env_output':True,'actual_browser_login':False,'actual_readonly_file_bind':True,'replacement_preserves_other_config':True,'unsafe_SQL_logging_refused':True,'actual_error_and_slow_hooks_no_leak':True},indent=2));print('CLI integrations passed:',len(results))
|