Files
dtf-system/local/security_test.py
Cauê Faleiros 4d707009ba
All checks were successful
Build and deploy / Validate source (push) Successful in 10s
Build and deploy / Publish images and notify Portainer (push) Successful in 56s
feat: configure Kanban login with optional email
2026-09-18 14:11:55 -03:00

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()