fix: post-Phase-0 corrections — data-dir bind mounts, reserved subdomains, list_active()
Unit Tests / test (push) Successful in 11m31s
Unit Tests / test (push) Successful in 11m31s
Three related fixes discovered during review of Phase 0 and Phase 1 manifests:
1. validate_rendered_compose(): add allowed_data_dir param. After ${PIC_DATA_DIR}
substitution, compose templates produce absolute paths; without this the
validator would reject every service install. ServiceComposer.write_compose()
now passes its resolved data_dir so only the designated data directory is
exempt — /etc, /proc, docker.sock etc. still blocked.
2. _RESERVED_SUBDOMAINS: remove service-level subdomains (mail, calendar, files,
webdav, webmail). The reserved list should protect PIC infrastructure endpoints
(api, webui, admin) — not service subdomains that official store services
(calendar, files, webmail) must be allowed to claim. Aligns with the
existing _RESERVED_SUBS in service_registry.py.
3. ServiceRegistry.list_active(): new method returning only installed store
services (no builtins). This is the forward-looking API that Phase 2 will
make the primary read path once builtins are deleted. Adding it now unblocks
the QA agent's test_optional_services_feature.py which was already testing
the expected Phase 2 behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,8 +19,10 @@ _CAP_DENYLIST = frozenset({
|
|||||||
'SYS_BOOT', 'MAC_ADMIN', 'MAC_OVERRIDE', 'SYS_TIME', 'SYS_TTY_CONFIG',
|
'SYS_BOOT', 'MAC_ADMIN', 'MAC_OVERRIDE', 'SYS_TIME', 'SYS_TTY_CONFIG',
|
||||||
})
|
})
|
||||||
_RESERVED_SUBDOMAINS = frozenset({
|
_RESERVED_SUBDOMAINS = frozenset({
|
||||||
'api', 'webui', 'admin', 'www', 'mail', 'ns1', 'ns2', 'git', 'registry',
|
# Core PIC infrastructure — never allow store services to hijack these
|
||||||
'install', 'calendar', 'files', 'webdav', 'webmail',
|
'api', 'webui', 'admin', 'www', 'ns1', 'ns2', 'git', 'registry', 'install',
|
||||||
|
# 'mail', 'calendar', 'files', 'webdav', 'webmail' are intentionally absent:
|
||||||
|
# they belong to official PIC store services and must be claimable by them.
|
||||||
})
|
})
|
||||||
_BACKEND_DENYLIST = frozenset({
|
_BACKEND_DENYLIST = frozenset({
|
||||||
'cell-api', 'cell-caddy', 'cell-coredns', 'cell-dnsmasq',
|
'cell-api', 'cell-caddy', 'cell-coredns', 'cell-dnsmasq',
|
||||||
@@ -116,12 +118,16 @@ def validate_manifest(manifest: dict) -> tuple:
|
|||||||
return (len(errors) == 0, errors)
|
return (len(errors) == 0, errors)
|
||||||
|
|
||||||
|
|
||||||
def validate_rendered_compose(yaml_text: str) -> tuple:
|
def validate_rendered_compose(yaml_text: str, allowed_data_dir: str = None) -> tuple:
|
||||||
"""
|
"""
|
||||||
Parse and security-validate a rendered docker-compose YAML string.
|
Parse and security-validate a rendered docker-compose YAML string.
|
||||||
|
|
||||||
Returns (True, []) when safe; (False, [error_strings]) otherwise.
|
Returns (True, []) when safe; (False, [error_strings]) otherwise.
|
||||||
Rejects constructs that would give a store service elevated access to the host.
|
Rejects constructs that would give a store service elevated access to the host.
|
||||||
|
|
||||||
|
allowed_data_dir: when set, absolute bind mounts under this prefix are
|
||||||
|
permitted — they come from ${PIC_DATA_DIR} substitution and land in the
|
||||||
|
designated service data directory.
|
||||||
"""
|
"""
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
@@ -176,11 +182,14 @@ def validate_rendered_compose(yaml_text: str) -> tuple:
|
|||||||
elif cap_str not in _CAP_ALLOWLIST:
|
elif cap_str not in _CAP_ALLOWLIST:
|
||||||
errors.append(f'{prefix}: cap_add {cap_str!r} not in allowlist')
|
errors.append(f'{prefix}: cap_add {cap_str!r} not in allowlist')
|
||||||
|
|
||||||
# volumes — reject absolute host-side bind mounts
|
# volumes — reject absolute host-side bind mounts unless they're under
|
||||||
|
# the sanctioned data directory (injected by ServiceComposer via PIC_DATA_DIR)
|
||||||
for vol in svc.get('volumes') or []:
|
for vol in svc.get('volumes') or []:
|
||||||
vol_str = str(vol)
|
vol_str = str(vol)
|
||||||
src = vol_str.split(':')[0] if ':' in vol_str else vol_str
|
src = vol_str.split(':')[0] if ':' in vol_str else vol_str
|
||||||
if src.startswith('/'):
|
if src.startswith('/'):
|
||||||
|
if allowed_data_dir and src.startswith(allowed_data_dir):
|
||||||
|
continue
|
||||||
errors.append(
|
errors.append(
|
||||||
f'{prefix}: absolute host bind mount not allowed: {vol_str!r}'
|
f'{prefix}: absolute host bind mount not allowed: {vol_str!r}'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -156,7 +156,11 @@ class ServiceComposer:
|
|||||||
content = self.render_template(service_id, manifest, template_content)
|
content = self.render_template(service_id, manifest, template_content)
|
||||||
|
|
||||||
# Validate before any file I/O so a bad template never touches disk.
|
# Validate before any file I/O so a bad template never touches disk.
|
||||||
ok, errs = validate_rendered_compose(content)
|
# Pass the resolved data_dir so that bind mounts created by ${PIC_DATA_DIR}
|
||||||
|
# substitution are allowed; all other absolute paths are still rejected.
|
||||||
|
ok, errs = validate_rendered_compose(
|
||||||
|
content, allowed_data_dir=str(Path(self.data_dir).resolve())
|
||||||
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f'Compose template failed security validation: {"; ".join(errs)}'
|
f'Compose template failed security validation: {"; ".join(errs)}'
|
||||||
|
|||||||
@@ -92,6 +92,20 @@ class ServiceRegistry:
|
|||||||
return None
|
return None
|
||||||
return {**manifest, 'config': self._merged_config(manifest)}
|
return {**manifest, 'config': self._merged_config(manifest)}
|
||||||
|
|
||||||
|
def list_active(self) -> List[Dict]:
|
||||||
|
"""Return only installed store services, each with merged config.
|
||||||
|
|
||||||
|
Unlike list_all(), builtins are excluded. Use this wherever the
|
||||||
|
intent is "what has the admin chosen to run?" rather than "everything
|
||||||
|
the registry knows about."
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for _svc_id, record in self._cm.get_installed_services().items():
|
||||||
|
manifest = record.get('manifest') or {}
|
||||||
|
if manifest.get('id'):
|
||||||
|
results.append({**manifest, 'config': self._merged_config(manifest)})
|
||||||
|
return results
|
||||||
|
|
||||||
def list_all(self) -> List[Dict]:
|
def list_all(self) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Return all services — builtins first, then installed store services —
|
Return all services — builtins first, then installed store services —
|
||||||
|
|||||||
@@ -164,13 +164,15 @@ class TestValidateManifest(unittest.TestCase):
|
|||||||
ok, errs = validate_manifest(m)
|
ok, errs = validate_manifest(m)
|
||||||
self.assertTrue(ok)
|
self.assertTrue(ok)
|
||||||
|
|
||||||
def test_subdomain_calendar_rejected(self):
|
def test_subdomain_calendar_passes(self):
|
||||||
|
# calendar/files/webmail/etc are service subdomains, not infrastructure;
|
||||||
|
# official PIC store services need to claim them.
|
||||||
ok, errs = validate_manifest(_minimal_manifest(subdomain='calendar'))
|
ok, errs = validate_manifest(_minimal_manifest(subdomain='calendar'))
|
||||||
self.assertFalse(ok)
|
self.assertTrue(ok, errs)
|
||||||
|
|
||||||
def test_subdomain_files_rejected(self):
|
def test_subdomain_files_passes(self):
|
||||||
ok, errs = validate_manifest(_minimal_manifest(subdomain='files'))
|
ok, errs = validate_manifest(_minimal_manifest(subdomain='files'))
|
||||||
self.assertFalse(ok)
|
self.assertTrue(ok, errs)
|
||||||
|
|
||||||
# ── extra_subdomains ─────────────────────────────────────────────────
|
# ── extra_subdomains ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -489,6 +491,56 @@ class TestValidateRenderedCompose(unittest.TestCase):
|
|||||||
ok, errs = validate_rendered_compose(yaml_text)
|
ok, errs = validate_rendered_compose(yaml_text)
|
||||||
self.assertTrue(ok)
|
self.assertTrue(ok)
|
||||||
|
|
||||||
|
def test_absolute_volume_under_allowed_data_dir_passes(self):
|
||||||
|
# After ${PIC_DATA_DIR} substitution, compose templates produce absolute
|
||||||
|
# paths like /data/services/email/mail:/var/mail. These must be allowed
|
||||||
|
# when allowed_data_dir is set to the same prefix.
|
||||||
|
yaml_text = (
|
||||||
|
'services:\n'
|
||||||
|
' mail:\n'
|
||||||
|
' image: svc-email:latest\n'
|
||||||
|
' command: ["postfix"]\n'
|
||||||
|
' volumes:\n'
|
||||||
|
' - /data/services/email/mail:/var/mail\n'
|
||||||
|
' - /data/services/email/config:/tmp/config\n'
|
||||||
|
'networks:\n'
|
||||||
|
' cell-network:\n'
|
||||||
|
' external: true\n'
|
||||||
|
)
|
||||||
|
ok, errs = validate_rendered_compose(yaml_text, allowed_data_dir='/data')
|
||||||
|
self.assertTrue(ok, errs)
|
||||||
|
|
||||||
|
def test_absolute_volume_outside_allowed_data_dir_still_rejected(self):
|
||||||
|
yaml_text = (
|
||||||
|
'services:\n'
|
||||||
|
' app:\n'
|
||||||
|
' image: nginx\n'
|
||||||
|
' volumes:\n'
|
||||||
|
' - /etc/passwd:/etc/passwd\n'
|
||||||
|
'networks:\n'
|
||||||
|
' cell-network:\n'
|
||||||
|
' external: true\n'
|
||||||
|
)
|
||||||
|
ok, errs = validate_rendered_compose(yaml_text, allowed_data_dir='/data')
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertTrue(any('bind mount' in e for e in errs))
|
||||||
|
|
||||||
|
def test_absolute_volume_rejected_when_no_allowed_data_dir(self):
|
||||||
|
yaml_text = (
|
||||||
|
'services:\n'
|
||||||
|
' app:\n'
|
||||||
|
' image: nginx\n'
|
||||||
|
' volumes:\n'
|
||||||
|
' - /data/services/email/mail:/var/mail\n'
|
||||||
|
'networks:\n'
|
||||||
|
' cell-network:\n'
|
||||||
|
' external: true\n'
|
||||||
|
)
|
||||||
|
# Without allowed_data_dir, even /data paths are rejected
|
||||||
|
ok, errs = validate_rendered_compose(yaml_text)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertTrue(any('bind mount' in e for e in errs))
|
||||||
|
|
||||||
# ── cap_add ──────────────────────────────────────────────────────────
|
# ── cap_add ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_compose_cap_add_sys_admin_rejected(self):
|
def test_compose_cap_add_sys_admin_rejected(self):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user