Ctrl K
ComunidadGuías…

backend/tests/test_marketplace_install_hardening.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Plugin install takes only indexed plugins and a signed-in person.

Code check 2026-11-05 (Forgejo #71, #77 N-3): the API key — which Sonarr,
Radarr and Jellyfin hold for their webhooks — could install a plugin from any
HTTPS URL with a hash the caller chose, which is code execution. Install now
takes the URL and hash from the plugin index, refuses the API key alone, and
plugin names are checked before they reach the file system.
"""

import os
from unittest.mock import MagicMock, patch

import pytest

from app import create_app
from extensions import db as sa_db
from tests.ui_session_helpers import grant_ui_session

API_KEY = "m" * 64
INDEXED = {
    "name": "good-plugin",
    "display_name": "Good",
    "version": "1.2.0",
    "zip_url": "https://github.com/someone/good-plugin/releases/download/v1.2.0/good.zip",
    "sha256": "a" * 64,
}


@pytest.fixture()
def app(tmp_path):
    os.environ["SUBLARR_DB_PATH"] = str(tmp_path / "test.db")
    os.environ["SUBLARR_API_KEY"] = API_KEY
    os.environ["SUBLARR_LOG_LEVEL"] = "ERROR"
    from config import reload_settings

    reload_settings()
    application = create_app(testing=True)
    application.config["TESTING"] = True
    with application.app_context():
        sa_db.create_all()
        _index(INDEXED)
        yield application
    for k in ("SUBLARR_DB_PATH", "SUBLARR_API_KEY", "SUBLARR_LOG_LEVEL"):
        os.environ.pop(k, None)
    reload_settings()


def _index(entry):
    from datetime import UTC, datetime

    from db.models.plugins import MarketplaceCache

    sa_db.session.add(
        MarketplaceCache(
            name=entry["name"],
            display_name=entry["display_name"],
            author="someone",
            version=entry["version"],
            description="",
            github_url="",
            zip_url=entry["zip_url"],
            sha256=entry["sha256"],
            capabilities="[]",
            min_sublarr_version="",
            is_official=0,
            last_fetched=datetime.now(UTC),
        )
    )
    sa_db.session.commit()


@pytest.fixture()
def signed_in(app):
    client = app.test_client()
    with client.session_transaction() as sess:
        grant_ui_session(sess)
    return client


def _install(client, body, **kw):
    fake = MagicMock(return_value={"path": "/config/plugins/good-plugin"})
    with (
        patch("services.marketplace.PluginMarketplace.install_plugin_from_zip", fake),
        patch("providers.plugins.get_plugin_manager", return_value=None),
    ):
        r = client.post("/api/v1/marketplace/install", json=body, **kw)
    return r, fake


def test_the_api_key_alone_cannot_install(app):
    r, fake = _install(app.test_client(), {"name": "good-plugin"}, headers={"X-Api-Key": API_KEY})
    assert r.status_code == 403
    assert r.get_json()["session_required"] is True
    fake.assert_not_called()


def test_install_uses_the_index_url_and_hash(signed_in):
    r, fake = _install(
        signed_in,
        {"name": "good-plugin", "zip_url": "https://evil.example/x.zip"},
    )
    assert r.status_code == 409
    fake.assert_not_called()

    r, fake = _install(signed_in, {"name": "good-plugin"})
    assert r.status_code == 200, r.get_json()
    kwargs = fake.call_args.kwargs
    assert kwargs["zip_url"] == INDEXED["zip_url"]
    assert kwargs["expected_sha256"] == INDEXED["sha256"]


def test_a_hash_that_differs_from_the_index_is_refused(signed_in):
    r, fake = _install(signed_in, {"name": "good-plugin", "sha256": "b" * 64})
    assert r.status_code == 409
    fake.assert_not_called()


def test_a_plugin_outside_the_index_is_refused(signed_in):
    with patch("routes.marketplace.install._registry_entry", return_value=None):
        r, fake = _install(
            signed_in,
            {"name": "other", "zip_url": "https://evil.example/x.zip", "sha256": "c" * 64},
        )
    assert r.status_code == 404
    fake.assert_not_called()


@pytest.mark.parametrize("name", [".", "..", "../x", "a/b", "Bad Name", ""])
def test_unsafe_names_are_refused(signed_in, name):
    r, fake = _install(signed_in, {"name": name})
    assert r.status_code == 400
    fake.assert_not_called()
    r = signed_in.post("/api/v1/marketplace/uninstall", json={"plugin_name": name})
    assert r.status_code == 400


def test_uninstall_needs_a_session_too(app):
    r = app.test_client().post(
        "/api/v1/marketplace/uninstall",
        json={"plugin_name": "good-plugin"},
        headers={"X-Api-Key": API_KEY},
    )
    assert r.status_code == 403


def test_the_service_refuses_to_delete_the_plugin_folder(tmp_path):
    from services.marketplace import PluginMarketplace

    (tmp_path / "keep").mkdir()
    with pytest.raises(RuntimeError):
        PluginMarketplace().uninstall_plugin(".", str(tmp_path))
    assert (tmp_path / "keep").is_dir()