From 1d0ff2f72e3f089118fc23b550d90324de75a373 Mon Sep 17 00:00:00 2001 From: migatu Date: Fri, 21 Aug 2026 13:10:16 +0200 Subject: [PATCH] PRE-27: generowanie tekstu przez model znika bez uprawnienia MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wymaganie było mocniejsze niż schowanie przycisku: po niedostępnej funkcji nie może zostać śladu w źródle strony. Największy wyciek nie był przyciskiem — _prompt_block wstrzykiwał w stronę CAŁY katalog modeli jako JSON (dostawcy, nazwy modeli, rozmiary okien kontekstu), na każdym ekranie z generowaniem, niezależnie od uprawnień konta. Druga dziura była głębsza: handlery nie sprawdzały nic. Trasa /interpret musi być dostępna dla konta z Interpretacjami, więc granica przebiega WEWNĄTRZ niej, po polu `action` — spreparowany formularz z action=prompt generował tekst, a action=export pobierał arkusz, mimo że szablon chował oba przyciski. Akcja bez uprawnienia wraca do akcji domyślnej ekranu zamiast dawać błąd: komunikat „brak uprawnień do generowania" sam w sobie mówiłby, że taka funkcja istnieje. Ślady wycięte także tam, gdzie nie były kontrolką: znaczniki natalNote/ reportNatal, pliki models.js/progress.js/natal.js/predictions.js oraz podtytuł ekranu Skompiluj, który wymieniał interpretację od AI z nazwy. 12 testów; 9 z nich pada na kodzie sprzed poprawki (sprawdzone przez cofnięcie zmian w app/). Kontrola pozytywna pilnuje, żeby nie przechodziły dlatego, że generowanie jest zepsute dla wszystkich. Co-Authored-By: Claude Opus 5 --- docs/konta-i-uprawnienia.md | 29 ++++ services/presentation/app/main.py | 41 ++++- .../presentation/app/templates/compile.html | 12 ++ .../presentation/app/templates/interpret.html | 13 +- .../presentation/app/templates/timeline.html | 6 +- .../presentation/tests/test_ukrywanie_ai.py | 150 ++++++++++++++++++ 6 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 services/presentation/tests/test_ukrywanie_ai.py diff --git a/docs/konta-i-uprawnienia.md b/docs/konta-i-uprawnienia.md index d693e6a..9291b1d 100644 --- a/docs/konta-i-uprawnienia.md +++ b/docs/konta-i-uprawnienia.md @@ -64,6 +64,35 @@ jest niewidoczna do chwili, gdy ktoś ją znajdzie. Trasa bez wpisu w mapie wyma uprawnień administracyjnych: **przeoczenie ma zamykać, nie otwierać**. Test przechodzi po wszystkich trasach aplikacji i wymaga, by każda była opisana. +### Gdy jedna trasa robi kilka rzeczy + +Mapa tras nie wystarcza tam, gdzie jedna trasa obsługuje kilka funkcji naraz. +`POST /interpret` liczy horoskop, ale to samo pole `action` prosi o wygenerowanie +promptu, napisanie horoskopu przez model albo eksport arkusza. Konto, które ma +mieć Interpretacje bez generowania, musi dostać tę trasę — więc granica przebiega +wewnątrz niej, po akcjach: `_AKCJE_POD_UPRAWNIENIEM` przypisuje akcji uprawnienie, +a `_dozwolona_akcja()` sprowadza żądanie bez uprawnienia do akcji domyślnej ekranu. + +Sprowadza — nie odrzuca. Komunikat „brak uprawnień do generowania" sam w sobie +mówiłby, że taka funkcja istnieje, czyli łamałby zasadę drugą po to, żeby +wyegzekwować pierwszą. Akcja bez uprawnienia ma wyglądać na literówkę w formularzu. + +### Ślad to nie tylko przycisk + +Wymaganie brzmi „nie może być śladu", i to jest mocniejsze niż schowanie kontrolki. +Największym wyciekiem po stronie generowania nie był przycisk, tylko **katalog +modeli** — nazwy dostawców, nazwy modeli i rozmiary okien kontekstu — wstrzykiwany +w stronę blokiem JSON na każdym ekranie z generowaniem, niezależnie od uprawnień. +Dlatego `_llm_catalog_for()` oddaje pusty katalog kontu bez uprawnienia, a szablony +trzymają pod bramką także znaczniki (`natalNote`, `reportNatal`), pliki skryptów +(`models.js`, `progress.js`, `natal.js`, `predictions.js`) i **zdania opisujące +funkcję** — podtytuł ekranu Skompiluj wymieniał interpretację od AI z nazwy. + +Testu na to nie da się napisać przez „sprawdź, czy przycisku nie ma": trzeba +sprawdzić, że w źródle strony nie ma żadnego z tych śladów, i mieć kontrolę +pozytywną, że przy uprawnieniu wszystkie są. Inaczej test przechodzi także wtedy, +gdy generowanie jest zepsute dla wszystkich. + ## Gdzie leżą konta Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`), diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index d3860ae..3a2731c 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -129,6 +129,35 @@ def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]: return utc.isoformat(), label +# Akcja formularza → uprawnienie, którego wymaga. Akcji spoza tej mapy nie +# ograniczamy; akcja Z mapy, na którą konto nie ma prawa, jest traktowana jak +# NIEZNANA — czyli wraca do zachowania domyślnego ekranu. +_AKCJE_POD_UPRAWNIENIEM = {"prompt": "ai", "horoscope": "ai", "export": "export"} + + +def _dozwolona_akcja(request: Request, action: str, domyslna: str) -> str: + """Akcja, której konto nie ma prawa użyć, zachowuje się jak literówka. + + NIE zwracamy błędu ani 404: komunikat „brak uprawnień do generowania" sam + w sobie mówiłby, że taka funkcja istnieje (PRE-27). Ukrycie przycisku chroni + tylko przed przypadkiem — kto zna nazwę pola, wyśle ją wprost, i to TU musi + się zatrzymać.""" + wymagane = _AKCJE_POD_UPRAWNIENIEM.get(action) + if wymagane and wymagane not in _perms(request): + return domyslna + return action + + +def _llm_catalog_for(request: Request) -> dict: + """Katalog modeli — pusty, gdy konto nie generuje tekstu przez model. + + Katalog trafia do strony jako blok JSON, więc bez tego ograniczenia nazwy + dostawców, modeli i rozmiary okien kontekstu byłyby w źródle KAŻDEJ strony + Interpretacji i Kalendarza, także dla kont, które o modelach nie mają prawa + wiedzieć.""" + return _llm_catalog() if "ai" in _perms(request) else {} + + def _llm_catalog() -> dict: """Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony — pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie.""" @@ -175,7 +204,7 @@ def chart_form(request: Request): return templates.TemplateResponse( request, "chart.html", {"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL, - "llm_catalog": _llm_catalog()}, + "llm_catalog": _llm_catalog_for(request)}, ) @@ -472,7 +501,7 @@ def interpret_form(request: Request): return templates.TemplateResponse( request, "interpret.html", {"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL, - "llm_catalog": _llm_catalog()}, + "llm_catalog": _llm_catalog_for(request)}, ) @@ -494,8 +523,9 @@ def interpret_run( form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget, "llm_provider": llm_provider, "llm_model": llm_model} + action = _dozwolona_akcja(request, action, "report") ctx: dict = {"form": form, "result": None, "error": None, "moment": None, - "llm_catalog": _llm_catalog()} + "llm_catalog": _llm_catalog_for(request)} try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label @@ -540,7 +570,7 @@ def timeline_form(request: Request): return templates.TemplateResponse( request, "timeline.html", {"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL, - "llm_catalog": _llm_catalog()}, + "llm_catalog": _llm_catalog_for(request)}, ) @@ -563,8 +593,9 @@ def timeline_run( form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon, "from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget, "llm_provider": llm_provider, "llm_model": llm_model} + action = _dozwolona_akcja(request, action, "timeline") ctx: dict = {"form": form, "result": None, "error": None, "moment": None, - "llm_catalog": _llm_catalog()} + "llm_catalog": _llm_catalog_for(request)} try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label diff --git a/services/presentation/app/templates/compile.html b/services/presentation/app/templates/compile.html index d514896..d3ebcce 100644 --- a/services/presentation/app/templates/compile.html +++ b/services/presentation/app/templates/compile.html @@ -3,8 +3,16 @@ {% set nav_active = "compile" %} {% block content %} +{# Opis raportu zależy od tego, z czego konto może go złożyć. Wersja z AI + wymieniałaby funkcje, których część kont nie ma — a wymienienie ich w zdaniu + jest takim samym śladem jak przycisk. #} +{% if can(request, 'ai') %}

Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.

