Keep a share path inside the share, a UTC timestamp in UTC, and a public map public

Three confirmed findings from the 2026-09-03 run, each independent.

GE-Enforce scope names are directory names on the imaging share, and create_scope
checked only that the name was non-empty. "../escape" wrote a manifest one level
above the share root, "../../escape2" two, and "/tmp/anywhere" wherever it liked
- proven by writing the files and then deleting them. The exposure is the
permission split rather than the traversal itself: geenforce.manage creates the
scope and geenforce.publish writes the file, so a manage-only author could aim a
publisher's write at any path that process can reach. Names are now a single
safe path segment, checked at creation AND again at the write (rows predating the
check exist, and the write is the line that acts), with the resolved target
confirmed to sit under the share root.

Agent timestamps were converted with astimezone(tz=None), which means the
SERVER's local zone, not UTC. An agent reporting 17:26:16Z was stored as
13:26:16 on this EDT box and re-served labelled 'Z', while the same row's
server-written lastreportedat stayed true UTC - one row carrying two time bases
four hours apart. The dashboard's 48-hour stability window compares a real UTC
cutoff against these values, so events left it four hours early. Invisible on a
UTC server, which is how it survived, and nothing in the Dockerfile, the compose
files or the Windows deploy pins TZ. AGENT-CONTRACT.md mandates UTC on the wire,
so a naive value is now taken as UTC rather than guessed at.

The three /api/maplevels reads went back to optional-auth. ADR-017 rule 6, the
API inventory (auth "none"), the module's own docstring and the comment in
PrinterInstallerMap.vue all say they are public, because the printer installer
map runs before anyone logs in and needs a blueprint to draw on. afce2437 swept
165 genuinely-open GET routes behind require_permission and caught these in the
net, so the anonymous installer map has drawn no floor plan since. Restored
rather than amending the ADR: blueprint paths and level names are not secrets,
and the asset positions that go on top already render on public kiosk pages.
The writes stay gated, and a test proves it.

Registered in PUBLIC_ENDPOINTS with the reason. That gate checks a public route
is DECLARED public; the new test checks it actually answers, which is the half
that was broken.

Ten tests. All three verified by planting: restore the non-empty scope check and
eight fail, restore the local-zone conversion and two fail, put the permission
back on the level list and one fails.

Scope: product
This commit is contained in:
cproudlock
2026-09-03 14:34:12 -04:00
parent 5d81f50c33
commit ffced2f822
8 changed files with 273 additions and 7 deletions

View File

@@ -0,0 +1,56 @@
"""The floor-map level reads answer a caller with no account.
ADR-017 rule 6 says so, docs/api-inventory.json declares auth "none" for all
three, PrinterInstallerMap.vue carries a comment explaining why, and the module
docstring in shopdb/core/api/maplevels.py says the reads are public. The code
disagreed with all four: afce2437 swept 165 genuinely-open GET routes behind
require_permission and caught these in the net, so the anonymous installer map
drew no floor plan.
Nothing leaked - it failed closed - but a feature was off for a fortnight while
every document said it was on. test_public_read_surface.py checks that a public
route is REGISTERED as public; this checks that it actually answers, which is
the part that was broken.
"""
def _level(db):
from shopdb.core.models.maplevel import Building, MapLevel
building = Building(buildingname='Public Works', sortorder=1)
db.session.add(building)
db.session.flush()
level = MapLevel(buildingid=building.buildingid, levelname='Ground',
sortorder=1, blueprintlight='floorplan.svg')
db.session.add(level)
db.session.commit()
return level.levelid
def test_the_level_list_answers_an_anonymous_caller(client, db):
levelid = _level(db)
response = client.get('/api/maplevels')
assert response.status_code == 200, response.get_json()
payload = response.get_json()['data']
names = [level['levelname']
for building in payload['buildings']
for level in building['levels']]
assert 'Ground' in names
assert levelid
def test_one_level_answers_an_anonymous_caller(client, db):
levelid = _level(db)
response = client.get('/api/maplevels/%d' % levelid)
assert response.status_code == 200, response.get_json()
assert response.get_json()['data']['levelname'] == 'Ground'
def test_writes_are_still_gated(client, db):
"""The reads going back to public must not take the writes with them."""
levelid = _level(db)
assert client.post('/api/maplevels/buildings',
json={'buildingname': 'Sneaky'}).status_code in (401, 403)
assert client.put('/api/maplevels/%d' % levelid,
json={'levelname': 'Renamed'}).status_code in (401, 403)
assert client.delete('/api/maplevels/%d' % levelid).status_code in (401, 403)

View File

@@ -59,6 +59,13 @@ PUBLIC_ENDPOINTS = {
'notifications.get_employee_recognitions',
'slides.feed', 'slides.serve_image',
# The floor-map levels and their blueprints. ADR-017 rule 6: the printer
# installer map runs optional-auth before anyone logs in and needs a
# blueprint to draw on. Names and image paths, not asset data - and the
# asset positions that go on top already render on public kiosk pages.
'maplevels.list_levels', 'maplevels.get_level',
'maplevels.serve_blueprint',
# Parts kiosk: one item by its code, and that item's picture.
# kiosk_item takes no JWT but is NOT open: same kiosk flag + device token
# as the write. Gating only the write would have left the enumeration