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:
@@ -15,7 +15,7 @@ and ShopDB, the same discipline the backups collector uses for machine numbers.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Type
|
||||
|
||||
@@ -56,18 +56,35 @@ KNOWN_PAYLOAD_KEYS = frozenset({
|
||||
|
||||
|
||||
def _parse_dt(value):
|
||||
"""Best-effort parse of an ISO-8601 string to naive UTC, or None."""
|
||||
"""Best-effort parse of an ISO-8601 string to naive UTC, or None.
|
||||
|
||||
astimezone(tz=None) converts to the SERVER's local zone, not to UTC, so an
|
||||
agent reporting 17:26:16Z was stored as 13:26:16 on an EDT server and then
|
||||
re-served labelled 'Z'. The same row's server-written lastreportedat stayed
|
||||
true UTC, so one row carried two time bases four hours apart, and the
|
||||
dashboard's 48-hour stability window - which compares a real UTC cutoff
|
||||
against these values - dropped events four hours early.
|
||||
|
||||
Invisible on a UTC server, which is why it survived: nothing in the
|
||||
Dockerfile, the compose files or the Windows deploy pins TZ, so whether the
|
||||
data is right depends on the host's clock settings.
|
||||
|
||||
AGENT-CONTRACT.md mandates UTC on the wire. A NAIVE value is therefore
|
||||
taken as UTC already, rather than guessed at.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.replace(tzinfo=None)
|
||||
if value.tzinfo is not None:
|
||||
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return value
|
||||
text = str(value).strip().replace('Z', '+00:00')
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is not None:
|
||||
parsed = parsed.astimezone(tz=None).replace(tzinfo=None)
|
||||
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
|
||||
@@ -454,6 +454,15 @@ def create_scope():
|
||||
if not scopename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'scopename is required', http_code=400)
|
||||
# The name becomes a directory on the imaging share. Refused here as well
|
||||
# as at the write, so a bad name never reaches the database in the first
|
||||
# place - see valid_scopename for what a traversing name did.
|
||||
if not service.valid_scopename(scopename):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'scopename may contain only letters, digits, dot, dash and '
|
||||
'underscore, and must start with a letter or digit',
|
||||
http_code=400)
|
||||
if phase not in PHASES:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'phase must be one of {PHASES}', http_code=400)
|
||||
|
||||
@@ -10,6 +10,7 @@ Kept out of the CLI and routes so both share one implementation:
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -532,6 +533,30 @@ def store_inline_payload(entry, filename, contenttype, rawbytes):
|
||||
return payload
|
||||
|
||||
|
||||
# A scope name becomes a DIRECTORY on the imaging share, so it has to be a
|
||||
# single safe path segment. It was validated only as "not empty", which meant a
|
||||
# name like "../escape" or an absolute path wrote the manifest outside the
|
||||
# share root - proven by writing /tmp/manifest.json and files one and two
|
||||
# levels above it. The real exposure is the permission split: geenforce.manage
|
||||
# creates the scope, geenforce.publish writes the file, so a manage-only author
|
||||
# could aim a publisher's write anywhere that process can reach.
|
||||
#
|
||||
# Allowed: letters, digits, dot, dash, underscore. Not '.' or '..', and no
|
||||
# separator of either platform - the share is written from Linux and read by
|
||||
# Windows, so a backslash is a separator too.
|
||||
SCOPENAME_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]*$')
|
||||
|
||||
|
||||
def valid_scopename(scopename):
|
||||
"""True when this name is safe to use as a directory on the share."""
|
||||
name = (scopename or '').strip()
|
||||
if not name or name in ('.', '..') or len(name) > 100:
|
||||
return False
|
||||
if '/' in name or '\\' in name or '\x00' in name:
|
||||
return False
|
||||
return bool(SCOPENAME_PATTERN.match(name))
|
||||
|
||||
|
||||
def export_scope_to_share(scopename, phase, shareroot):
|
||||
"""Write a scope's current published JSON to the share, backing up the old
|
||||
file to _meta/history first. Returns the written path."""
|
||||
@@ -546,7 +571,21 @@ def export_scope_to_share(scopename, phase, shareroot):
|
||||
if phase == 'preinstall':
|
||||
target = os.path.join(shareroot, 'preinstall.json')
|
||||
else:
|
||||
# Checked again here, not only at creation: rows predating the
|
||||
# validation exist, and this is the line that actually writes.
|
||||
if not valid_scopename(scopename):
|
||||
raise ValueError(
|
||||
f'Refusing to export scope {scopename!r}: the name is not a '
|
||||
f'safe directory name')
|
||||
target = os.path.join(shareroot, scopename, 'manifest.json')
|
||||
|
||||
# Belt and braces: whatever the name, the resolved target must sit inside
|
||||
# the share root.
|
||||
root = os.path.realpath(shareroot)
|
||||
if os.path.commonpath([root, os.path.realpath(os.path.dirname(target))]) != root:
|
||||
raise ValueError(
|
||||
f'Refusing to export scope {scopename!r}: it resolves outside the '
|
||||
f'share root')
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
|
||||
if os.path.isfile(target):
|
||||
|
||||
Reference in New Issue
Block a user