generated from jric11/baseProject
ChatGPT's update to harden software and run db updates.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user