first commit
This commit is contained in:
66
local/security_test.py
Normal file
66
local/security_test.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user