"""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'
([A-Z2-7]+)
',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()