security: signature lifecycle, update-path consent gates, download hardening

Audit remediation (install/signing surface):
- sig verdict reset on every install path (no stale verified badge)
- registry update route mirrors install consent gates (unreviewed 404,
  unsigned-first-party/untrusted 409 until acknowledged, bad sig 400);
  update offers carry installed_source_type; frontend risk dialog gains
  update branch
- first_party entries without verified pinned-key signature require
  acknowledge (community reviewed exempt, review-stamp-bound)
- downloads: https-only with per-hop redirect revalidation, private/
  link-local IP rejection, 100MB zip + 64KB sig caps, github token
  https-only
- extraction: declared+actual 250MB decompression cap with cleanup
- install/update audit logs record the signature verdict
This commit is contained in:
Juan Denis committed 2026-08-05 13:36:20 -04:00
1 parent d2d2d861cd
commit 670e9d8af7
9 files changed
+634 -106

No files matched your search

+4 -24
View File
@@ -62,31 +62,11 @@ def install_registry(slug):
body = request.get_json(silent=True) or {}
trust = (entry or {}).get('trust', 'unreviewed')
acknowledged = body.get('acknowledge_risk') is True
reason = None
if entry is not None and not acknowledged:
if trust == 'unreviewed':
reason = 'unreviewed'
elif not entry.get('sha256'):
reason = 'unverified'
elif entry.get('signature') and not entry.get('publisher_trusted'):
# Signed, but by a publisher key this panel doesn't pin (plan 55).
# Consent-overridable like unsigned; a BAD signature is never
# overridable — install_registry_extension hard-fails on it below.
reason = 'untrusted_key'
# Consent gate (shared with the update path — audits M2/M3):
# unreviewed (dev-stage) > unverified (no checksum) > signature gate
# (untrusted key, or unsigned first-party = possible downgrade).
reason, message = registry_service.consent_gate(entry, acknowledged)
if reason:
message = (
'This community extension has not been reviewed by the ServerKit '
'maintainers; installing it runs unreviewed code with full panel '
'privileges.'
if reason == 'unreviewed' else
'This extension has no pinned checksum, so the panel cannot '
'verify the artifact it would install.'
if reason == 'unverified' else
f"This extension is signed, but its publisher key "
f"'{entry.get('publisher_key_id')}' is not pinned by this panel, "
f"so the signature cannot be verified. Install only if you trust "
f"the source."
)
return jsonify({
'error': f'{message} Resend with acknowledge_risk: true to proceed.',
'trust': trust,
+52 -4
View File
@@ -82,12 +82,18 @@ def install_plugin():
url, user_id=user.id, expected_sha256=expected_sha256,
expected_signature=expected_signature, expected_key_id=expected_key_id,
)
sig = (plugin.config or {}).get('_signature') or {}
AuditService.log(
action=AuditLog.ACTION_RESOURCE_CREATE,
user_id=user.id,
target_type='plugin',
target_id=plugin.id,
details={'name': plugin.name, 'version': plugin.version, 'url': url}
details={
'name': plugin.name, 'version': plugin.version, 'url': url,
# Forensics (audit I7): the signature verdict of the bytes
# that just landed (verified / untrusted_key / unsigned).
'signature': sig.get('status') or 'unsigned',
}
)
return jsonify(plugin.to_dict()), 201
except ValueError as e:
@@ -616,20 +622,62 @@ def list_updates():
@plugins_bp.route('/<int:plugin_id>/update', methods=['POST'])
@jwt_required()
def update_plugin_route(plugin_id):
"""Update an installed plugin to the registry version (reinstall in place)."""
"""Update an installed plugin to the registry version (reinstall in place).
The update re-downloads from the registry, so it passes the SAME consent
gates as a fresh registry install (audit M2): hidden unreviewed entries
404; unreviewed / unverified / untrusted-key / unsigned-first-party
entries 409 until resent with ``acknowledge_risk: true``; a bad signature
is always a hard 400 (the service verifies at install).
"""
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
from app.services.plugin_service import update_plugin
from app.services.plugin_service import get_plugin, update_plugin
from app.services import registry_service
plugin = get_plugin(plugin_id)
if not plugin:
return jsonify({'error': 'Plugin not found'}), 404
entry = registry_service.get_entry(plugin.slug)
if not entry:
return jsonify({
'error': f"No registry entry for '{plugin.slug}' — cannot update "
f"from the registry."
}), 400
# Hidden means not updatable either: unreviewed entries only exist in
# development contexts (see registry_service._show_unreviewed).
if entry.get('trust') == 'unreviewed' \
and not registry_service._show_unreviewed():
return jsonify({'error': 'Extension not found in the registry'}), 404
body = request.get_json(silent=True) or {}
acknowledged = body.get('acknowledge_risk') is True
reason, message = registry_service.consent_gate(entry, acknowledged)
if reason:
return jsonify({
'error': f'{message} Resend with acknowledge_risk: true to proceed.',
'trust': entry.get('trust'),
'reason': reason,
'requires_acknowledgment': True,
}), 409
try:
plugin = update_plugin(plugin_id, user_id=user.id)
sig = (plugin.config or {}).get('_signature') or {}
AuditService.log(
action=AuditLog.ACTION_RESOURCE_UPDATE,
user_id=user.id,
target_type='plugin',
target_id=plugin.id,
details={'name': plugin.name, 'version': plugin.version, 'source': 'registry-update'},
details={
'name': plugin.name, 'version': plugin.version,
'source': 'registry-update',
# Forensics (audit I7): the signature verdict of the bytes
# that just landed.
'signature': sig.get('status') or 'unsigned',
},
)
return jsonify(plugin.to_dict())
except ValueError as e:
+215 -68
View File
@@ -169,8 +169,9 @@ def _github_token():
def _is_github_host(url):
"""True for github.com / api.github.com / codeload.github.com URLs — the only
hosts the token is ever attached to (never leak it to third-party mirrors)."""
return bool(re.match(r'^https?://(?:[a-z0-9-]+\.)?github\.com/', url or '', re.I))
hosts the token is ever attached to (never leak it to third-party mirrors,
and never over plain http — audit L6)."""
return bool(re.match(r'^https://(?:[a-z0-9-]+\.)?github\.com/', url or '', re.I))
def _github_api_headers(accept='application/vnd.github+json'):
@@ -305,6 +306,103 @@ def _download_zip(url):
return _download_resolved(resolved)
# ── Download safety (audit L4): scheme/host checks, redirect re-validation,
# and byte caps, shared by the zip and detached-signature fetches.
_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024 # extension zip cap
_MAX_SIGNATURE_BYTES = 64 * 1024 # detached .minisig cap
_MAX_EXTRACT_BYTES = 250 * 1024 * 1024 # decompression cap (audit L5)
_MAX_REDIRECTS = 5
def _ip_is_public(ip):
import ipaddress
addr = ipaddress.ip_address(ip)
return not (addr.is_private or addr.is_loopback or addr.is_link_local
or addr.is_multicast or addr.is_reserved or addr.is_unspecified)
def _assert_url_safe(url):
"""Reject anything that isn't an https URL to a public host (SSRF guard).
Rules: https everywhere; http only for loopback dev hosts
(http://localhost:8000/ext.zip while developing); loopback targets are
always allowed (a same-host registry is legitimate), but RFC1918 /
link-local / reserved targets are refused by default — a panel must never
follow an attacker-supplied URL into the internal network or the cloud
metadata service. Operators with a private-zone registry can opt out with
SERVERKIT_ALLOW_PRIVATE_DOWNLOADS=1.
"""
import ipaddress
import socket
from urllib.parse import urlparse
parsed = urlparse(url or '')
scheme = (parsed.scheme or '').lower()
if scheme not in ('http', 'https'):
raise ValueError(
f"Unsupported URL scheme {scheme!r} — extension downloads must be https://")
host = parsed.hostname
if not host:
raise ValueError('Download URL has no host')
try:
ips = {info[4][0] for info in socket.getaddrinfo(host, None)}
except Exception as e:
raise ValueError(f'Could not resolve download host {host!r}: {e}')
if all(ipaddress.ip_address(ip).is_loopback for ip in ips):
return # loopback dev host — http or https both fine
if scheme == 'http':
raise ValueError(
'Plain-http extension downloads are only allowed for loopback dev hosts')
if os.environ.get('SERVERKIT_ALLOW_PRIVATE_DOWNLOADS', '').lower() in ('1', 'true', 'yes'):
return
bad = sorted(ip for ip in ips if not _ip_is_public(ip))
if bad:
raise ValueError(
f'Download host {host!r} resolves to a non-public address ({bad[0]}) '
f'— refusing to fetch (SSRF guard). Set '
f'SERVERKIT_ALLOW_PRIVATE_DOWNLOADS=1 to allow private-zone registries.')
def _safe_get(url, timeout, headers, max_bytes):
"""GET ``url`` with per-hop SSRF re-validation (redirects are followed
manually so each hop is re-checked), a hard byte cap, and the GitHub
token re-computed per hop (never leaked cross-host). Returns bytes."""
from urllib.parse import urljoin
current = url
for _ in range(_MAX_REDIRECTS + 1):
_assert_url_safe(current)
hop_headers = dict(headers or {})
token = _github_token()
if token and _is_github_host(current):
hop_headers['Authorization'] = f'Bearer {token}'
resp = requests.get(current, timeout=timeout, stream=True,
headers=hop_headers, allow_redirects=False)
try:
# status_code check rather than is_redirect: equivalent semantics,
# and tolerant of minimal test doubles.
if resp.status_code in (301, 302, 303, 307, 308):
location = resp.headers.get('Location')
if not location:
raise ValueError('Redirect without a Location header')
current = urljoin(current, location)
continue
resp.raise_for_status()
buf = io.BytesIO()
for chunk in resp.iter_content(chunk_size=8192):
buf.write(chunk)
if buf.tell() > max_bytes:
raise ValueError(
f'Download exceeds the {max_bytes // (1024 * 1024)} MB cap '
f'— aborting (possible zip bomb or misconfigured source)')
buf.seek(0)
return buf.getvalue()
finally:
close = getattr(resp, 'close', None)
if callable(close):
close()
raise ValueError(f'Too many redirects fetching {url}')
def _download_resolved(resolved):
"""Download an already-resolved zip URL and return a BytesIO buffer."""
logger.info(f'Downloading plugin from: {resolved}')
@@ -312,18 +410,9 @@ def _download_resolved(resolved):
'Accept': 'application/octet-stream',
'User-Agent': 'ServerKit-Plugin-Installer/1.0',
}
# Attach the token only for GitHub hosts (private assets / rate limit).
token = _github_token()
if token and _is_github_host(resolved):
headers['Authorization'] = f'Bearer {token}'
resp = requests.get(resolved, timeout=120, stream=True, headers=headers)
resp.raise_for_status()
buf = io.BytesIO()
for chunk in resp.iter_content(chunk_size=8192):
buf.write(chunk)
buf.seek(0)
return buf
data = _safe_get(resolved, timeout=120, headers=headers,
max_bytes=_MAX_DOWNLOAD_BYTES)
return io.BytesIO(data)
def _find_manifest(zf):
@@ -580,9 +669,8 @@ def install_from_url(url, user_id=None, expected_sha256=None, force=False,
plugin = _install_from_buffer(
buf, source_url=url, source_type=source_type or 'url',
user_id=user_id, force=force,
hot_load=hot_load,
hot_load=hot_load, sig_result=sig_result,
)
_record_signature_status(plugin, sig_result)
return plugin
@@ -620,13 +708,9 @@ def _fetch_detached_signature(resolved_url):
'Accept': 'text/plain, application/octet-stream',
'User-Agent': 'ServerKit-Plugin-Installer/1.0',
}
token = _github_token()
if token and _is_github_host(resolved_url):
headers['Authorization'] = f'Bearer {token}'
resp = requests.get(resolved_url + '.minisig', timeout=15, headers=headers)
if resp.status_code != 200:
return None
text = (resp.text or '').strip()
data = _safe_get(resolved_url + '.minisig', timeout=15, headers=headers,
max_bytes=_MAX_SIGNATURE_BYTES)
text = data.decode('utf-8', errors='replace').strip()
return text or None
except Exception as e:
logger.debug(f'No detached signature at {resolved_url}.minisig: {e}')
@@ -805,7 +889,7 @@ def install_from_zip(zip_bytes, user_id=None, source_name=None):
def _install_from_buffer(buf, source_url, source_type, user_id=None, force=False,
hot_load=True):
hot_load=True, sig_result=None):
"""Shared install pipeline: takes a seekable BytesIO containing a zip,
extracts it into the panel's plugin dirs, and registers / hot-loads
the resulting blueprint. All public install_* helpers funnel here so
@@ -814,6 +898,12 @@ def _install_from_buffer(buf, source_url, source_type, user_id=None, force=False
`force=True` allows reinstalling over an active plugin (the update path);
normal installs still refuse to clobber an active install. `hot_load=False`
skips the immediate blueprint registration (the boot repair pass, #48).
`sig_result` is the signature verdict produced by the caller (only
install_from_url can produce one — local/upload/builtin installs have no
detached signature). It is stamped on EVERY successful install so a
reinstall via a different source can never inherit a stale 'verified'
badge from an earlier signed install (audit M1).
"""
_ensure_dirs()
@@ -904,53 +994,91 @@ def _install_from_buffer(buf, source_url, source_type, user_id=None, force=False
if has_frontend and os.path.exists(frontend_dest):
shutil.rmtree(frontend_dest)
for member in zf.namelist():
# Strip the GitHub zipball prefix
rel_path = member[len(prefix):] if prefix else member
if not rel_path or rel_path.endswith('/'):
continue
# Decompression cap (audit L5): refuse archives that expand past
# _MAX_EXTRACT_BYTES — a small zip can declare or write gigabytes
# (zip bomb). Pre-check the declared sizes (cheap, catches honest
# bombs before writing a byte), then count actual bytes written
# (the declared size can lie). On abort, remove the partial dest
# dirs so no half-extracted plugin is left behind.
declared_total = sum(
info.file_size for info in zf.infolist() if not info.is_dir())
if declared_total > _MAX_EXTRACT_BYTES:
raise ValueError(
f'Archive expands to {declared_total // (1024 * 1024)} MB, '
f'over the {_MAX_EXTRACT_BYTES // (1024 * 1024)} MB cap — '
f'refusing to extract (possible zip bomb)')
extracted_total = 0
if rel_path.startswith('backend/'):
# Zip Slip defense: _safe_extract_path rejects absolute
# paths, .. segments, and anything that resolves outside
# backend_dest after normalization.
out_path = _safe_extract_path(
backend_dest, rel_path[len('backend/'):]
)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zf.open(member) as src, open(out_path, 'wb') as dst:
dst.write(src.read())
def _write_member(src, dst):
nonlocal extracted_total
while True:
chunk = src.read(1024 * 1024)
if not chunk:
break
extracted_total += len(chunk)
if extracted_total > _MAX_EXTRACT_BYTES:
raise ValueError(
f'Archive expands past the '
f'{_MAX_EXTRACT_BYTES // (1024 * 1024)} MB cap — '
f'refusing to extract (possible zip bomb)')
dst.write(chunk)
elif rel_path.startswith('frontend/'):
out_path = _safe_extract_path(
frontend_dest, rel_path[len('frontend/'):]
)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zf.open(member) as src, open(out_path, 'wb') as dst:
dst.write(src.read())
try:
for member in zf.namelist():
# Strip the GitHub zipball prefix
rel_path = member[len(prefix):] if prefix else member
if not rel_path or rel_path.endswith('/'):
continue
elif rel_path == 'requirements.txt':
# Plugin Python deps are sandboxed by default: installing
# them runs pip with the backend's privileges, which means
# arbitrary code via setup.py hooks. Operators must opt in
# by setting SERVERKIT_ALLOW_PLUGIN_PIP=1.
req_content = zf.read(member).decode('utf-8')
if req_content.strip() and os.environ.get(
'SERVERKIT_ALLOW_PLUGIN_PIP', ''
).lower() in ('1', 'true', 'yes'):
_install_requirements(req_content, slug)
elif req_content.strip():
# Persist the requirements file so the admin can review
# and install it manually if they want.
req_out = _safe_extract_path(backend_dest, 'requirements.txt')
os.makedirs(os.path.dirname(req_out), exist_ok=True)
with open(req_out, 'w', encoding='utf-8') as f:
f.write(req_content)
logger.warning(
f"Plugin {slug} ships requirements.txt; skipping install "
f"(set SERVERKIT_ALLOW_PLUGIN_PIP=1 to enable). "
f"File saved to {req_out} for manual review."
if rel_path.startswith('backend/'):
# Zip Slip defense: _safe_extract_path rejects absolute
# paths, .. segments, and anything that resolves outside
# backend_dest after normalization.
out_path = _safe_extract_path(
backend_dest, rel_path[len('backend/'):]
)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zf.open(member) as src, open(out_path, 'wb') as dst:
_write_member(src, dst)
elif rel_path.startswith('frontend/'):
out_path = _safe_extract_path(
frontend_dest, rel_path[len('frontend/'):]
)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zf.open(member) as src, open(out_path, 'wb') as dst:
_write_member(src, dst)
elif rel_path == 'requirements.txt':
# Plugin Python deps are sandboxed by default: installing
# them runs pip with the backend's privileges, which means
# arbitrary code via setup.py hooks. Operators must opt in
# by setting SERVERKIT_ALLOW_PLUGIN_PIP=1.
req_content = zf.read(member).decode('utf-8')
if req_content.strip() and os.environ.get(
'SERVERKIT_ALLOW_PLUGIN_PIP', ''
).lower() in ('1', 'true', 'yes'):
_install_requirements(req_content, slug)
elif req_content.strip():
# Persist the requirements file so the admin can review
# and install it manually if they want.
req_out = _safe_extract_path(backend_dest, 'requirements.txt')
os.makedirs(os.path.dirname(req_out), exist_ok=True)
with open(req_out, 'w', encoding='utf-8') as f:
f.write(req_content)
logger.warning(
f"Plugin {slug} ships requirements.txt; skipping install "
f"(set SERVERKIT_ALLOW_PLUGIN_PIP=1 to enable). "
f"File saved to {req_out} for manual review."
)
except ValueError:
# Extraction aborted (zip bomb cap) — drop the partial dest dirs so
# no half-extracted plugin is left behind, then re-raise into the
# outer error handler (marks the row STATUS_ERROR).
for dest in (backend_dest, frontend_dest):
if os.path.isdir(dest):
shutil.rmtree(dest, ignore_errors=True)
raise
# Also write the manifest into the backend plugin dir for runtime access.
# The trailing newline matters: these manifests are tracked files that
@@ -989,6 +1117,10 @@ def _install_from_buffer(buf, source_url, source_type, user_id=None, force=False
_record_frontend_hashes(plugin, manifest, frontend_dest)
plugin.status = InstalledPlugin.STATUS_ACTIVE
db.session.commit()
# Stamp the signature verdict on every successful install (None for
# local/upload/builtin sources → 'unsigned'), so a reinstall via a
# different source never keeps a stale 'verified' badge.
_record_signature_status(plugin, sig_result)
# Try to register the blueprint immediately (hot-load)
if has_backend and entry_point and hot_load:
@@ -1401,6 +1533,9 @@ def uninstall_plugin(plugin_id, purge=False):
try:
from app.services import extension_lifecycle
extension_lifecycle.remove_jobs(plugin, manifest)
# Audit F1: seam registrations (backup kinds, event types, template
# providers) must not keep executing after uninstall.
extension_lifecycle.unregister_capabilities(plugin)
if purge:
extension_lifecycle.purge_models(plugin)
from app.plugins_sdk.store_sdk import purge as purge_store
@@ -1454,6 +1589,8 @@ def enable_plugin(plugin_id):
try:
from app.services import extension_lifecycle
extension_lifecycle.resume_jobs(plugin, plugin.manifest or {})
# Audit F4: re-register the core seams disable tore down (idempotent).
extension_lifecycle.register_capabilities(plugin, plugin.manifest or {})
except Exception as e:
logger.warning(f'Could not resume jobs for {plugin.slug}: {e}')
return plugin
@@ -1472,6 +1609,9 @@ def disable_plugin(plugin_id):
try:
from app.services import extension_lifecycle
extension_lifecycle.pause_jobs(plugin, plugin.manifest or {})
# Audit F1: seam registrations (backup kinds, event types, template
# providers) must not keep executing while disabled.
extension_lifecycle.unregister_capabilities(plugin)
except Exception as e:
logger.warning(f'Could not pause jobs for {plugin.slug}: {e}')
return plugin
@@ -1850,6 +1990,9 @@ def install_builtin_extension(slug, user_id=None):
if existing:
existing.status = InstalledPlugin.STATUS_ACTIVE
db.session.commit()
# A builtin re-install carries no signature — never keep a
# verdict stamped by some earlier install of the slug.
_record_signature_status(existing, None)
return existing
plugin = _seed_flagship_row(entry)
try:
@@ -2059,7 +2202,10 @@ def check_for_updates():
Update v1 is registry-driven: a plugin whose slug is in the registry with a
higher version than installed is flagged. Panel-version compatibility is
surfaced so the UI can gray out an update the panel can't run yet.
surfaced so the UI can gray out an update the panel can't run yet. The
installed plugin's ``source_type`` is included so callers can see the
provenance the update would cross (audit M2 — a registry entry only ever
matches on slug; the update route's consent gate is the enforcement).
"""
from app.services import registry_service
from app.utils.version import compare_versions, version_satisfies, get_panel_version
@@ -2080,6 +2226,7 @@ def check_for_updates():
panel, entry.get('min_panel_version'), entry.get('max_panel_version')
),
'source': entry.get('source'),
'installed_source_type': p.source_type,
})
return out
+65
View File
@@ -293,3 +293,68 @@ def list_catalog(include_bundled=False):
def registry_source_label():
return _cache.get('source')
def signature_consent_reason(entry):
"""The signature-side consent reason for an index entry, or None.
Shared by the registry install gate and the update gate (audits M2/M3):
- a signature pinned to a panel-trusted publisher key → no consent needed
(install verifies it cryptographically; a bad one hard-fails there).
- signed by an unpinned key → 'untrusted_key'.
- a FIRST-PARTY entry without a verifiable signature → 'unsigned':
first-party releases are expected to be signed, so anything less is a
possible downgrade and requires explicit consent.
- community REVIEWED entries stay exempt: the review stamp already binds
a maintainer's verdict to the exact sha256 of the artifact, which is
the pre-signature trust mechanism (see docs/EXTENSIONS.md).
"""
if entry.get('signature'):
if entry.get('publisher_trusted'):
return None
return 'untrusted_key'
if entry.get('first_party'):
return 'unsigned'
return None
_GATE_MESSAGES = {
'unreviewed': (
'This community extension has not been reviewed by the ServerKit '
'maintainers; installing it runs unreviewed code with full panel '
'privileges.'),
'unverified': (
'This extension has no pinned checksum, so the panel cannot '
'verify the artifact it would install.'),
# 'untrusted_key' is formatted inline (interpolates the key id).
'unsigned': (
'This first-party release is not signed with a publisher key '
'pinned by this panel, so the panel cannot verify its origin. It '
'may be a legitimate older release — or a downgrade attempt.'),
}
def consent_gate(entry, acknowledged):
"""The shared install/update consent decision (audits M2/M3).
Returns ``(reason, message)`` — reason is None when the action may
proceed without explicit acknowledgment. Order: unreviewed (dev-stage)
> unverified (no checksum) > signature gate (untrusted key / unsigned
first-party)."""
if not entry or acknowledged:
return None, None
trust = entry.get('trust', 'unreviewed')
if trust == 'unreviewed':
reason = 'unreviewed'
elif not entry.get('sha256'):
reason = 'unverified'
else:
reason = signature_consent_reason(entry)
if reason == 'untrusted_key':
return reason, (
f"This extension is signed, but its publisher key "
f"'{entry.get('publisher_key_id')}' is not pinned by this panel, "
f"so the signature cannot be verified. Install only if you trust "
f"the source.")
return reason, _GATE_MESSAGES.get(reason)
+253
View File
@@ -518,3 +518,256 @@ def test_install_endpoint_rejects_bad_signature(
assert resp.status_code == 400
assert 'Signature verification failed' in resp.get_json()['error']
assert InstalledPlugin.query.filter_by(slug='signed-ext').first() is None
# --------------------------------------------------------------------------- #
# Audit M1 — a reinstall via another source must not keep a stale verdict
# --------------------------------------------------------------------------- #
def test_unsigned_reinstall_clears_verified_badge(
app, plugin_dirs, tmp_path, monkeypatch):
"""Signed URL install → verified. Reinstalling the same slug via upload
(no signature possible) must re-stamp the verdict, never leave a forged
'verified' badge behind."""
priv = Ed25519PrivateKey.generate()
_pin_keys(tmp_path, monkeypatch, [('test-pub', priv, 'Test Publisher')])
zip_bytes = _make_plugin_zip()
sig = signing_service.sign_bytes(zip_bytes, priv)
monkeypatch.setattr(plugin_service, '_download_zip', lambda url: io.BytesIO(zip_bytes))
plugin = plugin_service.install_from_url(
'https://x/signed-ext.zip', expected_signature=sig, expected_key_id='test-pub')
assert plugin.config['_signature']['status'] == 'verified'
# Same slug, upload path (force, as the update flow does).
plugin = plugin_service._install_from_buffer(
io.BytesIO(zip_bytes), source_url='uploaded.zip', source_type='upload',
force=True)
db.session.refresh(plugin)
assert plugin.config['_signature'] == {'status': 'unsigned'}
def test_local_reinstall_clears_verified_badge(
app, plugin_dirs, tmp_path, monkeypatch):
"""Same as above but through the public install_from_zip upload helper."""
priv = Ed25519PrivateKey.generate()
_pin_keys(tmp_path, monkeypatch, [('test-pub', priv, 'Test Publisher')])
zip_bytes = _make_plugin_zip()
sig = signing_service.sign_bytes(zip_bytes, priv)
monkeypatch.setattr(plugin_service, '_download_zip', lambda url: io.BytesIO(zip_bytes))
plugin = plugin_service.install_from_url(
'https://x/signed-ext.zip', expected_signature=sig, expected_key_id='test-pub')
assert plugin.config['_signature']['status'] == 'verified'
# Uninstall then reinstall via upload (the ordinary re-install path).
plugin_service.uninstall_plugin(plugin.id)
plugin = plugin_service.install_from_zip(zip_bytes, source_name='signed-ext.zip')
assert plugin.config['_signature'] == {'status': 'unsigned'}
# --------------------------------------------------------------------------- #
# Audit M2 — the update route passes the same consent gates as install
# --------------------------------------------------------------------------- #
def _install_v1(app, plugin_dirs, monkeypatch, slug='upd-ext'):
"""Install v1 of a plugin so the update route has a row to work on."""
v1 = _make_plugin_zip(slug=slug, version='1.0.0')
monkeypatch.setattr(plugin_service, '_download_zip', lambda url: io.BytesIO(v1))
return plugin_service.install_from_url(f'https://x/{slug}.zip')
def _seed_update_entry(monkeypatch, slug, version='2.0.0', **overrides):
base = {
'slug': slug, 'display_name': 'Upd', 'version': version,
'source': f'https://x/{slug}.zip',
'min_panel_version': '0.0.1',
}
base.update(overrides)
monkeypatch.setattr(registry_service, '_cache', {
'ts': 9e18, 'source': 'test',
'entries': [registry_service._normalize(base)],
})
def test_update_route_hidden_unreviewed_is_404(
app, client, auth_headers, plugin_dirs, monkeypatch):
plugin = _install_v1(app, plugin_dirs, monkeypatch)
_seed_update_entry(monkeypatch, 'upd-ext', sha256='d' * 64) # → unreviewed
monkeypatch.setattr(registry_service, '_show_unreviewed', lambda: False)
resp = client.post(f'/api/v1/plugins/{plugin.id}/update', headers=auth_headers)
assert resp.status_code == 404
def test_update_route_unsigned_first_party_409_until_acknowledged(
app, client, auth_headers, plugin_dirs, monkeypatch):
plugin = _install_v1(app, plugin_dirs, monkeypatch)
v2 = _make_plugin_zip(slug='upd-ext', version='2.0.0')
_seed_update_entry(monkeypatch, 'upd-ext',
sha256=hashlib.sha256(v2).hexdigest(), first_party=True)
monkeypatch.setattr(plugin_service, '_download_zip', lambda url: io.BytesIO(v2))
resp = client.post(f'/api/v1/plugins/{plugin.id}/update', headers=auth_headers)
assert resp.status_code == 409
data = resp.get_json()
assert data['requires_acknowledgment'] is True
assert data['reason'] == 'unsigned'
resp = client.post(f'/api/v1/plugins/{plugin.id}/update',
headers=auth_headers, json={'acknowledge_risk': True})
assert resp.status_code == 200
assert resp.get_json()['version'] == '2.0.0'
def test_update_route_signed_installs_without_ack(
app, client, auth_headers, plugin_dirs, tmp_path, monkeypatch):
priv = Ed25519PrivateKey.generate()
_pin_keys(tmp_path, monkeypatch, [('test-pub', priv, 'Test Publisher')])
plugin = _install_v1(app, plugin_dirs, monkeypatch)
v2 = _make_plugin_zip(slug='upd-ext', version='2.0.0')
_seed_update_entry(
monkeypatch, 'upd-ext', sha256=hashlib.sha256(v2).hexdigest(),
first_party=True,
signature=signing_service.sign_bytes(v2, priv),
publisher_key_id='test-pub')
monkeypatch.setattr(plugin_service, '_download_zip', lambda url: io.BytesIO(v2))
resp = client.post(f'/api/v1/plugins/{plugin.id}/update', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['version'] == '2.0.0'
def test_update_route_bad_signature_is_400(
app, client, auth_headers, plugin_dirs, tmp_path, monkeypatch):
priv = Ed25519PrivateKey.generate()
_pin_keys(tmp_path, monkeypatch, [('test-pub', priv, 'Test Publisher')])
plugin = _install_v1(app, plugin_dirs, monkeypatch)
v2 = _make_plugin_zip(slug='upd-ext', version='2.0.0')
bad_sig = signing_service.sign_bytes(b'other bytes', priv)
_seed_update_entry(
monkeypatch, 'upd-ext', sha256=hashlib.sha256(v2).hexdigest(),
first_party=True, signature=bad_sig, publisher_key_id='test-pub')
monkeypatch.setattr(plugin_service, '_download_zip', lambda url: io.BytesIO(v2))
resp = client.post(f'/api/v1/plugins/{plugin.id}/update', headers=auth_headers)
assert resp.status_code == 400
assert 'Signature verification failed' in resp.get_json()['error']
# The installed v1 row was not clobbered.
assert InstalledPlugin.query.get(plugin.id).version == '1.0.0'
# --------------------------------------------------------------------------- #
# Audit L4 — download SSRF guards + byte caps
# --------------------------------------------------------------------------- #
def test_download_rejects_non_http_schemes(app, plugin_dirs):
with pytest.raises(ValueError, match='Failed to download plugin'):
plugin_service.install_from_url('file:///etc/passwd')
with pytest.raises(ValueError, match='scheme'):
plugin_service._assert_url_safe('file:///etc/passwd')
def test_download_rejects_redirect_to_link_local(app, monkeypatch):
"""A 302 into the cloud metadata service must be refused on the second hop
(redirects are followed manually and re-checked)."""
class FakeRedirect:
status_code = 302
headers = {'Location': 'http://169.254.169.254/latest/meta-data'}
def close(self): pass
monkeypatch.setattr(plugin_service.requests, 'get',
lambda *a, **k: FakeRedirect())
import socket as _socket
monkeypatch.setattr(_socket, 'getaddrinfo',
lambda host, port, *a, **k: [(2, 1, 6, '', ('93.184.216.34', 0))])
with pytest.raises(ValueError, match='Plain-http|non-public'):
plugin_service._download_resolved('https://example.com/ext.zip')
def test_download_rejects_private_and_plain_http_targets(app):
with pytest.raises(ValueError, match='Plain-http'):
plugin_service._assert_url_safe('http://192.168.1.10/ext.zip')
with pytest.raises(ValueError, match='non-public'):
plugin_service._assert_url_safe('https://192.168.1.10/ext.zip')
with pytest.raises(ValueError, match='non-public'):
plugin_service._assert_url_safe('https://169.254.169.254/latest')
# Loopback dev hosts stay allowed (http and https).
plugin_service._assert_url_safe('http://127.0.0.1:8000/ext.zip')
plugin_service._assert_url_safe('http://localhost:8000/ext.zip')
def test_download_aborts_past_byte_cap(app, monkeypatch):
monkeypatch.setattr(plugin_service, '_MAX_DOWNLOAD_BYTES', 1024)
class FakeResp:
status_code = 200
def raise_for_status(self): pass
def iter_content(self, chunk_size=8192):
return [b'x' * 1024, b'x' * 1024]
def close(self): pass
monkeypatch.setattr(plugin_service.requests, 'get', lambda *a, **k: FakeResp())
import socket as _socket
monkeypatch.setattr(_socket, 'getaddrinfo',
lambda host, port, *a, **k: [(2, 1, 6, '', ('93.184.216.34', 0))])
with pytest.raises(ValueError, match='cap'):
plugin_service._download_resolved('https://example.com/ext.zip')
# --------------------------------------------------------------------------- #
# Audit L5 — decompression cap
# --------------------------------------------------------------------------- #
def test_extraction_cap_aborts_and_leaves_no_residue(
app, plugin_dirs, monkeypatch):
# Tiny cap so the declared-size pre-check trips on an ordinary plugin zip.
monkeypatch.setattr(plugin_service, '_MAX_EXTRACT_BYTES', 16)
zip_bytes = _make_plugin_zip()
with pytest.raises(ValueError, match='zip bomb|cap'):
plugin_service.install_from_zip(zip_bytes)
plugin = InstalledPlugin.query.filter_by(slug='signed-ext').first()
assert plugin is not None and plugin.status == InstalledPlugin.STATUS_ERROR
assert not (plugin_dirs['backend'] / 'signed-ext').exists()
assert not (plugin_dirs['frontend'] / 'signed-ext').exists()
# --------------------------------------------------------------------------- #
# Audit L6 — the GitHub token never travels over plain http
# --------------------------------------------------------------------------- #
def test_github_host_matching_is_https_only():
assert plugin_service._is_github_host('https://github.com/o/r') is True
assert plugin_service._is_github_host('https://api.github.com/x') is True
assert plugin_service._is_github_host('http://github.com/o/r') is False
assert plugin_service._is_github_host('https://github.com.evil.test/') is False
# --------------------------------------------------------------------------- #
# Audit I7 — the install/update audit-log entries carry the signature verdict
# --------------------------------------------------------------------------- #
def test_install_audit_log_records_signature_verdict(
app, client, auth_headers, plugin_dirs, tmp_path, monkeypatch):
from app.models.audit_log import AuditLog
priv = Ed25519PrivateKey.generate()
_pin_keys(tmp_path, monkeypatch, [('test-pub', priv, 'Test Publisher')])
zip_bytes = _make_plugin_zip()
sig = signing_service.sign_bytes(zip_bytes, priv)
monkeypatch.setattr(plugin_service, '_download_zip', lambda url: io.BytesIO(zip_bytes))
resp = client.post('/api/v1/plugins/install', headers=auth_headers, json={
'url': 'https://x/signed-ext.zip',
'signature': sig,
'publisher_key_id': 'test-pub',
})
assert resp.status_code == 201
row = (AuditLog.query.filter_by(target_type='plugin')
.order_by(AuditLog.id.desc()).first())
assert row is not None
assert row.get_details().get('signature') == 'verified'
+5
View File
@@ -190,6 +190,11 @@ def test_download_token_only_for_github_hosts(monkeypatch):
return FakeResp()
monkeypatch.setattr(plugin_service.requests, 'get', fake_get)
# The SSRF guard resolves hosts before fetching; stub DNS to a public IP
# so this test stays offline (its subject is token scoping, not DNS).
import socket as _socket
monkeypatch.setattr(_socket, 'getaddrinfo',
lambda host, port, *a, **k: [(2, 1, 6, '', ('93.184.216.34', 0))])
plugin_service._download_resolved('https://github.com/o/r/releases/download/v1/x.zip')
plugin_service._download_resolved('https://evil.example.com/x.zip')
+11 -1
View File
@@ -459,10 +459,20 @@ def test_install_gate_passes_with_acknowledge_risk(app, client, auth_headers, fa
assert resp.status_code == 201
def test_install_gate_unaffected_for_first_party(app, client, auth_headers, fake_install, monkeypatch):
def test_install_gate_unsigned_first_party_requires_ack(app, client, auth_headers, fake_install, monkeypatch):
# Audit M3: a first-party entry without a verifiable signature is a
# possible downgrade — consent-gated like untrusted_key.
_seed_cache(monkeypatch, _entry(first_party=True))
resp = client.post('/api/v1/marketplace/registry/community-ext/install',
headers=auth_headers)
assert resp.status_code == 409
data = resp.get_json()
assert data['requires_acknowledgment'] is True
assert data['reason'] == 'unsigned'
assert 'first-party' in data['error']
resp = client.post('/api/v1/marketplace/registry/community-ext/install',
headers=auth_headers, json={'acknowledge_risk': True})
assert resp.status_code == 201
+25 -8
View File
@@ -318,8 +318,13 @@ const Marketplace = () => {
const confirmRiskyInstall = () => {
const slug = riskTarget?.slug;
const updatePluginId = riskTarget?.updatePluginId;
setRiskTarget(null);
if (slug) handleRegistryInstall(slug, true);
if (updatePluginId) {
handlePluginUpdate(updatePluginId, true);
} else if (slug) {
handleRegistryInstall(slug, true);
}
};
// Land on Installed after a manual install so the new row (and its
@@ -350,15 +355,21 @@ const Marketplace = () => {
}
};
const handlePluginUpdate = async (pluginId) => {
const handlePluginUpdate = async (pluginId, acknowledgeRisk = false) => {
if (busyPlugin) return;
setBusyPlugin({ id: pluginId, action: 'update' });
try {
const result = await api.updatePlugin(pluginId);
const result = await api.updatePlugin(
pluginId, acknowledgeRisk ? { acknowledge_risk: true } : undefined);
toast.success(`Extension "${result.display_name}" updated to v${result.version}.`);
await loadExtensions();
} catch (err) {
toast.error(err.message || 'Extension update failed');
// 409 consent gate (audit M2) — same acknowledge flow as installs.
if (err.status === 409 && err.data?.requires_acknowledgment) {
setRiskTarget({ updatePluginId: pluginId, reason: err.data?.reason || 'unsigned' });
} else {
toast.error(err.message || 'Extension update failed');
}
} finally {
setBusyPlugin(null);
}
@@ -593,13 +604,15 @@ const Marketplace = () => {
? 'Install without checksum verification?'
: riskTarget.reason === 'untrusted_key'
? 'Install with an unverifiable signature?'
: 'Install unreviewed extension?'}
: riskTarget.reason === 'unsigned'
? `${riskTarget.updatePluginId ? 'Update' : 'Install'} an unsigned release?`
: 'Install unreviewed extension?'}
size="sm"
footer={
<>
<Button variant="ghost" onClick={() => setRiskTarget(null)}>Cancel</Button>
<Button variant="destructive" onClick={confirmRiskyInstall}>
Install anyway
{riskTarget.updatePluginId ? 'Update anyway' : 'Install anyway'}
</Button>
</>
}
@@ -613,8 +626,12 @@ const Marketplace = () => {
? 'This extension is signed, but the publisher key is not '
+ 'pinned by this panel, so the signature cannot be '
+ 'verified. Treat it as unsigned.'
: 'This is a community extension whose exact code has not '
+ 'been reviewed by the ServerKit maintainers.'}
: riskTarget.reason === 'unsigned'
? 'This release is not signed with a publisher key pinned '
+ 'by this panel, so its origin cannot be verified. It '
+ 'may be a legitimate older release — or a downgrade attempt.'
: 'This is a community extension whose exact code has not '
+ 'been reviewed by the ServerKit maintainers.'}
</p>
<p className="text-muted">
It runs with full panel privileges. Only install it if you trust
+4 -1
View File
@@ -150,8 +150,11 @@ export async function getPluginUpdates() {
}
// Updates an installed plugin in place; returns the updated plugin dict.
export async function updatePlugin(pluginId) {
// Pass { acknowledge_risk: true } to proceed past the 409 consent gate
// (unsigned/untrusted-key/unreviewed registry versions — audit M2).
export async function updatePlugin(pluginId, body) {
return this.request(`/plugins/${pluginId}/update`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
});
}