Security hardening, multi-site portability, and a documentation and API-surface sweep

This commit is contained in:
2026-08-14 16:28:34 -04:00
parent 72f8da0e58
commit 265ca2eb01
76 changed files with 3684 additions and 811 deletions

View File

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

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

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