Catering: answer the panel's CSRF 403 at login; kuchnia --log publishes
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-17 12:42:13 +02:00
parent 8b82faf8f9
commit e6432d5b9d
11 changed files with 302 additions and 40 deletions

View File

@ -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 - Every test that logs a meal would otherwise hit the network through these new
call sites; `conftest._isolate_state` patches each one call sites; `conftest._isolate_state` patches each one
(listed in `tests/_state_redirects.py`: `_cli_log`, `_cli_gate`, `_mcp`, (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 `_sync_events`, so a direct test still exercises the real helper. A new
network entry point missing from that list reaches the real remote. network entry point missing from that list reaches the real remote.

View File

@ -32,12 +32,16 @@ const kuchniaCompany = 'kuchniavikinga';
/// The panel's own launcher tag for a browser panel session. /// The panel's own launcher tag for a browser panel session.
const kuchniaLauncherType = 'BROWSER_PANEL'; const kuchniaLauncherType = 'BROWSER_PANEL';
/// The only cookie the panel issues on login. /// The cookie that authenticates every call after 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.
const kuchniaSessionCookie = 'SESSION'; 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. /// Per-request timeout.
const kuchniaTimeout = Duration(seconds: 8); const kuchniaTimeout = Duration(seconds: 8);
@ -58,14 +62,18 @@ const kuchniaWebUnsupportedReason =
/// a panel that sets more than one cookie hands over /// a panel that sets more than one cookie hands over
/// `SESSION=abc; Path=/, OTHER=def; Path=/`. Returns null when the cookie is /// `SESSION=abc; Path=/, OTHER=def; Path=/`. Returns null when the cookie is
/// absent, which the caller treats as a failed login rather than guessing. /// 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; if (setCookieHeader == null || setCookieHeader.isEmpty) return null;
// Split on both separators: ',' joins whole cookies, ';' separates a // Split on both separators: ',' joins whole cookies, ';' separates a
// cookie's attributes from its value. // cookie's attributes from its value.
for (final part in setCookieHeader.split(RegExp('[,;]'))) { for (final part in setCookieHeader.split(RegExp('[,;]'))) {
final trimmed = part.trim(); final trimmed = part.trim();
if (trimmed.startsWith('$kuchniaSessionCookie=')) { if (trimmed.startsWith('$name=')) {
final value = trimmed.substring(kuchniaSessionCookie.length + 1); final value = trimmed.substring(name.length + 1);
if (value.isNotEmpty) return value; if (value.isNotEmpty) return value;
} }
} }
@ -81,8 +89,7 @@ class KuchniaSession {
http.Client? client, http.Client? client,
DateTime? startedAt, DateTime? startedAt,
}) : _client = client ?? http.Client(), }) : _client = client ?? http.Client(),
_deadline = _deadline = (startedAt ?? DateTime.now()).add(kuchniaTotalDeadline);
(startedAt ?? DateTime.now()).add(kuchniaTotalDeadline);
/// The panel e-mail. /// The panel e-mail.
final String username; final String username;
@ -120,25 +127,23 @@ class KuchniaSession {
/// sticky JSON default would mislabel it. Passing a `Map` to `http.post` /// sticky JSON default would mislabel it. Passing a `Map` to `http.post`
/// sets the right one automatically. /// sets the right one automatically.
Future<void> login() async { Future<void> login() async {
_checkDeadline(); var response = await _postLogin(null);
final http.Response response; final xsrf = cookieFrom(response.headers['set-cookie'], kuchniaXsrfCookie);
try { if (response.statusCode == 403 && xsrf != null) {
response = await _client // The CSRF handshake: the 403 handed out the token, echo it once.
.post( response = await _postLogin(xsrf);
Uri.parse('$kuchniaApiBase/auth/login'),
headers: _headers,
body: {'username': username, 'password': password},
)
.timeout(kuchniaTimeout);
} on Exception catch (error) {
throw KuchniaError('catering panel unreachable: $error');
} }
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
// A 401 is a wrong password, and on the phone the only place to fix it // 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. // is Settings. A 403 that survives the handshake is the panel's
final hint = response.statusCode == 401 // protection; any other status is the panel's problem, not the user's.
? ' — check the password in Settings' 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( throw KuchniaError(
'catering login rejected (HTTP ${response.statusCode})$hint', 'catering login rejected (HTTP ${response.statusCode})$hint',
); );
@ -150,6 +155,28 @@ class KuchniaSession {
sessionCookie = cookie; sessionCookie = cookie;
} }
Future<http.Response> _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. /// 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 /// A cached cookie that has expired is indistinguishable from a good one

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go." description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none' publish_to: 'none'
version: 1.0.114+1 version: 1.0.115+1
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2

View File

@ -42,4 +42,75 @@ void main() {
expect(error.message, isNot(contains(_settingsHint))); 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<http.Request> 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 = <http.Request>[];
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<KuchniaError>().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)));
},
);
});
} }

View File

