diff --git a/AGENTS.md b/AGENTS.md index ecda46f..88e6ce2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ every idle tick to approximate the two moments state actually changes here. - Every test that logs a meal would otherwise hit the network through these new call sites; `conftest._isolate_state` patches each one (listed in `tests/_state_redirects.py`: `_cli_log`, `_cli_gate`, `_mcp`, - `_cli_body` publish sites and `_cli_gate.pull_peer_logs`) — not on + `_cli_body`, `_cli_kuchnia` publish sites and `_cli_gate.pull_peer_logs`) — not on `_sync_events`, so a direct test still exercises the real helper. A new network entry point missing from that list reaches the real remote. diff --git a/app/lib/services/kuchnia_client.dart b/app/lib/services/kuchnia_client.dart index 0c7fea8..fdbb4b0 100644 --- a/app/lib/services/kuchnia_client.dart +++ b/app/lib/services/kuchnia_client.dart @@ -32,12 +32,16 @@ const kuchniaCompany = 'kuchniavikinga'; /// The panel's own launcher tag for a browser panel session. const kuchniaLauncherType = 'BROWSER_PANEL'; -/// The only cookie the panel issues on login. -/// -/// It sets no `XSRF-TOKEN`, so the CSRF echo its own JavaScript performs is a -/// no-op for this account -- the session cookie alone authenticates. +/// The cookie that authenticates every call after login. const kuchniaSessionCookie = 'SESSION'; +/// Spring's CSRF cookie. Since ~2026-10 the panel refuses a login without it +/// echoed back in [kuchniaXsrfHeader], answering with a bare 403 that sets it. +const kuchniaXsrfCookie = 'XSRF-TOKEN'; + +/// The header that echoes [kuchniaXsrfCookie]. +const kuchniaXsrfHeader = 'X-XSRF-TOKEN'; + /// Per-request timeout. const kuchniaTimeout = Duration(seconds: 8); @@ -58,14 +62,18 @@ const kuchniaWebUnsupportedReason = /// a panel that sets more than one cookie hands over /// `SESSION=abc; Path=/, OTHER=def; Path=/`. Returns null when the cookie is /// absent, which the caller treats as a failed login rather than guessing. -String? sessionCookieFrom(String? setCookieHeader) { +String? sessionCookieFrom(String? setCookieHeader) => + cookieFrom(setCookieHeader, kuchniaSessionCookie); + +/// Extracts the cookie called [name] the same way as [sessionCookieFrom]. +String? cookieFrom(String? setCookieHeader, String name) { if (setCookieHeader == null || setCookieHeader.isEmpty) return null; // Split on both separators: ',' joins whole cookies, ';' separates a // cookie's attributes from its value. for (final part in setCookieHeader.split(RegExp('[,;]'))) { final trimmed = part.trim(); - if (trimmed.startsWith('$kuchniaSessionCookie=')) { - final value = trimmed.substring(kuchniaSessionCookie.length + 1); + if (trimmed.startsWith('$name=')) { + final value = trimmed.substring(name.length + 1); if (value.isNotEmpty) return value; } } @@ -81,8 +89,7 @@ class KuchniaSession { http.Client? client, DateTime? startedAt, }) : _client = client ?? http.Client(), - _deadline = - (startedAt ?? DateTime.now()).add(kuchniaTotalDeadline); + _deadline = (startedAt ?? DateTime.now()).add(kuchniaTotalDeadline); /// The panel e-mail. final String username; @@ -120,25 +127,23 @@ class KuchniaSession { /// sticky JSON default would mislabel it. Passing a `Map` to `http.post` /// sets the right one automatically. Future login() async { - _checkDeadline(); - final http.Response response; - try { - response = await _client - .post( - Uri.parse('$kuchniaApiBase/auth/login'), - headers: _headers, - body: {'username': username, 'password': password}, - ) - .timeout(kuchniaTimeout); - } on Exception catch (error) { - throw KuchniaError('catering panel unreachable: $error'); + var response = await _postLogin(null); + final xsrf = cookieFrom(response.headers['set-cookie'], kuchniaXsrfCookie); + if (response.statusCode == 403 && xsrf != null) { + // The CSRF handshake: the 403 handed out the token, echo it once. + response = await _postLogin(xsrf); } if (response.statusCode < 200 || response.statusCode >= 300) { // A 401 is a wrong password, and on the phone the only place to fix it - // is Settings; any other status is the panel's problem, not the user's. - final hint = response.statusCode == 401 - ? ' — check the password in Settings' - : ''; + // is Settings. A 403 that survives the handshake is the panel's + // protection; any other status is the panel's problem, not the user's. + final hint = switch (response.statusCode) { + 401 => ' — check the password in Settings', + 403 => + " — the panel's CSRF/bot protection refused it, " + 'not a password problem', + _ => '', + }; throw KuchniaError( 'catering login rejected (HTTP ${response.statusCode})$hint', ); @@ -150,6 +155,28 @@ class KuchniaSession { sessionCookie = cookie; } + Future _postLogin(String? xsrf) async { + _checkDeadline(); + final headers = { + ..._headers, + if (xsrf != null) ...{ + kuchniaXsrfHeader: xsrf, + 'Cookie': '$kuchniaXsrfCookie=$xsrf', + }, + }; + try { + return await _client + .post( + Uri.parse('$kuchniaApiBase/auth/login'), + headers: headers, + body: {'username': username, 'password': password}, + ) + .timeout(kuchniaTimeout); + } on Exception catch (error) { + throw KuchniaError('catering panel unreachable: $error'); + } + } + /// GETs [path] and decodes it, re-logging in once if the cookie expired. /// /// A cached cookie that has expired is indistinguishable from a good one diff --git a/app/pubspec.yaml b/app/pubspec.yaml index d0f8d9e..4ed5fac 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.114+1 +version: 1.0.115+1 environment: sdk: ^3.12.2 diff --git a/app/test/services/kuchnia_client_test.dart b/app/test/services/kuchnia_client_test.dart index 3a4bac9..3f9db29 100644 --- a/app/test/services/kuchnia_client_test.dart +++ b/app/test/services/kuchnia_client_test.dart @@ -42,4 +42,75 @@ void main() { expect(error.message, isNot(contains(_settingsHint))); }); }); + + group('the CSRF handshake', () { + // Since ~2026-10 the panel answers a login without `X-XSRF-TOKEN` with a + // bare 403 that sets the XSRF-TOKEN cookie; a retry echoing it gets the + // real answer. Without the retry every login failed with HTTP 403. + const token = 'csrf-123'; + + /// A panel that 403s until the token is echoed, then answers [then]. + KuchniaSession panel( + http.Response Function() then, + List seen, + ) { + return KuchniaSession( + username: 'me@example.com', + password: 'pw', + client: MockClient((request) async { + seen.add(request); + final echoed = + request.headers['X-XSRF-TOKEN'] == token && + (request.headers['Cookie'] ?? '').contains('XSRF-TOKEN=$token'); + if (!echoed) { + return http.Response( + '', + 403, + headers: {'set-cookie': 'XSRF-TOKEN=$token; Path=/; Secure'}, + ); + } + return then(); + }), + ); + } + + test('a 403 is retried once echoing the token', () async { + final seen = []; + final session = panel( + () => http.Response( + '', + 200, + headers: {'set-cookie': 'SESSION=sess-1; Path=/; HttpOnly'}, + ), + seen, + ); + await session.login(); + expect(seen, hasLength(2)); + expect(session.sessionCookie, 'sess-1'); + }); + + test('a wrong password behind CSRF still points at Settings', () async { + final session = panel(() => http.Response('Bad credentials', 401), []); + await expectLater( + session.login(), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains(_settingsHint), + ), + ), + ); + }); + + test( + 'a 403 that survives the handshake is not a password problem', + () async { + final error = await _loginError(403); + expect(error.message, contains('HTTP 403')); + expect(error.message, contains('not a password problem')); + expect(error.message, isNot(contains(_settingsHint))); + }, + ); + }); } diff --git a/diet_guard/_cli_kuchnia.py b/diet_guard/_cli_kuchnia.py index 69935a2..c166afa 100644 --- a/diet_guard/_cli_kuchnia.py +++ b/diet_guard/_cli_kuchnia.py @@ -21,6 +21,7 @@ from diet_guard._kuchnia_spread import assign_slots from diet_guard._meal_schedule_store import current_schedule from diet_guard._slots import day_slots, slot_label from diet_guard._state import now_local +from diet_guard._sync_events import publish_after_log if TYPE_CHECKING: # pragma: no cover - typing only import argparse @@ -134,5 +135,11 @@ def _log_into_empty_slots( if not yes and ask(question).strip().lower() != "y": emit("nothing logged.") return - written = sys.modules[__name__].log_dishes(plan) + module = sys.modules[__name__] + written = module.log_dishes(plan) emit(f"logged {len(written)} meal(s).") + # Push now, like every other write path: until a peer sees these, the + # phone keeps offering the same dishes and invites a double log. + failure = publish_after_log() + if failure is not None: + emit(f"saved locally, not yet published ({failure}).") diff --git a/diet_guard/_kuchnia_client.py b/diet_guard/_kuchnia_client.py index 4438baa..9d8c1a0 100644 --- a/diet_guard/_kuchnia_client.py +++ b/diet_guard/_kuchnia_client.py @@ -45,6 +45,11 @@ _LAZY_ATTRS = ("requests",) _COOKIE_DOMAIN = "panel.kuchniavikinga.pl" +# Spring's CSRF pair: the panel sets the cookie on a refused login and wants it +# echoed back in the header. +_XSRF_COOKIE = "XSRF-TOKEN" +_XSRF_HEADER = "X-XSRF-TOKEN" + def __getattr__(name: str) -> object: """Resolve the deferred ``requests`` import on first attribute access.""" @@ -111,14 +116,25 @@ class PanelSession: msg = f"catering panel unreachable: {exc}" raise KuchniaError(msg) from exc + def _post_login(self) -> requests.Response: + """POST the stored credentials, answering the CSRF handshake once. + + The panel enforces CSRF since ~2026-10: a login without the header gets + a bare 403 that also sets the ``XSRF-TOKEN`` cookie. ``requests`` + stores that cookie, so one retry echoing it gets the real answer. + """ + username, password = read_credentials() + body = {"username": username, "password": password} + response = self._request("POST", "auth/login", data=body) + token = self._session.cookies.get(_XSRF_COOKIE) + if response.status_code == HTTPStatus.FORBIDDEN and token: + self._session.headers[_XSRF_HEADER] = token + response = self._request("POST", "auth/login", data=body) + return response + def _login(self) -> None: """Authenticate with the stored credentials and cache the cookie.""" - username, password = read_credentials() - response = self._request( - "POST", - "auth/login", - data={"username": username, "password": password}, - ) + response = self._post_login() if not response.ok: msg = f"catering login rejected (HTTP {response.status_code})" if response.status_code == HTTPStatus.UNAUTHORIZED: @@ -128,6 +144,13 @@ class PanelSession: " -- check the password in " f"{_kuchnia_config.KUCHNIA_CREDENTIALS_FILE}" ) + elif response.status_code == HTTPStatus.FORBIDDEN: + # A wrong password is a 401 even behind CSRF; a 403 that + # survives the handshake is the panel's protection refusing. + msg += ( + " -- the panel's CSRF/bot protection refused it, " + "not a password problem" + ) raise KuchniaError(msg) cookie = self._session.cookies.get(SESSION_COOKIE) if cookie is None: diff --git a/diet_guard/tests/_kuchnia_fakes.py b/diet_guard/tests/_kuchnia_fakes.py index ec3c340..2a11335 100644 --- a/diet_guard/tests/_kuchnia_fakes.py +++ b/diet_guard/tests/_kuchnia_fakes.py @@ -25,10 +25,21 @@ class FakeResponse: """The slice of ``requests.Response`` the client actually touches.""" def __init__( - self, status: int = 200, payload: object = None, *, text: str = "" + self, + status: int = 200, + payload: object = None, + *, + text: str = "", + cookies: dict[str, str] | None = None, ) -> None: - """Build a response with ``status`` and either JSON or raw text.""" + """Build a response with ``status`` and either JSON or raw text. + + ``cookies`` land in the session's jar when the response is replayed, + the way ``requests`` absorbs a ``Set-Cookie`` -- the panel's CSRF 403 + hands out its ``XSRF-TOKEN`` that way. + """ self.status_code = status + self.cookies = cookies or {} self._payload = payload self.text = text or (json.dumps(payload) if payload is not None else "") @@ -62,6 +73,8 @@ class FakeSession: self.cookies = FakeCookieJar() self.calls: list[tuple[str, str]] = [] self.bodies: list[dict[str, str]] = [] + # A snapshot per request: the session-level dict mutates afterwards. + self.sent_headers: list[dict[str, str]] = [] self._responses: Iterator[FakeResponse] = iter(responses or []) self.login_cookie: str | None = "session-abc" self.raise_on_request: Exception | None = None @@ -78,14 +91,17 @@ class FakeSession: if self.raise_on_request is not None: raise self.raise_on_request self.calls.append((method, url)) + self.sent_headers.append(dict(self.headers)) if data is not None: self.bodies.append(data) if method == "POST" and url.endswith("auth/login") and self.login_cookie: self.cookies.set("SESSION", self.login_cookie) try: - return next(self._responses) + response = next(self._responses) except StopIteration: return FakeResponse(200, {}) + self.cookies.update(response.cookies) + return response def fake_requests(session: FakeSession) -> SimpleNamespace: diff --git a/diet_guard/tests/_state_redirects.py b/diet_guard/tests/_state_redirects.py index e03d78c..de0fad3 100644 --- a/diet_guard/tests/_state_redirects.py +++ b/diet_guard/tests/_state_redirects.py @@ -98,6 +98,7 @@ def state_redirects(tmp_path: Path) -> list[_patch[object]]: patch("diet_guard._cli_gate.publish_after_log", return_value=None), patch("diet_guard._mcp.publish_after_log", return_value=None), patch("diet_guard._cli_body.publish_after_log", return_value=None), + patch("diet_guard._cli_kuchnia.publish_after_log", return_value=None), # The gate's pre-lock refresh is a *second*, independent network # entry point (narrow peer-log pull, not the full tick). Without # this every test that reaches `_should_lock` hits the real remote. diff --git a/diet_guard/tests/test_kuchnia_cli.py b/diet_guard/tests/test_kuchnia_cli.py index b023e32..8175f71 100644 --- a/diet_guard/tests/test_kuchnia_cli.py +++ b/diet_guard/tests/test_kuchnia_cli.py @@ -160,3 +160,50 @@ def test_unknown_lazy_attribute_raises() -> None: missing = "not_a_real_helper" with pytest.raises(AttributeError, match="no attribute"): getattr(_cli_kuchnia, missing) + + +class TestPublish: + """``--log`` pushes the new entries, like every other write path. + + Without it the phone kept offering "5 more to go" for dishes the PC had + already logged (2026-10-17), inviting a double log. + """ + + def _log(self, lines: list[str], written: list[str]) -> object: + with ( + patch.object( + _cli_kuchnia, "refresh_delivery", return_value=([_dish()], None) + ), + patch.object(_cli_kuchnia, "log_dishes", return_value=written), + patch.object(_cli_kuchnia, "publish_after_log", return_value=None) as pub, + ): + _cli_kuchnia.cmd_kuchnia(lines.append, lambda _p: "y", log=True, yes=True) + return pub + + def test_logging_publishes_once(self) -> None: + assert self._log([], ["A", "B"]).call_count == 1 + + def test_a_dry_run_does_not_publish(self) -> None: + with ( + patch.object( + _cli_kuchnia, "refresh_delivery", return_value=([_dish()], None) + ), + patch.object(_cli_kuchnia, "publish_after_log") as pub, + ): + _cli_kuchnia.cmd_kuchnia([].append, lambda _p: "y", log=False, yes=False) + assert pub.call_count == 0 + + def test_an_outage_is_reported_not_raised(self) -> None: + lines: list[str] = [] + with ( + patch.object( + _cli_kuchnia, "refresh_delivery", return_value=([_dish()], None) + ), + patch.object(_cli_kuchnia, "log_dishes", return_value=["A"]), + patch.object(_cli_kuchnia, "publish_after_log", return_value="offline"), + ): + code = _cli_kuchnia.cmd_kuchnia( + lines.append, lambda _p: "y", log=True, yes=True + ) + assert code == 0 + assert any("not yet published (offline)" in line for line in lines) diff --git a/diet_guard/tests/test_kuchnia_csrf.py b/diet_guard/tests/test_kuchnia_csrf.py new file mode 100644 index 0000000..298c846 --- /dev/null +++ b/diet_guard/tests/test_kuchnia_csrf.py @@ -0,0 +1,70 @@ +"""The panel's CSRF handshake at login. + +Since ~2026-10 the panel enforces Spring's CSRF check: a login without an +``X-XSRF-TOKEN`` header gets a bare 403 that also sets the ``XSRF-TOKEN`` +cookie. The client retries once echoing it. Before this, every login failed +with "login rejected (HTTP 403)" whatever the password was. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from diet_guard import _kuchnia_client, _kuchnia_config +from diet_guard._kuchnia_client import PanelSession +from diet_guard._kuchnia_errors import KuchniaError +from diet_guard.tests._kuchnia_fakes import ( + FakeResponse, + FakeSession, + fake_requests, + write_credentials, +) + +_CSRF = "csrf-123" + + +@pytest.fixture(autouse=True) +def creds() -> None: + """Write a credentials file at the (redirected) config path.""" + write_credentials(_kuchnia_config.KUCHNIA_CREDENTIALS_FILE) + + +def _csrf_403() -> FakeResponse: + return FakeResponse(403, text="", cookies={"XSRF-TOKEN": _CSRF}) + + +def _authenticate(session: FakeSession) -> None: + with patch.object(_kuchnia_client, "requests", fake_requests(session)): + PanelSession().authenticate() + + +def test_a_csrf_403_is_retried_echoing_the_token() -> None: + session = FakeSession([_csrf_403(), FakeResponse(200, {})]) + _authenticate(session) + assert [call[0] for call in session.calls] == ["POST", "POST"] + assert "X-XSRF-TOKEN" not in session.sent_headers[0] + assert session.sent_headers[1]["X-XSRF-TOKEN"] == _CSRF + assert _kuchnia_config.load_session_cookie() == "session-abc" + + +def test_a_wrong_password_behind_csrf_still_names_the_password() -> None: + session = FakeSession([_csrf_403(), FakeResponse(401, {})]) + with pytest.raises(KuchniaError, match=r"HTTP 401.*check the password"): + _authenticate(session) + + +def test_a_second_403_blames_the_panel_not_the_password() -> None: + session = FakeSession([_csrf_403(), _csrf_403()]) + with pytest.raises(KuchniaError, match="not a password problem") as raised: + _authenticate(session) + assert "HTTP 403" in str(raised.value) + assert len(session.calls) == 2 + + +def test_a_403_without_a_token_is_not_retried() -> None: + session = FakeSession([FakeResponse(403, text="")]) + with pytest.raises(KuchniaError, match="not a password problem"): + _authenticate(session) + assert len(session.calls) == 1 diff --git a/docs/DOCS-kuchnia-wikinga.md b/docs/DOCS-kuchnia-wikinga.md index 9d3ebcb..cf14009 100644 --- a/docs/DOCS-kuchnia-wikinga.md +++ b/docs/DOCS-kuchnia-wikinga.md @@ -72,9 +72,9 @@ Three traps worth knowing before touching `_kuchnia_orders.py`: and the one the bundles pass a `deliveryId` to. The menu endpoint above is the one that answers. - **`deliveryId` is opaque, never a date.** The date form 400s. -- **No `XSRF-TOKEN` cookie is issued**, so the CSRF echo the panel's own - JavaScript performs is a no-op here. The client skips it when absent rather - than sending an empty header. +- **CSRF is enforced at login since ~2026-10.** No `X-XSRF-TOKEN` → a bare + 403 that sets the `XSRF-TOKEN` cookie; both clients retry once echoing it. A + login 403 is never a wrong password (that is 401). Logins rate-limit (429). ### Macros are per portion