"""Non-destructive localhost security regressions. Leaves tiny test upload reservations.""" import base64 import os from urllib.error import HTTPError from urllib.request import Request, urlopen from urllib.parse import urlparse, parse_qs from uuid import uuid4 from .smoke_test import Client, BASE def raw(path, expected, headers=None, body=None): request=Request(BASE+path, data=body, headers=headers or {}) try: with urlopen(request,timeout=10) as response: assert response.status==expected return response.headers except HTTPError as error: assert error.code==expected,(path,error.code,expected) return error.headers def run(): headers=raw('/',200) policy=headers['Content-Security-Policy'] assert "script-src-attr 'none'" in policy and "frame-ancestors 'none'" in policy assert "'sha256-" in policy and "object-src 'none'" in policy raw('/api/health',400,{'Host':'attacker.invalid'}) raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'https://attacker.invalid'},b'{}') raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'http://localhost:9999'},b'{}') print('PASS: CSP, frame protection, Host and cross-origin rejection') operator=Client() credentials={'username':os.environ.get('OPERATOR_USER','operator'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')} encoded=base64.b64encode((credentials['username']+':'+credentials['password']).encode()).decode() raw('/api/operator/board',401,{'Authorization':'Basic '+encoded}) operator.call('/operator/login',credentials) token=next(c for c in operator.jar if c.name=='dtf_operator') assert token.has_nonstandard_attr('HttpOnly') and token.get_nonstandard_attr('SameSite')=='strict' assert token.path=='/api/operator' operator.call('/operator/board') replay=Client();replay.jar.set_cookie(token) operator.call('/operator/logout',{}) replay.call('/operator/board',expected=401) print('PASS: Basic rejected; HttpOnly scoped operator session; server-side logout revocation') client=Client();client.call('/session') client.call('/uploads',{'name':'payload.html','size':1},expected=422) uid=client.call('/uploads',{'name':'SECURITY-PART.cdr','size':3})['id'] url=client.call('/uploads/'+uid+'/parts/1',{})['url'] assert 'content-length' in parse_qs(urlparse(url).query)['X-Amz-SignedHeaders'][0] try: urlopen(Request(url,data=b'toolong',method='PUT'),timeout=10) raise AssertionError('Signed part accepted wrong length') except HTTPError as error:assert error.code==403,error.code with urlopen(Request(url,data=b'abc',method='PUT'),timeout=10) as response:assert response.status==200 client.call('/uploads/'+uid+'/complete',{}) count=int(os.environ.get('MAX_PENDING_UPLOADS','10')) for i in range(count):client.call('/uploads',{'name':'SECURITY-PENDING.cdr','size':1}) client.call('/uploads',{'name':'SECURITY-OVER-LIMIT.cdr','size':1},expected=429) print('PASS: extension allowlist, exact multipart Content-Length signature, pending upload quota') # Unique identity avoids locking out the real local operator. attacker=Client();username='test-'+uuid4().hex for _ in range(10):attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=401) attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=429) print('PASS: operator login throttling (only synthetic account bucket exhausted)') if __name__=='__main__':run()