@ -21,6 +21,7 @@ from diet_guard._kuchnia_spread import assign_slots
from diet_guard._meal_schedule_store import current_schedule from diet_guard._meal_schedule_store import current_schedule
from diet_guard._slots import day_slots, slot_label from diet_guard._slots import day_slots, slot_label
from diet_guard._state import now_local from diet_guard._state import now_local
from diet_guard._sync_events import publish_after_log
if TYPE_CHECKING: # pragma: no cover - typing only if TYPE_CHECKING: # pragma: no cover - typing only
import argparse import argparse
@ -134,5 +135,11 @@ def _log_into_empty_slots(
if not yes and ask(question).strip().lower() != "y": if not yes and ask(question).strip().lower() != "y":
emit("nothing logged.") emit("nothing logged.")
return return
written = sys.modules[__name__].log_dishes(plan) module = sys.modules[__name__]
written = module.log_dishes(plan)
emit(f"logged {len(written)} meal(s).") 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}).")

View File

@ -45,6 +45,11 @@ _LAZY_ATTRS = ("requests",)
_COOKIE_DOMAIN = "panel.kuchniavikinga.pl" _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: def __getattr__(name: str) -> object:
"""Resolve the deferred ``requests`` import on first attribute access.""" """Resolve the deferred ``requests`` import on first attribute access."""
@ -111,14 +116,25 @@ class PanelSession:
msg = f"catering panel unreachable: {exc}" msg = f"catering panel unreachable: {exc}"
raise KuchniaError(msg) from 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: def _login(self) -> None:
"""Authenticate with the stored credentials and cache the cookie.""" """Authenticate with the stored credentials and cache the cookie."""
username, password = read_credentials() response = self._post_login()
response = self._request(
"POST",
"auth/login",
data={"username": username, "password": password},
)
if not response.ok: if not response.ok:
msg = f"catering login rejected (HTTP {response.status_code})" msg = f"catering login rejected (HTTP {response.status_code})"
if response.status_code == HTTPStatus.UNAUTHORIZED: if response.status_code == HTTPStatus.UNAUTHORIZED:
@ -128,6 +144,13 @@ class PanelSession:
" -- check the password in " " -- check the password in "
f"{_kuchnia_config.KUCHNIA_CREDENTIALS_FILE}" 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) raise KuchniaError(msg)
cookie = self._session.cookies.get(SESSION_COOKIE) cookie = self._session.cookies.get(SESSION_COOKIE)
if cookie is None: if cookie is None:

View File

@ -25,10 +25,21 @@ class FakeResponse:
"""The slice of ``requests.Response`` the client actually touches.""" """The slice of ``requests.Response`` the client actually touches."""
def __init__( 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: ) -> 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.status_code = status
self.cookies = cookies or {}
self._payload = payload self._payload = payload
self.text = text or (json.dumps(payload) if payload is not None else "") self.text = text or (json.dumps(payload) if payload is not None else "")
@ -62,6 +73,8 @@ class FakeSession:
self.cookies = FakeCookieJar() self.cookies = FakeCookieJar()
self.calls: list[tuple[str, str]] = [] self.calls: list[tuple[str, str]] = []
self.bodies: list[dict[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._responses: Iterator[FakeResponse] = iter(responses or [])
self.login_cookie: str | None = "session-abc" self.login_cookie: str | None = "session-abc"
self.raise_on_request: Exception | None = None self.raise_on_request: Exception | None = None
@ -78,14 +91,17 @@ class FakeSession:
if self.raise_on_request is not None: if self.raise_on_request is not None:
raise self.raise_on_request raise self.raise_on_request
self.calls.append((method, url)) self.calls.append((method, url))
self.sent_headers.append(dict(self.headers))
if data is not None: if data is not None:
self.bodies.append(data) self.bodies.append(data)
if method == "POST" and url.endswith("auth/login") and self.login_cookie: if method == "POST" and url.endswith("auth/login") and self.login_cookie:
self.cookies.set("SESSION", self.login_cookie) self.cookies.set("SESSION", self.login_cookie)
try: try:
return next(self._responses) response = next(self._responses)
except StopIteration: except StopIteration:
return FakeResponse(200, {}) return FakeResponse(200, {})
self.cookies.update(response.cookies)
return response
def fake_requests(session: FakeSession) -> SimpleNamespace: def fake_requests(session: FakeSession) -> SimpleNamespace:

View File

@ -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._cli_gate.publish_after_log", return_value=None),
patch("diet_guard._mcp.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_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 # The gate's pre-lock refresh is a *second*, independent network
# entry point (narrow peer-log pull, not the full tick). Without # entry point (narrow peer-log pull, not the full tick). Without
# this every test that reaches `_should_lock` hits the real remote. # this every test that reaches `_should_lock` hits the real remote.

View File

@ -160,3 +160,50 @@ def test_unknown_lazy_attribute_raises() -> None:
missing = "not_a_real_helper" missing = "not_a_real_helper"
with pytest.raises(AttributeError, match="no attribute"): with pytest.raises(AttributeError, match="no attribute"):
getattr(_cli_kuchnia, missing) 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)

View File

@ -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

View File

@ -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 and the one the bundles pass a `deliveryId` to. The menu endpoint above is
the one that answers. the one that answers.
- **`deliveryId` is opaque, never a date.** The date form 400s. - **`deliveryId` is opaque, never a date.** The date form 400s.
- **No `XSRF-TOKEN` cookie is issued**, so the CSRF echo the panel's own - **CSRF is enforced at login since ~2026-10.** No `X-XSRF-TOKEN` → a bare
JavaScript performs is a no-op here. The client skips it when absent rather 403 that sets the `XSRF-TOKEN` cookie; both clients retry once echoing it. A
than sending an empty header. login 403 is never a wrong password (that is 401). Logins rate-limit (429).
### Macros are per portion ### Macros are per portion