diff --git a/VERSION b/VERSION index 737a538d..07a8425e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.65 +1.7.68 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index c3937fdd..880e2bea 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,4 +1,5 @@ import os +import sys from flask import Flask, send_from_directory, request, jsonify from flask_sqlalchemy import SQLAlchemy from flask_jwt_extended import JWTManager @@ -649,74 +650,91 @@ def create_app(config_name=None): import logging logging.getLogger(__name__).warning(f'Extension backend acquisition: {e}') - # Start metrics history collection in background - from app.services.metrics_history_service import MetricsHistoryService - if not MetricsHistoryService.is_running(): - MetricsHistoryService.start_collection(app) - - # Start queue-bus webhook consumer - from app.queue_bus.consumers import start_webhook_consumer - start_webhook_consumer(app) - - # Start queue-bus notification consumer (delivers in-app/email/chat) - from app.notifications.consumer import start_notification_consumer - start_notification_consumer(app) - - # Start the API analytics flush thread (a 5s buffer flush — a real-time - # stream, deliberately NOT modeled as a job). - from app.middleware.api_analytics import start_analytics_flush_thread - start_analytics_flush_thread(app) - - # Start the unified job system: ONE consumer runs every enqueued Job and - # ONE scheduler ticks all periodic work. This supersedes the former set - # of per-domain daemon scheduler threads (auto-sync, snapshot-retention, - # workflow, health-check, wp-update, api-background, pairing-prune, - # registrar-expiry) — they are now ScheduledJob rows backed by the - # built-in handlers in app/jobs/builtin_handlers.py. - from app.jobs import start_job_system - from app.jobs.builtin_handlers import register_builtin_handlers, seed_builtin_schedules - register_builtin_handlers() - # Register event-driven job handlers (deployment installs, workflow runs, - # scheduled backups). - from app.services.deployment_job_service import DeploymentJobService - DeploymentJobService.register_jobs() - # WorkflowEngine.register_jobs() removed in plan 45 Phase 4 (engine retired). - from app.services.backup_service import BackupService - BackupService.register_jobs() - from app.services.backup_policy_service import BackupPolicyService - BackupPolicyService.register_jobs() - from app.services.server_onboarding_service import ServerOnboardingService - ServerOnboardingService.register_jobs() - from app.services.preview_service import PreviewService - PreviewService.register_jobs() - from app.services.metadata_guard_service import MetadataGuardService - MetadataGuardService.register_jobs() - if not app.config.get('TESTING'): - MetadataGuardService.ensure() # converge the metadata egress rule (no-op when unsupported) - from app.services.speed_test_service import SpeedTestService - SpeedTestService.register_jobs() - from app.services import login_link_service - login_link_service.register_jobs() - from app.services.db_admin_sso_service import DbAdminSsoService - DbAdminSsoService.register_jobs() - from app.services.site_import_service import SiteImportService - SiteImportService.register_jobs() - from app.services.drift_service import DriftService - DriftService.register_jobs() - from app.services.doctor_service import DoctorService - DoctorService.register_jobs() - from app.services.file_integrity_service import FileIntegrityService - FileIntegrityService.register_jobs() - from app.services.malware_scan_service import MalwareScanService - MalwareScanService.register_jobs() - from app.services.bandwidth_service import BandwidthService - BandwidthService.register_jobs() - start_job_system(app, seed=seed_builtin_schedules) - - # Resume the embedded agent when this panel is linked to a master - # ServerKit panel (ServerKit-to-ServerKit peering). - from app.services.linked_panel_service import LinkedPanelService - LinkedPanelService.start_client_if_linked(app) + # Background daemons (metrics collector, queue consumers, analytics + # flush, the job system, the linked-panel client) only make sense in a + # long-running server process. When the app is loaded by a Flask CLI + # one-shot — crucially `flask db upgrade` during an update — they must + # NOT start: they query the database before migrations have run, and + # any failure there (a corrupt DB, or pre-migration schema the new code + # doesn't match yet) aborts the CLI command and sinks the whole update. + # SERVERKIT_SKIP_BACKGROUND=1 forces the same skip; the updater sets it + # as an explicit contract when running migrations. + _cli_one_shot = ( + os.path.basename(sys.argv[0] or '').startswith('flask') + and (len(sys.argv) < 2 or sys.argv[1] != 'run') + ) + _skip_background = ( + os.environ.get('SERVERKIT_SKIP_BACKGROUND') == '1' or _cli_one_shot + ) + if not _skip_background: + # Start metrics history collection in background + from app.services.metrics_history_service import MetricsHistoryService + if not MetricsHistoryService.is_running(): + MetricsHistoryService.start_collection(app) + + # Start queue-bus webhook consumer + from app.queue_bus.consumers import start_webhook_consumer + start_webhook_consumer(app) + + # Start queue-bus notification consumer (delivers in-app/email/chat) + from app.notifications.consumer import start_notification_consumer + start_notification_consumer(app) + + # Start the API analytics flush thread (a 5s buffer flush — a real-time + # stream, deliberately NOT modeled as a job). + from app.middleware.api_analytics import start_analytics_flush_thread + start_analytics_flush_thread(app) + + # Start the unified job system: ONE consumer runs every enqueued Job and + # ONE scheduler ticks all periodic work. This supersedes the former set + # of per-domain daemon scheduler threads (auto-sync, snapshot-retention, + # workflow, health-check, wp-update, api-background, pairing-prune, + # registrar-expiry) — they are now ScheduledJob rows backed by the + # built-in handlers in app/jobs/builtin_handlers.py. + from app.jobs import start_job_system + from app.jobs.builtin_handlers import register_builtin_handlers, seed_builtin_schedules + register_builtin_handlers() + # Register event-driven job handlers (deployment installs, workflow runs, + # scheduled backups). + from app.services.deployment_job_service import DeploymentJobService + DeploymentJobService.register_jobs() + # WorkflowEngine.register_jobs() removed in plan 45 Phase 4 (engine retired). + from app.services.backup_service import BackupService + BackupService.register_jobs() + from app.services.backup_policy_service import BackupPolicyService + BackupPolicyService.register_jobs() + from app.services.server_onboarding_service import ServerOnboardingService + ServerOnboardingService.register_jobs() + from app.services.preview_service import PreviewService + PreviewService.register_jobs() + from app.services.metadata_guard_service import MetadataGuardService + MetadataGuardService.register_jobs() + if not app.config.get('TESTING'): + MetadataGuardService.ensure() # converge the metadata egress rule (no-op when unsupported) + from app.services.speed_test_service import SpeedTestService + SpeedTestService.register_jobs() + from app.services import login_link_service + login_link_service.register_jobs() + from app.services.db_admin_sso_service import DbAdminSsoService + DbAdminSsoService.register_jobs() + from app.services.site_import_service import SiteImportService + SiteImportService.register_jobs() + from app.services.drift_service import DriftService + DriftService.register_jobs() + from app.services.doctor_service import DoctorService + DoctorService.register_jobs() + from app.services.file_integrity_service import FileIntegrityService + FileIntegrityService.register_jobs() + from app.services.malware_scan_service import MalwareScanService + MalwareScanService.register_jobs() + from app.services.bandwidth_service import BandwidthService + BandwidthService.register_jobs() + start_job_system(app, seed=seed_builtin_schedules) + + # Resume the embedded agent when this panel is linked to a master + # ServerKit panel (ServerKit-to-ServerKit peering). + from app.services.linked_panel_service import LinkedPanelService + LinkedPanelService.start_client_if_linked(app) # Request body size limit app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100MB limit diff --git a/backend/app/api/files.py b/backend/app/api/files.py index d8579b6a..7e151aa3 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -1,7 +1,7 @@ """File Manager API endpoints for browsing, editing, and managing files.""" from flask import Blueprint, request, jsonify, send_file -from flask_jwt_extended import jwt_required +from ..middleware.rbac import permission_required from ..services.file_service import FileService from ..services.storage_provider_service import StorageProviderService import os @@ -11,7 +11,7 @@ @files_bp.route('/browse', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def browse_directory(): """List directory contents.""" path = request.args.get('path', '/home') @@ -25,7 +25,7 @@ def browse_directory(): @files_bp.route('/info', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def get_file_info(): """Get information about a file or directory.""" path = request.args.get('path') @@ -46,7 +46,7 @@ def get_file_info(): @files_bp.route('/read', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def read_file(): """Read file contents.""" path = request.args.get('path') @@ -64,7 +64,7 @@ def read_file(): @files_bp.route('/write', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def write_file(): """Write content to a file.""" data = request.get_json() @@ -92,7 +92,7 @@ def write_file(): @files_bp.route('/create', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def create_file(): """Create a new file.""" data = request.get_json() @@ -116,7 +116,7 @@ def create_file(): @files_bp.route('/mkdir', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def create_directory(): """Create a new directory.""" data = request.get_json() @@ -139,7 +139,7 @@ def create_directory(): @files_bp.route('/delete', methods=['DELETE']) -@jwt_required() +@permission_required('files', 'write') def delete_path(): """Delete a file or directory.""" path = request.args.get('path') @@ -157,7 +157,7 @@ def delete_path(): @files_bp.route('/rename', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def rename_path(): """Rename a file or directory.""" data = request.get_json() @@ -181,7 +181,7 @@ def rename_path(): @files_bp.route('/copy', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def copy_path(): """Copy a file or directory.""" data = request.get_json() @@ -205,7 +205,7 @@ def copy_path(): @files_bp.route('/move', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def move_path(): """Move a file or directory.""" data = request.get_json() @@ -229,7 +229,7 @@ def move_path(): @files_bp.route('/chmod', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def change_permissions(): """Change file/directory permissions.""" data = request.get_json() @@ -253,7 +253,7 @@ def change_permissions(): @files_bp.route('/search', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def search_files(): """Search for files matching a pattern.""" directory = request.args.get('directory', '/home') @@ -273,7 +273,7 @@ def search_files(): @files_bp.route('/disk-usage', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def get_disk_usage(): """Get disk usage for a path.""" path = request.args.get('path', '/') @@ -286,7 +286,7 @@ def get_disk_usage(): @files_bp.route('/disk-mounts', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def get_disk_mounts(): """Get disk usage for all mount points.""" result = FileService.get_all_disk_mounts() @@ -297,7 +297,7 @@ def get_disk_mounts(): @files_bp.route('/analyze', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def analyze_directory(): """Analyze directory sizes.""" path = request.args.get('path', '/home') @@ -314,7 +314,7 @@ def analyze_directory(): @files_bp.route('/type-breakdown', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def get_type_breakdown(): """Get file type breakdown for a directory.""" path = request.args.get('path', '/home') @@ -330,7 +330,7 @@ def get_type_breakdown(): @files_bp.route('/download', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def download_file(): """Download a file.""" path = request.args.get('path') @@ -358,7 +358,7 @@ def download_file(): @files_bp.route('/upload', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def upload_file(): """Upload a file.""" if 'file' not in request.files: @@ -419,7 +419,7 @@ def upload_file(): # ── S3 / object-storage browser (reuses the configured backup storage creds) ── @files_bp.route('/s3/browse', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def s3_browse(): """List a bucket prefix in the same entry shape as the local browser.""" path = request.args.get('path', '/') @@ -428,7 +428,7 @@ def s3_browse(): @files_bp.route('/s3/read', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def s3_read(): """Read a text object for in-app editing.""" path = request.args.get('path') @@ -439,7 +439,7 @@ def s3_read(): @files_bp.route('/s3/write', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def s3_write(): """Write (create or overwrite) an object from text content.""" data = request.get_json() or {} @@ -454,7 +454,7 @@ def s3_write(): @files_bp.route('/s3/delete', methods=['DELETE']) -@jwt_required() +@permission_required('files', 'write') def s3_delete(): """Delete an object (or every object beneath a prefix).""" path = request.args.get('path') @@ -465,7 +465,7 @@ def s3_delete(): @files_bp.route('/s3/download-url', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def s3_download_url(): """Return a short-lived presigned URL the browser can download directly.""" path = request.args.get('path') @@ -476,7 +476,7 @@ def s3_download_url(): @files_bp.route('/s3/upload', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def s3_upload(): """Upload a file into the bucket at the given prefix.""" if 'file' not in request.files: diff --git a/backend/app/services/log_service.py b/backend/app/services/log_service.py index 96498112..3120bdc3 100644 --- a/backend/app/services/log_service.py +++ b/backend/app/services/log_service.py @@ -315,8 +315,13 @@ def _read_syslog(cls, filepath: str, service: str, lines: int) -> Dict: """Read system logs from a syslog file, optionally filtering by service.""" try: if service: + # No shell: list2cmdline() is cmd.exe-style quoting and does + # not protect against $(...)/backtick expansion under bash. + # grep runs as argv and the tail happens in Python instead. + # -F: the unit name is a literal string, not a regex — names + # like 'nginx.service' must not have '.' match any character. result = run_privileged( - ['bash', '-c', f'grep -i {subprocess.list2cmdline([service])} {subprocess.list2cmdline([filepath])} | tail -n {int(lines)}'], + ['grep', '-F', '-i', '--', service, filepath], timeout=60, ) else: @@ -327,6 +332,13 @@ def _read_syslog(cls, filepath: str, service: str, lines: int) -> Dict: if result.returncode == 0 or (service and result.returncode == 1): log_lines = result.stdout.split('\n') if result.stdout else [] + if log_lines and log_lines[-1] == '': + log_lines.pop() # trailing newline artifact + if service: + # Clamp like `tail -n`: -0 would slice as [0:] (all + # lines) and a negative count would keep nearly all. + tail_lines = max(0, int(lines)) + log_lines = log_lines[-tail_lines:] if tail_lines else [] return sourced_result(log_lines, 'syslog', filepath) else: return {'success': False, 'error': result.stderr} diff --git a/backend/app/services/postfix_service.py b/backend/app/services/postfix_service.py index ebbc9ce4..a90c9fe9 100644 --- a/backend/app/services/postfix_service.py +++ b/backend/app/services/postfix_service.py @@ -131,12 +131,18 @@ def get_status(cls) -> Dict: def install(cls, hostname: str = None) -> Dict: """Install Postfix.""" try: - # Pre-seed debconf to avoid interactive prompts + if hostname and not re.match(r'^[a-zA-Z0-9.-]+$', hostname): + return {'success': False, 'error': 'Invalid hostname format'} + + # Pre-seed debconf to avoid interactive prompts. The values are + # piped via stdin so no user input is ever interpolated into a + # shell command string (GHSA-mc93-rc3x-fpgq). if PackageManager.detect() == 'apt': - run_privileged(['bash', '-c', - 'echo "postfix postfix/mailname string ' + (hostname or 'localhost') + '" | debconf-set-selections']) - run_privileged(['bash', '-c', - 'echo "postfix postfix/main_mailer_type select Internet Site" | debconf-set-selections']) + debconf_lines = ( + f'postfix postfix/mailname string {hostname or "localhost"}\n' + 'postfix postfix/main_mailer_type select Internet Site\n' + ) + run_privileged(['debconf-set-selections'], input=debconf_lines) result = PackageManager.install(['postfix'], timeout=300) if result.returncode != 0: diff --git a/backend/tests/test_files_rbac.py b/backend/tests/test_files_rbac.py new file mode 100644 index 00000000..8be34026 --- /dev/null +++ b/backend/tests/test_files_rbac.py @@ -0,0 +1,204 @@ +"""Regression tests for two security advisories. + +GHSA-4wqh-7f4f-5qmx — the File Manager API only required a valid JWT, so a +viewer account (files.write=False) could write/delete files. Mutating +endpoints must now require the 'files' write permission. + +GHSA-mc93-rc3x-fpgq — PostfixService.install() interpolated the hostname into +a bash -c string. Hostnames are now validated and debconf values are piped +via stdin, never through a shell. +""" +import subprocess + +import pytest + +from app import db as _db +from app.models import User +from app.services.postfix_service import PostfixService + + +@pytest.fixture +def role_headers(app): + """JWT headers for a viewer and a developer user.""" + from flask_jwt_extended import create_access_token + from werkzeug.security import generate_password_hash + + headers = {} + with app.app_context(): + for name, role in (('files_viewer', User.ROLE_VIEWER), + ('files_dev', User.ROLE_DEVELOPER)): + u = User(email=f'{name}@t.local', username=name, + password_hash=generate_password_hash('x'), + role=role, is_active=True) + _db.session.add(u) + _db.session.commit() + headers[role] = {'Authorization': f'Bearer {create_access_token(identity=u.id)}'} + return headers + + +# --------------------------------------------------------------------------- # +# GHSA-4wqh-7f4f-5qmx: viewer role blocked from file write/delete operations +# --------------------------------------------------------------------------- # + +WRITE_CASES = [ + ('post', '/api/v1/files/write', {'json': {'path': '/tmp/x', 'content': 'x'}}), + ('post', '/api/v1/files/create', {'json': {'path': '/tmp/x'}}), + ('post', '/api/v1/files/mkdir', {'json': {'path': '/tmp/x'}}), + ('delete', '/api/v1/files/delete?path=/tmp/x', {}), + ('post', '/api/v1/files/rename', {'json': {'path': '/tmp/x', 'new_name': 'y'}}), + ('post', '/api/v1/files/copy', {'json': {'src': '/tmp/x', 'dest': '/tmp/y'}}), + ('post', '/api/v1/files/move', {'json': {'src': '/tmp/x', 'dest': '/tmp/y'}}), + ('post', '/api/v1/files/chmod', {'json': {'path': '/tmp/x', 'mode': '755'}}), + ('post', '/api/v1/files/upload', {}), + ('post', '/api/v1/files/s3/write', {'json': {'path': '/x', 'content': 'x'}}), + ('post', '/api/v1/files/s3/upload', {}), + ('delete', '/api/v1/files/s3/delete?path=/x', {}), +] + + +@pytest.mark.parametrize('method,url,kwargs', WRITE_CASES) +def test_viewer_cannot_mutate_files(client, role_headers, method, url, kwargs): + resp = getattr(client, method)(url, headers=role_headers[User.ROLE_VIEWER], **kwargs) + assert resp.status_code == 403 + assert 'files' in resp.get_json()['error'] + + +@pytest.mark.parametrize('method,url,kwargs', WRITE_CASES) +def test_developer_passes_files_write_gate(client, role_headers, monkeypatch, + method, url, kwargs): + """A role with files.write=True must get past the permission gate. + + The underlying services are stubbed so the test never touches the real + filesystem or S3; we only assert the gate does not return 403. + """ + from app.services.file_service import FileService + from app.services.storage_provider_service import StorageProviderService + + ok = {'success': True} + for name in ('write_file', 'create_file', 'create_directory', 'delete', + 'rename', 'copy', 'move', 'change_permissions'): + monkeypatch.setattr(FileService, name, staticmethod(lambda *a, **k: dict(ok))) + monkeypatch.setattr(StorageProviderService, 's3_write', + staticmethod(lambda *a, **k: dict(ok))) + monkeypatch.setattr(StorageProviderService, 's3_delete', + staticmethod(lambda *a, **k: dict(ok))) + + resp = getattr(client, method)(url, headers=role_headers[User.ROLE_DEVELOPER], **kwargs) + assert resp.status_code != 403 + + +def test_viewer_can_still_browse(client, role_headers): + """Read endpoints stay reachable for viewers (files.read=True).""" + resp = client.get('/api/v1/files/browse?path=/tmp', + headers=role_headers[User.ROLE_VIEWER]) + assert resp.status_code != 403 + + +# --------------------------------------------------------------------------- # +# files.read revocation: a user whose files.read permission is disabled must +# be blocked from read endpoints too, not just writes +# --------------------------------------------------------------------------- # + +READ_CASES = [ + '/api/v1/files/browse?path=/tmp', + '/api/v1/files/info?path=/tmp/x', + '/api/v1/files/read?path=/tmp/x', + '/api/v1/files/search?path=/tmp&query=x', + '/api/v1/files/disk-usage', + '/api/v1/files/disk-mounts', + '/api/v1/files/analyze?path=/tmp', + '/api/v1/files/type-breakdown?path=/tmp', + '/api/v1/files/download?path=/tmp/x', + '/api/v1/files/s3/browse?path=/', + '/api/v1/files/s3/read?path=/x', + '/api/v1/files/s3/download-url?path=/x', +] + + +@pytest.fixture +def no_read_headers(app): + """JWT headers for a viewer whose files.read permission is revoked.""" + from flask_jwt_extended import create_access_token + from werkzeug.security import generate_password_hash + + with app.app_context(): + u = User(email='files_noread@t.local', username='files_noread', + password_hash=generate_password_hash('x'), + role=User.ROLE_VIEWER, is_active=True) + perms = {f: {'read': True, 'write': False} for f in User.PERMISSION_FEATURES} + perms['files'] = {'read': False, 'write': False} + u.set_permissions(perms) + _db.session.add(u) + _db.session.commit() + return {'Authorization': f'Bearer {create_access_token(identity=u.id)}'} + + +@pytest.mark.parametrize('url', READ_CASES) +def test_revoked_files_read_blocks_read_endpoints(client, no_read_headers, url): + resp = client.get(url, headers=no_read_headers) + assert resp.status_code == 403 + assert 'files' in resp.get_json()['error'] + + +# --------------------------------------------------------------------------- # +# log_service._read_syslog: the service filter must never go through a shell +# (subprocess.list2cmdline is cmd.exe quoting and does not stop $(...)/backtick +# expansion under bash) +# --------------------------------------------------------------------------- # + +def test_read_syslog_does_not_use_a_shell(monkeypatch): + from app.services import log_service + from app.services.log_service import LogService + + captured = {} + + def fake_run_privileged(cmd, **kwargs): + captured['cmd'] = cmd + return subprocess.CompletedProcess(cmd, 0, stdout='match1\nmatch2\n', stderr='') + + monkeypatch.setattr(log_service, 'run_privileged', fake_run_privileged) + + payload = 'x$(id)`id`' + result = LogService._read_syslog('/var/log/syslog', payload, 100) + + cmd = captured['cmd'] + assert 'bash' not in cmd and '-c' not in cmd + # the payload travels as a standalone argv element, verbatim + assert payload in cmd + assert result['success'] + + +def test_read_syslog_tails_matches_in_python(monkeypatch): + from app.services import log_service + from app.services.log_service import LogService + + def fake_run_privileged(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, stdout='l1\nl2\nl3\n', stderr='') + + monkeypatch.setattr(log_service, 'run_privileged', fake_run_privileged) + + result = LogService._read_syslog('/var/log/syslog', 'nginx', 2) + assert result['lines'] == ['l2', 'l3'] + + +# --------------------------------------------------------------------------- # +# GHSA-mc93-rc3x-fpgq: postfix install rejects malicious hostnames +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize('hostname', [ + '"; id > /tmp/pwned; echo "', + '$(id)', + '`id`', + 'mail.example.com; rm -rf /', + 'host name with spaces', +]) +def test_postfix_install_rejects_bad_hostname(hostname): + result = PostfixService.install(hostname=hostname) + assert result == {'success': False, 'error': 'Invalid hostname format'} + + +@pytest.mark.parametrize('hostname', ['mail.example.com', 'mx-1.example.org', 'localhost']) +def test_postfix_install_accepts_valid_hostname_format(hostname): + """Valid hostnames pass validation (install itself is not executed).""" + import re + assert re.match(r'^[a-zA-Z0-9.-]+$', hostname) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d439fd1e..98bca9a0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5465,9 +5465,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -5485,7 +5485,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/frontend/package.json b/frontend/package.json index 4cdd86c9..d94e8997 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -64,7 +64,6 @@ "overrides": { "lodash": "4.18.1", "js-yaml": "4.3.0", - "postcss": "8.5.16", "@babel/core": "7.29.7", "ws": "8.21.0", "engine.io-client": "6.6.6" diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 9af0732b..20769f1f 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -13,7 +13,7 @@ import { sanitizeSvgInner } from '../utils/sanitizeSvg'; import useModules from '../hooks/useModules'; const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {} }) => { - const { user, logout, updateUser } = useAuth(); + const { user, logout, updateUser, hasPermission } = useAuth(); const { theme, resolvedTheme, setTheme, whiteLabel } = useTheme(); const { layout, setLayout } = useLayout(); const navigate = useNavigate(); @@ -162,6 +162,12 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => { let items = [...core, ...fromPlugins].filter( (item) => !item.requiresCondition || conds[item.requiresCondition] ); + // Per-user feature permissions: a user whose files.read is revoked + // (custom permissions override the role template) gets 403s from every + // /api/v1/files endpoint, so don't surface the File Manager at all. + if (!hasPermission('files', 'read')) { + items = items.filter((item) => item.id !== 'files'); + } // Extension-contributed tab-group tabs (#43) keep the host group's // sidebar item lit on extension-owned tab routes (group id == sidebar // item id) — the core matchPrefixes only cover the group's own tabs. @@ -188,7 +194,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => { } } return applyWorkspaceNavPermissions(items, activeWorkspace, user); - }, [user?.sidebar_config, pluginNav, pluginTabs, wpInstalled, gpuAvailable, wordpressEnabled, user]); + }, [user?.sidebar_config, pluginNav, pluginTabs, wpInstalled, gpuAvailable, wordpressEnabled, user, hasPermission]); // Group visible items by category const groupedItems = useMemo(() => { diff --git a/frontend/src/hooks/usePaletteAuthz.js b/frontend/src/hooks/usePaletteAuthz.js index d7ac8e78..3dddd576 100644 --- a/frontend/src/hooks/usePaletteAuthz.js +++ b/frontend/src/hooks/usePaletteAuthz.js @@ -20,7 +20,7 @@ const ALWAYS_VISIBLE = new Set( * you hid from your sidebar for tidiness stays reachable via the palette. */ export default function usePaletteAuthz() { - const { isAdmin } = useAuth(); + const { isAdmin, hasPermission } = useAuth(); return useMemo(() => { let navMap = null; @@ -38,6 +38,9 @@ export default function usePaletteAuthz() { if (!navId) return true; if (isAdmin) return true; if (ALWAYS_VISIBLE.has(navId)) return true; + // Per-user feature permissions: files.read=false 403s every files + // endpoint, so don't offer the File Manager in the palette either. + if (navId === 'files' && !hasPermission('files', 'read')) return false; if (!navMap) return true; const allowed = navMap[role]; if (!Array.isArray(allowed) || allowed.length === 0) return true; @@ -52,5 +55,5 @@ export default function usePaletteAuthz() { return { isAdmin, allowNav, allowItem }; // Recomputes on login/role change (isAdmin flips); the workspace nav map // is read from localStorage at build time and isn't otherwise reactive. - }, [isAdmin]); + }, [isAdmin, hasPermission]); } diff --git a/scripts/test/test_update.sh b/scripts/test/test_update.sh index 991cf944..4260db5d 100644 --- a/scripts/test/test_update.sh +++ b/scripts/test/test_update.sh @@ -244,6 +244,8 @@ t="$WORK/t10/serverkit-b" mkdir -p "$t/venv/bin" "$t/backend/instance" : > "$t/venv/bin/activate" # sourceable no-op : > "$t/backend/instance/serverkit.db" # the slot's DB copy +printf '#!/usr/bin/env bash\necho ok\n' > "$t/venv/bin/python" # integrity probe: healthy DB +chmod +x "$t/venv/bin/python" printf 'DATABASE_URL=sqlite:///opt/serverkit/backend/instance/serverkit.db\n' > "$t/.env" FLASK_CAP="$WORK/t10/flask-saw-dburl" cat > "$STUB_BIN/flask" < "$t/venv/bin/activate" +: > "$t/backend/instance/serverkit.db" +printf 'DATABASE_URL=sqlite:///opt/serverkit/backend/instance/serverkit.db\n' > "$t/.env" +printf '#!/usr/bin/env bash\necho "database disk image is malformed"\n' > "$t/venv/bin/python" +chmod +x "$t/venv/bin/python" +FLASK_CAP="$WORK/t10b/flask-ran" +cat > "$STUB_BIN/flask" </dev/null 2>&1 || mig_rc=$? +if [ "$mig_rc" -ne 0 ] && [ ! -e "$FLASK_CAP" ]; then + ok "migrate_database halts on a corrupt SQLite DB before flask runs (fail-fast)" +else + bad "migrate_database corrupt-DB: rc=$mig_rc (want non-zero), flask-ran=$([ -e "$FLASK_CAP" ] && echo yes || echo no) (want no)" +fi +rm -f "$STUB_BIN/flask" + +# -------------------------------------------------------------------------- +# T10c — a probe that can't run at all (no usable venv python) is NOT proof +# of corruption: warn and proceed so flask can surface any real problem. +# -------------------------------------------------------------------------- +t="$WORK/t10c/serverkit-b" +mkdir -p "$t/venv/bin" "$t/backend/instance" +: > "$t/venv/bin/activate" +: > "$t/backend/instance/serverkit.db" +printf 'DATABASE_URL=sqlite:///opt/serverkit/backend/instance/serverkit.db\n' > "$t/.env" +printf '#!/usr/bin/env bash\nexit 0\n' > "$STUB_BIN/flask" +chmod +x "$STUB_BIN/flask" +mig_rc=0 +( + set -Eeuo pipefail + DRY_RUN=0 + migrate_database "$t" +) >/dev/null 2>&1 || mig_rc=$? +if [ "$mig_rc" -eq 0 ]; then + ok "migrate_database proceeds (with a warning) when the integrity probe can't run" +else + bad "migrate_database probe-unavailable: rc=$mig_rc, expected rc 0 (warn + proceed)" +fi +rm -f "$STUB_BIN/flask" # nginx and must NEVER stop it. Host nginx fronts every managed app, so a stop # during a panel update used to black out unrelated sites. A recording systemctl # stub (PATH-prepended ahead of the global stub) captures every invocation. @@ -701,6 +757,8 @@ t="$WORK/t24/serverkit-b" mkdir -p "$t/venv/bin" "$t/backend/instance" : > "$t/venv/bin/activate" : > "$t/backend/instance/serverkit.db" +printf '#!/usr/bin/env bash\necho ok\n' > "$t/venv/bin/python" # integrity probe: healthy DB +chmod +x "$t/venv/bin/python" printf 'DATABASE_URL=sqlite:////opt/serverkit/backend/instance/serverkit.db\n' > "$t/.env" printf '#!/usr/bin/env bash\nexit 0\n' > "$STUB_BIN/flask" chmod +x "$STUB_BIN/flask" diff --git a/scripts/update.sh b/scripts/update.sh index 4cfe5828..20687ca7 100644 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -632,12 +632,32 @@ rebuild_virtualenv() { good "Virtual environment rebuilt at $target_dir" } -# Ensure the target directory has a usable venv. If a pre-built one exists, use -# it; otherwise rebuild from requirements. +# Ensure the target directory has a usable venv. If a pre-built one exists AND +# is bound to this slot's path, use it; otherwise rebuild from requirements. require_venv() { local target_dir="$1" if [ -f "$target_dir/bin/activate" ] && [ -x "$target_dir/bin/python" ]; then - good "Virtual environment ready at $target_dir" + # A venv is path-bound: bin/activate and every entry-point shebang + # embed the absolute path the venv was created at. The release + # tarball's prebuilt venv is baked as $INSTALL_DIR/venv, which — + # until atomic_switch flips the symlink — resolves to the OLD slot. + # Running it would execute the new code with the old slot's + # dependencies (and run the OLD flask binary for migrations), so a + # venv bound to any other path must be rebuilt in place. + local bound_path resolved_bound resolved_target + bound_path="$(sed -n 's/^VIRTUAL_ENV="\(.*\)"$/\1/p' "$target_dir/bin/activate" | head -1)" + # Compare RESOLVED paths: on a fresh install the symlink already points + # at the only slot, so the baked path resolves to the target and the + # prebuilt venv is genuinely usable (fast path). During an update it + # resolves to the OLD slot — that mismatch triggers the rebuild. + resolved_bound="$(readlink -f "$bound_path" 2>/dev/null || true)" + resolved_target="$(readlink -f "$target_dir" 2>/dev/null || echo "$target_dir")" + if [ -n "$resolved_bound" ] && [ "$resolved_bound" = "$resolved_target" ]; then + good "Virtual environment ready at $target_dir" + return 0 + fi + warn "Virtual environment at $target_dir is bound to '${bound_path:-unknown path}' — rebuilding for this slot" + rebuild_virtualenv "$target_dir" return 0 fi warn "Virtual environment missing at $target_dir" @@ -667,16 +687,41 @@ migrate_database() { # Subshell: the venv activation and the cd must not leak into the main # shell — the rest of the update keeps running from the caller's cwd. local slot_db="$work_dir/backend/instance/serverkit.db" + local use_slot_db=0 + if grep -qE '^DATABASE_URL=sqlite' "$work_dir/.env" 2>/dev/null && [ -f "$slot_db" ]; then + use_slot_db=1 + # Fail fast with an actionable message when the SQLite slot copy is + # corrupt, instead of dying deep inside app boot with "database disk + # image is malformed". The sqlite3 CLI isn't guaranteed to be + # installed — probe via the venv's Python instead. An EMPTY result + # means the probe itself couldn't run (no usable venv python) — that + # is not proof of corruption, so warn and let `flask db upgrade` + # surface any real problem rather than halting on a false positive. + local integrity + integrity="$("$venv/bin/python" -c \ + "import sqlite3,sys;print(sqlite3.connect(sys.argv[1]).execute('PRAGMA integrity_check').fetchone()[0])" \ + "$slot_db" 2>/dev/null || true)" + if [ -n "$integrity" ] && [ "$integrity" != "ok" ]; then + halt "SQLite database is corrupt ($integrity). Repair or restore it from $BACKUP_DIR and re-run the update. The previous installation is still active." + elif [ -z "$integrity" ]; then + warn "Could not pre-check SQLite integrity (venv python unavailable) — proceeding" + fi + fi if ! ( # shellcheck source=/dev/null source "$venv/bin/activate" cd "$work_dir/backend" - if grep -qE '^DATABASE_URL=sqlite' "$work_dir/.env" 2>/dev/null && [ -f "$slot_db" ]; then - DATABASE_URL="sqlite:///$slot_db" FLASK_ENV=production flask db upgrade + # SERVERKIT_SKIP_BACKGROUND=1: create_app() must not start queue + # consumers / schedulers / collectors while we only want migrations — + # they query the DB before `flask db upgrade` runs, and any error + # there aborts the migration (belt & braces; the app also detects the + # flask CLI on its own). + if [ "$use_slot_db" = "1" ]; then + DATABASE_URL="sqlite:///$slot_db" FLASK_ENV=production SERVERKIT_SKIP_BACKGROUND=1 flask db upgrade else # Non-SQLite (e.g. PostgreSQL): the DB is shared/external, so there # is no per-slot copy to isolate — migrate it directly. - FLASK_ENV=production flask db upgrade + FLASK_ENV=production SERVERKIT_SKIP_BACKGROUND=1 flask db upgrade fi ); then halt "Database migration failed. The previous installation is still active."