67 lines
3.5 KiB
Python
67 lines
3.5 KiB
Python
"""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={'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')}
|
|
encoded=base64.b64encode((credentials['email']+':'+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();email='test-'+uuid4().hex+'@example.test'
|
|
for _ in range(10):attacker.call('/operator/login',{'email':email,'password':'invalid'},expected=401)
|
|
attacker.call('/operator/login',{'email':email,'password':'invalid'},expected=429)
|
|
print('PASS: operator login throttling (only synthetic account bucket exhausted)')
|
|
|
|
if __name__=='__main__':run()
|