Ctrl K
ComunidadGuías…

test_serve_security_audit.py

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

Archivo original ↗Preguntar a KiBot
"""Régressions des protections web et des identités de caméra."""

from __future__ import annotations

import http.server
import os
import tempfile
import unittest
from types import SimpleNamespace
from unittest import mock


os.environ["BLINK_BOOTSTRAP"] = "none"
_TEST_HOME = tempfile.TemporaryDirectory(prefix="blink-security-")
os.environ["BLINK_HOME"] = _TEST_HOME.name

import serve  # noqa: E402 - environnement isolé avant import


class SecuriteWebTests(unittest.TestCase):
    def handler(self, client="127.0.0.1", host="127.0.0.1", trusted_host=""):
        handler = serve.Handler.__new__(serve.Handler)
        handler.client_address = (client, 12345)
        handler.headers = {"Host": host}
        handler.path = "/api/status"
        handler.trusted_host = trusted_host
        return handler

    def test_host_local_ne_suffit_pas_a_un_client_distant(self):
        with mock.patch.dict(os.environ, {}, clear=False):
            os.environ.pop("BLINK_TRUSTED_LOOPBACK_PROXY", None)
            self.assertFalse(self.handler("192.0.2.4").hote_autorise())
            self.assertTrue(self.handler().hote_autorise())

    def test_proxy_docker_exige_un_opt_in_et_un_host_loopback(self):
        with mock.patch.dict(os.environ, {"BLINK_TRUSTED_LOOPBACK_PROXY": "1"}):
            self.assertTrue(self.handler("172.18.0.1").hote_autorise())
            self.assertFalse(
                self.handler("172.18.0.1", "192.168.1.20").hote_autorise()
            )

    def test_hote_de_confiance_sans_opt_in_reste_refuse(self):
        self.assertFalse(
            self.handler("100.101.194.5", "100.101.194.5").hote_autorise()
        )

    def test_refus_journalise_la_raison_dans_serve_erreurs_log(self):
        # Issue #10 : un trusted_host mal renseigné ne laissait qu'un 403
        # générique côté navigateur, sans rien d'exploitable pour diagnostiquer
        # à distance. hote_autorise() doit tracer pourquoi, dans le même
        # fichier que handle_error.
        import runtime as rt
        journal = rt.app_dir() / "serve_erreurs.log"
        journal.unlink(missing_ok=True)
        self.assertFalse(
            self.handler("100.101.194.5", "100.101.194.5").hote_autorise()
        )
        self.assertTrue(journal.exists())
        contenu = journal.read_text(encoding="utf-8")
        self.assertIn("100.101.194.5", contenu)
        self.assertIn("accès refusé", contenu)

    def test_hote_de_confiance_accepte_un_client_distant_qui_le_designe(self):
        self.assertTrue(
            self.handler("100.101.194.5", "100.101.194.5",
                        trusted_host="100.101.194.5").hote_autorise()
        )
        # Un autre client distant, même Host : accepté aussi, la garantie
        # vient du réseau du tunnel, pas de quel appareil précis y est.
        self.assertTrue(
            self.handler("100.64.9.9", "100.101.194.5",
                        trusted_host="100.101.194.5").hote_autorise()
        )

    def test_hote_de_confiance_cidr_accepte_toute_adresse_du_sous_reseau(self):
        # Issue #10 : client Windows en DHCP, pas d'adresse fixe a mettre
        # dans un champ IP unique - un sous-reseau entier plutot qu'une IP.
        for ip in ("192.168.1.1", "192.168.1.254"):
            with self.subTest(ip=ip):
                self.assertTrue(
                    self.handler(ip, ip, trusted_host="192.168.1.0/24").hote_autorise()
                )

    def test_hote_de_confiance_cidr_refuse_hors_du_sous_reseau(self):
        self.assertFalse(
            self.handler("192.168.2.5", "192.168.2.5",
                        trusted_host="192.168.1.0/24").hote_autorise()
        )

    def test_hote_de_confiance_cidr_malforme_refuse_sans_lever(self):
        # trusted_host est un reglage persiste : une valeur corrompue sur
        # disque (edition manuelle du fichier de reglages, vieux format)
        # ne doit jamais faire planter la verification, seulement l'echouer.
        self.assertFalse(
            self.handler("192.168.1.1", "192.168.1.1",
                        trusted_host="not/a/cidr").hote_autorise()
        )

    def test_hote_de_confiance_n_elargit_pas_a_un_host_different(self):
        self.assertFalse(
            self.handler("100.101.194.5", "un-autre-host.example",
                        trusted_host="100.101.194.5").hote_autorise()
        )

    def test_hote_de_confiance_toujours_verifie_via_origin(self):
        handler = self.handler("100.101.194.5", "100.101.194.5",
                               trusted_host="100.101.194.5")
        handler.headers["Origin"] = "http://100.101.194.5"
        self.assertTrue(handler.hote_autorise())
        handler.headers["Origin"] = "http://attaquant.example"
        self.assertFalse(handler.hote_autorise())

    def test_origin_malformee_refusee_sans_lever(self):
        # urlparse lève ValueError sur certaines formes manifestement
        # invalides (IPv6 mal fermé) plutôt que de rendre un hostname vide :
        # confirmé en direct contre une vraie instance (curl avec cet en-tête
        # provoquait une exception non rattrapée jusqu'à do_GET, connexion
        # coupée au lieu d'un 403 propre).
        handler = self.handler()
        handler.headers["Origin"] = "http://[invalid"
        self.assertFalse(handler.hote_autorise())

    def test_jeton_est_accepte_en_entete_ou_dans_url_media(self):
        handler = self.handler()
        handler.headers["X-Blink-Token"] = serve.TOKEN
        self.assertTrue(handler.jeton_valide())
        del handler.headers["X-Blink-Token"]
        handler.path = f"/media/clip/test.mp4?token={serve.TOKEN}"
        self.assertTrue(handler.jeton_valide())

    def test_get_sensible_sans_jeton_est_refuse(self):
        erreurs = []
        handler = SimpleNamespace(
            path="/api/choisir-dossier",
            hote_autorise=lambda: True,
            jeton_valide=lambda: False,
            send_error=lambda code, *_args: erreurs.append(code),
        )
        serve.Handler.do_GET(handler)
        self.assertEqual(erreurs, [403])

    def test_page_n_injecte_plus_de_handlers_inline(self):
        self.assertNotIn("onclick=", serve.PAGE)
        self.assertNotIn("onchange=", serve.PAGE)
        self.assertIn(f'<script nonce="{serve.SCRIPT_NONCE}">', serve.PAGE)
        self.assertIn("const h =", serve.PAGE)

    def _frame_ancestors(self, trusted_host: str) -> str:
        handler = self.handler(trusted_host=trusted_host)
        handler.send_header = mock.Mock()
        with mock.patch.object(http.server.BaseHTTPRequestHandler, "end_headers"):
            handler.end_headers()
        (nom, valeur), = (
            appel.args for appel in handler.send_header.call_args_list
            if appel.args[0] == "Content-Security-Policy"
        )
        return next(
            directive for directive in valeur.split("; ")
            if directive.startswith("frame-ancestors")
        )

    def test_frame_ancestors_none_sans_hote_de_confiance(self):
        self.assertEqual(self._frame_ancestors(""), "frame-ancestors 'none'")

    def test_frame_ancestors_autorise_l_hote_de_confiance_exact(self):
        # Issue #12 : embarquer la page dans un tableau de bord domotique
        # (ioBroker en iframe). Sur le meme hote que trusted_host puisque
        # c'est deja la garantie de securite en place pour l'acces direct.
        self.assertEqual(
            self._frame_ancestors("192.168.201.253"),
            "frame-ancestors 'self' 192.168.201.253:*")

    def test_frame_ancestors_accepte_tout_port_de_l_hote_de_confiance(self):
        # Sans port, une source CSP ne vaut que pour le port par defaut (80) :
        # ioBroker sert son tableau de bord sur 8081/8082, l'iframe etait
        # refusee (verifie dans Chrome, parent sur un autre port que 80).
        self.assertTrue(self._frame_ancestors("iobroker").endswith(" iobroker:*"))

    def test_frame_ancestors_ignore_une_entree_ipv6(self):
        # La syntaxe CSP n'a pas de forme pour une IPv6 litterale : l'emettre
        # donnerait une source invalide, ignoree par le navigateur.
        self.assertEqual(self._frame_ancestors("fe80::1"), "frame-ancestors 'none'")
        self.assertEqual(
            self._frame_ancestors("fe80::1,192.168.1.10"),
            "frame-ancestors 'self' 192.168.1.10:*")

    def test_frame_ancestors_reste_none_pour_un_sous_reseau_cidr(self):
        # frame-ancestors n'a pas de syntaxe pour un sous-reseau : accepter
        # un CIDR ici donnerait une fausse impression de protection.
        self.assertEqual(
            self._frame_ancestors("192.168.1.0/24"), "frame-ancestors 'none'")

    # Issue #13 : trusted_host en liste, pour meler iframe et acces direct.

    def test_liste_accepte_chacune_de_ses_entrees(self):
        liste = "192.168.1.10,192.168.201.0/24,server"
        for client, host in (("192.168.1.10", "192.168.1.10"),
                             ("192.168.201.134", "192.168.201.134"),
                             ("192.168.201.134", "server")):
            with self.subTest(host=host):
                self.assertTrue(
                    self.handler(client, host, trusted_host=liste).hote_autorise())

    def test_liste_n_elargit_pas_hors_de_ses_entrees(self):
        # Un nom n'est jamais couvert par un sous-reseau : le navigateur envoie
        # le nom tape comme Host, pas l'IP qu'il designe.
        liste = "192.168.1.10,192.168.201.0/24"
        for client, host in (("192.168.2.5", "192.168.2.5"),
                             ("192.168.201.134", "server")):
            with self.subTest(host=host):
                self.assertFalse(
                    self.handler(client, host, trusted_host=liste).hote_autorise())

    def test_frame_ancestors_liste_ne_garde_que_les_entrees_exactes(self):
        self.assertEqual(
            self._frame_ancestors("192.168.1.10,192.168.201.0/24,server"),
            "frame-ancestors 'self' 192.168.1.10:* server:*")

    def test_frame_ancestors_ignore_une_entree_invalide_d_un_reglage_edite_a_la_main(self):
        # La page de reglages refuse deja ce « ; », mais un fichier de reglages
        # modifie a la main n'y passe pas : il ne doit rien ajouter a l'en-tete.
        self.assertEqual(
            self._frame_ancestors("iobroker;script-src,192.168.1.10"),
            "frame-ancestors 'self' 192.168.1.10:*")


class IdentiteCameraTests(unittest.TestCase):
    def test_deux_cameras_homonymes_de_reseaux_distincts_ont_deux_cles(self):
        camera_a = SimpleNamespace(attributes={}, network_id="1")
        camera_b = SimpleNamespace(attributes={}, network_id="2")
        sync_a = SimpleNamespace(network_id="1", sync_id="10")
        sync_b = SimpleNamespace(network_id="2", sync_id="20")
        self.assertNotEqual(
            serve.camera_key(sync_a, "Jardin", camera_a),
            serve.camera_key(sync_b, "Jardin", camera_b),
        )

    def test_suppression_auto_distingue_les_homonymes(self):
        entries = {
            "a": {"camera": "Jardin", "network_id": "1", "sync_id": "10",
                  "source": "usb", "hub": "Maison"},
            "b": {"camera": "Jardin", "network_id": "2", "sync_id": "20",
                  "source": "usb", "hub": "Atelier"},
        }
        choices = serve.suppression_auto_choices(entries)
        self.assertEqual(len(choices), 2)
        self.assertEqual(len({choice["key"] for choice in choices}), 2)


if __name__ == "__main__":
    unittest.main()