+{% else %} +

Składa policzony horoskop w jeden raport. Dane pobiera z pozostałych +zakładek — nie trzeba nic przepisywać.

+{% endif %} {# Gotowość materiału — wypełniana po stronie przeglądarki (compile.js), bo część kawałków (AI) mieszka w magazynie lokalnym, nie na serwerze. #} @@ -120,14 +128,18 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t {% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %} {% include "_result_tables.html" %} + {% if can(request, 'ai') %} {# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
+ {% endif %} {% endif %} +{% if can(request, 'ai') %} {# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza — każdy sprawdza, czy jego kontener jest na stronie (tu go nie ma). #} +{% endif %} {% endblock %} diff --git a/services/presentation/app/templates/interpret.html b/services/presentation/app/templates/interpret.html index b639102..d4ae8ea 100644 --- a/services/presentation/app/templates/interpret.html +++ b/services/presentation/app/templates/interpret.html @@ -35,14 +35,19 @@ {% if location_label %}

Wstępnie wpisano lokalizację: {{ location_label }} ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".

{% endif %} - {% include "_prompt_block.html" %} -

+ {# Cały blok generowania — budżet, dostawca, model i wynik — istnieje TYLKO + dla kont z tym uprawnieniem. Nie ukrywamy go stylem: bez uprawnienia nie ma + go w źródle strony, razem z katalogiem modeli, który blok wstrzykuje. #} + {% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %} +{% if can(request, 'ai') %}

{% endif %}
{% if can(request, 'export') %}{% endif %} + {% if can(request, 'ai') %} + {% endif %}
@@ -99,8 +104,12 @@ +{# Skrypty obsługujące WYŁĄCZNIE generowanie przez model. Bez uprawnienia nie ma + ich nawet w odsyłaczach — sam adres pliku podpowiadałby, co program potrafi. #} +{% if can(request, 'ai') %} {# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #} +{% endif %} {% endblock %} diff --git a/services/presentation/app/templates/timeline.html b/services/presentation/app/templates/timeline.html index dc17d89..eb840c1 100644 --- a/services/presentation/app/templates/timeline.html +++ b/services/presentation/app/templates/timeline.html @@ -40,12 +40,14 @@ - {% include "_prompt_block.html" %} + {% if can(request, 'ai') %}{% include "_prompt_block.html" %}{% endif %}
+ {% if can(request, 'ai') %} + {% endif %}
@@ -93,8 +95,10 @@ +{% if can(request, 'ai') %} {# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #} +{% endif %} {% endblock %} diff --git a/services/presentation/tests/test_ukrywanie_ai.py b/services/presentation/tests/test_ukrywanie_ai.py new file mode 100644 index 0000000..ee05681 --- /dev/null +++ b/services/presentation/tests/test_ukrywanie_ai.py @@ -0,0 +1,150 @@ +"""Generowanie tekstu przez model znika bez uprawnienia (PRE-27 × LOG-29/30/31). + +Wymaganie jest MOCNIEJSZE niż „przycisk ma być schowany": po niedostępnej funkcji +nie może zostać ŚLAD w źródle strony. To rozróżnienie ma znaczenie praktyczne — +ukrycie stylem albo atrybutem `hidden` zostawia wszystko w HTML-u, więc podgląd +źródła nadal opowiada, co program potrafi. + +Największy wyciek nie był przyciskiem: blok generowania wstrzykiwał w stronę CAŁY +katalog modeli jako JSON — nazwy dostawców, nazwy modeli i rozmiary okien +kontekstu — i robił to na każdej stronie Interpretacji i Kalendarza, niezależnie +od uprawnień konta. +""" +import pytest + +from app import accounts as store + +# Słowa, które nie mają prawa paść przy koncie bez uprawnienia. Mieszanka +# widocznego tekstu, nazw pól formularza, identyfikatorów dla JS i nazw plików — +# bo ślad zostawia każde z nich z osobna. +SLADY_AI = [ + "Generuj prompt", "Napisz horoskop", "llmCatalog", "llmProvider", "llmModel", + "llm_provider", "llm_model", "prompt_budget", "Budżet promptu", "Dostawca", + "anthropic", "openai", "models.js", "progress.js", + 'value="prompt"', 'value="horoscope"', +] + +RAPORT = {"engine": "test", "objects": []} +KALENDARZ = {"engine": "test", "days": []} + + +@pytest.fixture() +def env(tmp_path, monkeypatch): + monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json")) + monkeypatch.setenv("APP_USER", "szef") + monkeypatch.setenv("APP_PASSWORD", "tajne-szefa") + monkeypatch.delenv("APP_USERS", raising=False) + monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") + monkeypatch.setenv("SESSION_SECRET", "t" * 64) + monkeypatch.setenv("COOKIES_INSECURE", "1") + + +def _auth(user): + from app import security, session + + return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"} + + +def _client(monkeypatch): + """Klient ze śledzeniem tego, co poszło do warstwy logicznej.""" + from starlette.testclient import TestClient + + from app.main import app, logic + + wolania = [] + monkeypatch.setattr(logic, "report", lambda **kw: (wolania.append("report"), RAPORT)[1]) + monkeypatch.setattr(logic, "timeline", lambda **kw: (wolania.append("timeline"), KALENDARZ)[1]) + monkeypatch.setattr(logic, "prompt", + lambda **kw: (wolania.append("prompt"), {"prompt": "TREŚĆ"})[1]) + monkeypatch.setattr(logic, "horoscope", + lambda **kw: (wolania.append("horoscope"), {"text": "TREŚĆ"})[1]) + monkeypatch.setattr(logic, "llm_models", lambda: {"providers": {"openai": ["gpt-x"]}}) + return TestClient(app, follow_redirects=False), wolania + + +NATAL = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2", + "lat": "50.06", "lon": "19.94"} +OKRES = {**NATAL, "from_date": "2026-01-01", "to_date": "2026-02-01"} + + +# ── brak śladu w źródle strony ────────────────────────────────────────── + +@pytest.mark.parametrize("ekran", ["/interpret", "/timeline"]) +def test_no_trace_of_the_model_features_without_the_permission(env, monkeypatch, ekran): + store.create("ala", "x", ["interpret", "timeline"]) + c, _ = _client(monkeypatch) + html = c.get(ekran, headers=_auth("ala")).text + for slad in SLADY_AI: + assert slad not in html, f"{ekran} zdradza „{slad}”" + + +@pytest.mark.parametrize("ekran", ["/interpret", "/timeline"]) +def test_the_same_screens_carry_them_with_the_permission(env, monkeypatch, ekran): + """Kontrola pozytywna. Bez niej test wyżej przechodziłby także wtedy, gdyby + generowanie było zepsute dla wszystkich.""" + store.create("ola", "x", ["interpret", "timeline", "ai"]) + c, _ = _client(monkeypatch) + html = c.get(ekran, headers=_auth("ola")).text + for musi_byc in ("Generuj prompt", "llmCatalog", "llmProvider", "models.js"): + assert musi_byc in html, f"{ekran}: brakuje „{musi_byc}” mimo uprawnienia" + + +def test_the_model_catalogue_never_reaches_a_page_it_should_not(env, monkeypatch): + """Katalog jedzie do strony blokiem JSON. Bez ograniczenia nazwy dostawców, + modeli i rozmiary okien kontekstu byłyby w źródle KAŻDEJ strony.""" + store.create("ala", "x", ["interpret"]) + c, _ = _client(monkeypatch) + html = c.get("/interpret", headers=_auth("ala")).text + assert "gpt-x" not in html and "providers" not in html + + +# ── spreparowane żądanie nie kupuje funkcji ───────────────────────────── + +@pytest.mark.parametrize("akcja", ["prompt", "horoscope"]) +def test_a_crafted_request_cannot_generate_without_the_permission(env, monkeypatch, akcja): + """Ukrycie przycisku chroni przed przypadkiem, nie przed kimś, kto zna nazwę + pola. Granicą jest handler.""" + store.create("ala", "x", ["interpret"]) + c, wolania = _client(monkeypatch) + r = c.post("/interpret", headers=_auth("ala"), data={**NATAL, "action": akcja}) + assert r.status_code == 200 + assert akcja not in wolania, f"wygenerowano „{akcja}” bez uprawnienia" + assert wolania == ["report"], f"oczekiwano powrotu do domyślnej akcji, było: {wolania}" + + +@pytest.mark.parametrize("akcja", ["prompt", "horoscope"]) +def test_the_same_on_the_calendar_screen(env, monkeypatch, akcja): + store.create("ala", "x", ["timeline"]) + c, wolania = _client(monkeypatch) + c.post("/timeline", headers=_auth("ala"), data={**OKRES, "action": akcja}) + assert akcja not in wolania and wolania == ["timeline"] + + +def test_a_crafted_export_needs_the_export_permission_too(env, monkeypatch): + """Ta sama dziura obejmowała eksport: szablon chował przycisk, handler nie + sprawdzał niczego, więc spreparowane żądanie pobierało arkusz.""" + store.create("ala", "x", ["interpret"]) + c, _ = _client(monkeypatch) + r = c.post("/interpret", headers=_auth("ala"), data={**NATAL, "action": "export"}) + assert r.status_code == 200 + assert "spreadsheetml" not in r.headers.get("content-type", ""), "pobrano arkusz bez uprawnienia" + + +def test_the_streaming_route_stays_shut(env, monkeypatch): + """Pisanie horoskopu idzie osobną trasą — ona też musi być zamknięta, + i to jako NIEISTNIEJĄCA, nie jako odmowa.""" + store.create("ala", "x", ["interpret"]) + c, wolania = _client(monkeypatch) + r = c.post("/horoscope/stream", headers=_auth("ala"), json={"profile": "natal"}) + assert r.status_code == 404 + assert wolania == [] + + +def test_refusal_never_explains_itself(env, monkeypatch): + """Komunikat „brak uprawnień do generowania" sam w sobie mówiłby, że taka + funkcja istnieje. Akcja bez uprawnienia ma zachowywać się jak literówka.""" + store.create("ala", "x", ["interpret"]) + c, _ = _client(monkeypatch) + html = c.post("/interpret", headers=_auth("ala"), data={**NATAL, "action": "prompt"}).text + for slowo in ("uprawnie", "brak dostępu", "nie wolno", "zabronione", "AI", "model"): + assert slowo not in html, f"odpowiedź tłumaczy odmowę słowem „{slowo}”"