Files
huntingclubroster/tests/mfa_lifecycle_http.py

87 lines
7.5 KiB
Python

"""Competing password-only sessions must never inherit replacement permission."""
import http.cookiejar,re,time,urllib.error,urllib.parse,urllib.request
from application_harness import Application
app=Application();checks=0;password='Admin test password 123!'
def check(ok,message):
global checks
if not ok:raise AssertionError(message)
checks+=1;print('PASS:',message)
class Client:
def __init__(self):
self.jar=http.cookiejar.CookieJar();self.client=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar));self.body=b''
self.login()
def request(self,path='/',data=None,ajax=False):
req=urllib.request.Request(app.base+path,data=urllib.parse.urlencode(data).encode() if data is not None else None,headers={'X-Requested-With':'XMLHttpRequest'} if ajax else {})
try:
with self.client.open(req,timeout=30) as r:status,body=r.status,r.read()
except urllib.error.HTTPError as e:status,body=e.code,e.read()
if b'csrf' in body:self.body=body
return status,body
def login(self,pwd=password):
_,body=self.request();return self.request(data={'action':'SignIn.login','csrf_token':app.csrf(body),'user_email':'admin@example.test','user_password':pwd})
def security(self,code='',pwd=password,**extra):
return self.request(data={'action':'security','csrf_token':app.csrf(self.body),'current_password':pwd,'mfa_code':code,**extra})
def post(self,action,**extra):return self.request(data={'action':action,'csrf_token':app.csrf(self.body),**extra},ajax=True)
def state(self,operation='snapshot'):
sid=next(c.value for c in self.jar if c.name=='HUNTINGCLUBROSTER');return app.fixture('mfaTestSession',sid,operation)
def factor():return app.fixture('mfaState','admin@example.test')
def secret(body):return re.search(rb'<pre>([A-Z2-7]+)</pre>',body).group(1).decode()
try:
victim=Client();original_secret=secret(victim.body)
status,body=victim.security(app.totp(original_secret));codes=[v.decode() for v in re.findall(rb'\b[a-f0-9]{32}\b',body)]
check(status==200 and len(codes)==10,'Initial enrollment succeeds')
check(b'notification email could not be sent' in body,'Failed security email is visibly reported without undoing enrollment')
victim.request('/main.php?Xaction=security');before=factor()
status,_=victim.security(codes[0],pwd='incorrect')
check(status==403 and factor()==before,'Wrong password preserves the recovery code and factor counter')
status,_=victim.security(codes[0]);check(status==200 and len(factor()['recovery'])==9,'Recovery code remains usable with the correct password')
victim.request('/main.php?Xaction=security');before=factor();next_code=app.totp(original_secret,time.time()+30)
status,_=victim.security(next_code,pwd='incorrect');check(status==403 and factor()==before,'Wrong password cannot consume an authenticator time step')
status,_=victim.security(next_code);check(status==200,'Authenticator time step still verifies with the correct password')
victim.request('/main.php?Xaction=security');before=factor();version=victim.state()['auth_version']
status,body=victim.security(codes[1],factor_action='replace');replacement_secret=secret(body)
check(status==200 and factor()['factor']==before['factor'] and len(factor()['recovery'])==8,'Starting replacement keeps the old factor and unused recovery codes')
check(victim.state()['auth_version']==version,'Starting replacement does not revoke other sessions prematurely')
attacker=Client();check(b'setup key' not in attacker.body and b'Authentication code' in attacker.body,'A separate password-only session still faces the existing MFA challenge')
status,_=attacker.security(app.totp(replacement_secret),factor_action='confirmReplacement')
check(status==409 and factor()['factor']==before['factor'],'Password-only session cannot confirm another session\'s pending replacement')
check(attacker.post('Users.setupUsers')[0]==403,'Password-only session cannot access Super Administrator pages')
victim.security(factor_action='cancelReplacement')
check(factor()['factor']==before['factor'] and len(factor()['recovery'])==8,'Canceling replacement preserves the existing factor and remaining codes')
victim.security(codes[2],factor_action='replace');expired_secret=secret(victim.body);victim.state('expire');before=factor()
status,_=victim.security(app.totp(expired_secret),factor_action='confirmReplacement')
check(status==409 and factor()==before,'Expired replacement cannot change either factor or recovery codes')
app.fixture('clearTestAttempts')
victim.security(codes[3],factor_action='replace');replacement_secret=secret(victim.body);before=factor()
status,_=victim.security(app.totp(replacement_secret),pwd='incorrect',factor_action='confirmReplacement')
check(status==403 and factor()==before,'Replacement confirmation with wrong password is non-mutating')
app.fixture('clearTestAttempts')
status,_=victim.security('not-a-code',factor_action='confirmReplacement')
check(status==422 and factor()==before,'Invalid new authenticator code leaves existing protection intact')
app.fixture('mfaTestAuditFailure','on')
status,_=victim.security(app.totp(replacement_secret),factor_action='confirmReplacement')
app.fixture('mfaTestAuditFailure','off')
check(status==500 and factor()==before,'Failed mandatory audit rolls back the entire factor swap')
victim.request('/main.php?Xaction=security')
confirmed_code=app.totp(replacement_secret)
status,body=victim.security(confirmed_code,factor_action='confirmReplacement');newcodes=[v.decode() for v in re.findall(rb'\b[a-f0-9]{32}\b',body)]
check(status==200 and len(newcodes)==10 and factor()['factor']['secret_cipher']!=before['factor']['secret_cipher'],'Successful confirmation swaps the factor and creates ten fresh recovery codes')
check(not set(before['recovery'])&set(factor()['recovery']),'Confirmation revokes every old recovery code')
check(attacker.post('Users.setupUsers')[0]==401,'Successful replacement revokes the competing session')
victim.request('/main.php?Xaction=security');before=factor();status,_=victim.security(confirmed_code)
check(status==403 and factor()==before,'New authenticator confirmation code cannot be replayed')
status,_=victim.security(newcodes[0]);check(status==200,'New recovery codes authenticate successfully')
app.fixture('clearTestAttempts');victim.request('/main.php?Xaction=security');victim.security(newcodes[1],factor_action='replace');before=factor()
token=app.fixture('mfaResetToken')['token'];newpwd='Changed with MFA still active 123!'
_,reset=victim.request('/main.php?Xaction=resetpassword&token='+token)
status,_=victim.request(data={'action':'resetpassword','step':'reset','csrf_token':app.csrf(reset),'token':token,'user_password':newpwd,'user_password_confirm':newpwd})
check(status==200 and factor()==before,'Password reset preserves the active factor during pending replacement')
check(victim.post('security',factor_action='confirmReplacement',current_password=newpwd,mfa_code='000000')[0]==401,'Password reset invalidates the old replacement session')
fresh=Client();fresh.login(newpwd)
check(b'Authentication code' in fresh.body and b'setup key' not in fresh.body,'Password reset never reopens password-only MFA enrollment')
print(checks,'MFA lifecycle HTTP checks passed.')
except Exception:
app.log.seek(0);print(app.log.read().decode(errors='replace')[-4500:]);raise
finally:
app.fixture('mfaTestAuditFailure','off');app.close()