Security hardening, multi-site portability, and a documentation and API-surface sweep
This commit is contained in:
@@ -4,11 +4,11 @@ from shopdb.utils.clientip import strip_port, client_ip
|
||||
|
||||
|
||||
def test_strip_ipv4_port():
|
||||
assert strip_port('10.134.48.20:52344') == '10.134.48.20'
|
||||
assert strip_port('192.0.2.20:52344') == '192.0.2.20'
|
||||
|
||||
|
||||
def test_ipv4_without_port_untouched():
|
||||
assert strip_port('10.134.48.20') == '10.134.48.20'
|
||||
assert strip_port('192.0.2.20') == '192.0.2.20'
|
||||
|
||||
|
||||
def test_bare_ipv6_untouched():
|
||||
|
||||
76
tests/test_core/test_relationship_soft_delete.py
Normal file
76
tests/test_core/test_relationship_soft_delete.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Deleting a relationship is soft, and everything that reads them must know.
|
||||
|
||||
Rows are retired with isactive = False, never removed, and assetrelationships is
|
||||
unique on (source, target, type). Anything that looks a relationship up without
|
||||
filtering on isactive therefore sees links the UI does not show - and refuses to
|
||||
create one it cannot create any other way, or retires the last live row believing
|
||||
another one covers it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rig(db):
|
||||
if not AssetType.query.filter_by(assettype='computer').first():
|
||||
db.session.add(AssetType(assettype='computer'))
|
||||
if not AssetType.query.filter_by(assettype='machine').first():
|
||||
db.session.add(AssetType(assettype='machine'))
|
||||
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
|
||||
if not controls:
|
||||
controls = RelationshipType(relationshiptype='controls', isdirectional=True)
|
||||
db.session.add(controls)
|
||||
controls.isdirectional = True
|
||||
db.session.commit()
|
||||
computer = AssetType.query.filter_by(assettype='computer').first()
|
||||
machine = AssetType.query.filter_by(assettype='machine').first()
|
||||
pc = Asset(assetnumber='PCSOFT01', assettypeid=computer.assettypeid)
|
||||
bay = Asset(assetnumber='BAYSOFT01', assettypeid=machine.assettypeid)
|
||||
db.session.add_all([pc, bay])
|
||||
db.session.commit()
|
||||
return {'pc': pc, 'bay': bay, 'controls': controls}
|
||||
|
||||
|
||||
def _create(client, headers, source, target, typeid):
|
||||
return client.post('/api/assets/relationships',
|
||||
json={'sourceassetid': source, 'targetassetid': target,
|
||||
'relationshiptypeid': typeid},
|
||||
headers=headers)
|
||||
|
||||
|
||||
def test_a_deleted_relationship_can_be_created_again(client, db, rig, auth_headers):
|
||||
"""Soft delete then re-add answered 409 about a link nothing displayed."""
|
||||
created = _create(client, auth_headers, rig['pc'].assetid, rig['bay'].assetid,
|
||||
rig['controls'].relationshiptypeid)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
relid = created.get_json()['data']['relationshipid']
|
||||
|
||||
assert client.delete('/api/assets/relationships/%d' % relid,
|
||||
headers=auth_headers).status_code == 200
|
||||
|
||||
again = _create(client, auth_headers, rig['pc'].assetid, rig['bay'].assetid,
|
||||
rig['controls'].relationshiptypeid)
|
||||
assert again.status_code in (200, 201), again.get_json()
|
||||
row = db.session.get(AssetRelationship, relid)
|
||||
assert row.isactive is True
|
||||
assert AssetRelationship.query.filter_by(
|
||||
sourceassetid=rig['pc'].assetid,
|
||||
targetassetid=rig['bay'].assetid).count() == 1
|
||||
|
||||
|
||||
def test_a_deleted_inverse_does_not_block_the_right_direction(
|
||||
client, db, rig, auth_headers):
|
||||
"""'Remove the existing one first' is the advice the guard itself gives."""
|
||||
wrong = _create(client, auth_headers, rig['bay'].assetid, rig['pc'].assetid,
|
||||
rig['controls'].relationshiptypeid)
|
||||
assert wrong.status_code == 201
|
||||
relid = wrong.get_json()['data']['relationshipid']
|
||||
assert client.delete('/api/assets/relationships/%d' % relid,
|
||||
headers=auth_headers).status_code == 200
|
||||
|
||||
right = _create(client, auth_headers, rig['pc'].assetid, rig['bay'].assetid,
|
||||
rig['controls'].relationshiptypeid)
|
||||
assert right.status_code in (200, 201), right.get_json()
|
||||
57
tests/test_core/test_security_headers.py
Normal file
57
tests/test_core/test_security_headers.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Responses carry the baseline headers, and uploads carry the strict ones.
|
||||
|
||||
The application image route is PUBLIC and one accepted image type is an XML
|
||||
document that can carry a script. Loaded through an <img> tag that script never
|
||||
runs, so the tiles were never the risk; opening the file's own URL is. These
|
||||
tests pin the headers that make that case inert, because the failure is silent -
|
||||
nothing about the page looks different when they are missing.
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.utils.uploads import UPLOAD_CSP
|
||||
|
||||
|
||||
def test_every_response_carries_the_baseline_headers(client):
|
||||
resp = client.get('/api/health')
|
||||
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||
assert resp.headers.get('X-Frame-Options') == 'SAMEORIGIN'
|
||||
assert resp.headers.get('Referrer-Policy') == 'strict-origin-when-cross-origin'
|
||||
|
||||
|
||||
def test_an_error_response_carries_them_too(client):
|
||||
"""after_request runs for error handlers as well, which is where a response
|
||||
built by a different code path would otherwise slip through."""
|
||||
resp = client.get('/api/no-such-route')
|
||||
assert resp.status_code == 404
|
||||
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||
|
||||
|
||||
def test_a_served_upload_is_sandboxed(client, app, tmp_path):
|
||||
"""The uploaded file itself gets a policy that blocks script execution."""
|
||||
from shopdb.core.api.applications import _appimage_dir
|
||||
|
||||
with app.app_context():
|
||||
directory = _appimage_dir()
|
||||
svg = ('<svg xmlns="http://www.w3.org/2000/svg">'
|
||||
'<script>window.top.location="http://evil"</script></svg>')
|
||||
path = directory / 'probe-headers.svg' if hasattr(directory, '__truediv__') \
|
||||
else None
|
||||
if path is None:
|
||||
import os
|
||||
path = os.path.join(directory, 'probe-headers.svg')
|
||||
with open(path, 'w') as handle:
|
||||
handle.write(svg)
|
||||
else:
|
||||
path.write_text(svg)
|
||||
|
||||
try:
|
||||
resp = client.get('/api/applications/image/probe-headers.svg')
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers.get('Content-Security-Policy') == UPLOAD_CSP
|
||||
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||
finally:
|
||||
import os
|
||||
os.remove(path)
|
||||
Reference in New Issue
Block a user