mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 17:07:30 +01:00
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
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:
parent
8b82faf8f9
commit
e6432d5b9d
@ -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.
|
||||
|
||||
|
||||
@ -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<void> 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<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.
|
||||
///
|
||||
/// A cached cookie that has expired is indistinguishable from a good one
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<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)));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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}).")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
70
diet_guard/tests/test_kuchnia_csrf.py
Normal file
70
diet_guard/tests/test_kuchnia_csrf.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user