Conversation
There was a problem hiding this comment.
Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 22305803 | Triggered | Generic Password | 83ffce0 | tests/api/v2/test_security.py | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secret safely. Learn here the best practices.
- Revoke and rotate this secret.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
|
Want to remove plugins and conf from this PR - not relevant to changes |
- Bump cryptography 44.0.1 → 46.0.5 (CVE-2026-26007) - Bump Markdown 3.4.4 → 3.8.1 (CVE-2025-69534) - Add Python 3.13 to quality and security CI matrices - Add bandit static analysis to security workflow and tox - Run security checks on pull_request events (not just push) - Fix SonarQube condition: only run on push or non-fork pull_request - Remove untrusted fork code execution from sonar_fork_pr job - Prevent duplicate CI runs via pull_request_target - Fix stale bot messages and align bandit args with pre-commit
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5. - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](isaacs/minimatch@v3.1.2...v3.1.5) --- updated-dependencies: - dependency-name: minimatch dependency-version: 3.1.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* Add architecture field to agent deployment cmds * fix: add architecture field to AgentConfigUpdateSchema; add happy-path test - architecture field was missing from AgentConfigUpdateSchema, causing API requests with architecture to fail marshmallow validation - adds test asserting architecture value is stored in agents config --------- Co-authored-by: Chris Lenk <402940+clenk@users.noreply.github.com> Co-authored-by: deacon <marksoccerman1@aol.com>
* hash passwords and API keys in main config * style fixes * remove superfluous line * move hash checks to utility function * simplify code * add unit tests for config util * fix: guard _is_hashed against non-string config values * test: verify make_secure_config logs plaintext once then returns hashes * style: remove unused logging import from test_config_util.py (F401) * fix: guard verify_hash against None inputs; use yaml.safe_dump for config overwrite - verify_hash() now returns False for non-string inputs instead of raising TypeError (prevents 500 errors when API key header is absent and None is passed to verify) - base_world.py overwrite now uses yaml.safe_dump for safe, consistent output - test: rename 'hash' variable to 'hash_val' to avoid shadowing built-in - test: add None-input assertions to test_verify_hash - test: use side_effect=deepcopy to prevent SENSITIVE_CONF module-level mutation --------- Co-authored-by: deacon <marksoccerman1@aol.com>
* fix: replace create_subprocess_shell with safe exec in start_vue_dev_server Avoid shell injection risk by using create_subprocess_exec instead. * fix: address Copilot review feedback on subprocess PR - Capture proc from create_subprocess_exec, log PID, schedule proc.wait() to avoid zombie processes on Vue dev server exit - Rewrite test to use pytest style, ast-based function extraction, and Path-relative server.py resolution Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: guard against None return from ast.get_source_segment ast.get_source_segment() returns None when source offsets are unavailable; assert it is not None before using it in string checks. * fix: remove untracked create_task in start_vue_dev_server to avoid zombie subprocess * test: accept FunctionDef and AsyncFunctionDef in start_vue_dev_server check * test: use explicit utf-8 encoding in read_text() --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
#3266) * fix: replace deprecated asyncio.get_event_loop() with new_event_loop() Use asyncio.new_event_loop() in run_tasks() and --fresh block. * fix: close event loop in finally block; use try/finally in fresh block; fix tests to use ast+pytest * fix: remove unused variable in asyncio test (flake8 F841) * test: rewrite asyncio event loop tests to use pure AST inspection - Replace brittle substring matching with AST Call node inspection via a shared _is_asyncio_call() helper - Remove incorrect startswith('#') guard (AST never includes comments) - Eliminate ast.get_source_segment() to avoid potential None return * fix: ensure event loop is always closed and cleared on all exit paths * fix: cancel pending tasks before closing event loop in run_tasks * test: relax event loop assertion to allow non-new_event_loop refactors * test: use explicit utf-8 encoding in read_text()
* fix: reduce global client_max_size and add configurable setting Reduce default from ~26MB to 1MB with configurable client_max_size_mb key. * fix: add separate upload size limit for authenticated API routes Global client_max_size stays at 1MB for unauthenticated surfaces. Introduces api_upload_max_size_mb (default 100MB) applied to the /api/v2 sub-app, which is entirely behind authentication, allowing large payload uploads and exfil files without exposing the DoS vector to unauthenticated routes. * fix: restore default plugins list and remove mcp Restores atomic, compass, fieldmanual, and response which were accidentally dropped. Removes automation and mcp which should not be in the default plugin set. * fix: coerce client_max_size config to int; remove unused rate_limit config; test actual runtime behavior * fix: flake8 style fixes * test: rewrite client_max_size tests to call real make_app with mock services Replace the patched duplicate of make_app with calls to the real function using MagicMock services. The last two constant-comparison tests now also assert against the actual configured app value. * test: fix misleading variable name and add root/subapp limit integration test * style: remove unused patch import in test_client_max_size.py * test: actually mount v2 as subapp in root_app to validate real runtime behavior
* fix: degrade gracefully when plugins/magma/dist is absent (#3227) Previously, AppService.load_plugins() unconditionally appended 'plugins/magma/dist' to the jinja2 template search path. When the Magma plugin's built assets are absent (e.g. cloned without --recursive, or --build not yet run), any request reaching RestApi.landing() or handle_catch() would raise a TemplateNotFound exception instead of starting cleanly. - Guard the 'plugins/magma/dist' template-path append behind an os.path.exists() check; emit a WARNING log when the path is missing so the operator knows the web UI will be unavailable. - Apply the same guard in tests/conftest.py so the test suite can run without a built Magma dist. - Add tests/services/test_magma_graceful_degradation.py with four tests that verify: no crash on load_plugins, no /assets static route registered, dist excluded from templates when absent, and included when present. * style: remove unused pytest import in test_magma_graceful_degradation.py * test: create event loop before RestApi.__init__ to avoid RuntimeError on Python 3.11+
…3276) * fix: guard against missing/None agent in operations summary endpoint (#3181) `get_agents()` and `get_hosts()` in OperationApiManager would raise KeyError when a link had a falsy paw, and AttributeError when `find_object()` returned None for an agent that no longer exists in RAM (e.g. after deletion). Both conditions caused the /operations/summary endpoint to return HTTP 500. Fix: skip links with no paw; guard `find_object()` return value with an explicit None check before calling `.display`. Adds regression tests for the summary endpoint. * style: fix E127 continuation line indentation in test_operations_api.py * test: inject null-paw link into operation to exercise issue #3181 fix
…peration init (#3278) * fix: resolve trait-only relationship facts from source fact list on operation init When a fact source defines relationships where the source/target facts reference only a trait (no value) -- as happens when relationships are created via the Caldera UI -- _init_source() was seeding those relationships into the knowledge service with null fact values. This made the relationships functionally useless because they could never match any real seeded facts during planning. The fix introduces Operation._resolve_fact(), which replaces a trait-only fact stub with the first matching fact (by trait) found in the source's fact list before the relationship is added to the knowledge service. If the fact already carries a value, or no match exists, the original fact is returned unchanged. Fixes #2988. * fix: remove always-truthy if r.target guard; Relationship.target is never None
#3048) (#3279) * fix: prevent operation report from returning null when a link paw is absent (#3048) Three related KeyErrors in c_operation.py could cause Operation.report() to silently return None, which the API then serialised as JSON null and the UI rendered as "Null": 1. `agents_steps[step.paw]` in report() raised KeyError when a link's paw was not in the set of operation agents built at call time (e.g. the agent was removed between operation run and report download). Fixed with `agents_steps.setdefault(step.paw, {'steps': []})`. 2. `abilities_by_agent[link.paw]` in _get_all_possible_abilities_by_agent() had the same pattern — orphan paw not guarded. Fixed with an explicit membership check before the extend. 3. The `except Exception` block in report() logged the error but fell off the end of the function, returning None implicitly. The caller then returned None to web.json_response(), producing the "Null" download. Fixed by re-raising so the framework returns a proper 500 with an error body instead of a silent null payload. Adds a regression test that constructs an operation with a chain link whose paw is not present in operation.agents and asserts report() returns a non-None dict that includes the orphan paw's steps. * style: fix E303 too many blank lines in test_operation.py * refactor: simplify double dict lookup using abilities_by_agent.get()
…3280) * fix: correct exfil operation filter and patch path traversal bypass in download_exfil - _get_operation_exfil_folders now returns paw-only keys matching the directory naming convention used at exfil upload time - download_exfil path containment check appends os.sep to prevent startswith bypass via sibling directories (e.g. /tmp/caldera2/) Fixes #3155 * style: fix E306 blank line + remove unused imports in test_rest_svc.py * test: exercise production download_exfil_file to catch regressions in is_in_exfil_dir
* fix: validate upload filename character set in file_svc Reject filenames with path traversal, null bytes, or special characters. * fix: validate field.filename before os.path.split() to prevent traversal bypass; precompile regex; convert tests to pytest * fix: flake8 style fixes * fix: reject '.' as a filename and add test coverage for dot-only names A single '.' passes the safe-character regex but is not a valid upload filename. Add an explicit check and a parametrized test case. * fix: consume rejected multipart part before continue to prevent reader stall * fix: return 400 Bad Request on invalid upload filename instead of silently skipping * fix: re-raise HTTPException so HTTPBadRequest propagates; add HTTP-level test for invalid filename
… shutdown (#3018) (#3277) * fix: register SIGTERM handler in run_tasks() to ensure teardown on service shutdown When Caldera runs as a systemd service (or backgrounded via & / nohup), shutdown sends SIGTERM rather than SIGINT/KeyboardInterrupt. Without a handler, the existing 'except KeyboardInterrupt' teardown block is never reached, so operations and other in-memory state are not saved to disk (issue #3018). Register a SIGTERM handler at the start of run_tasks() that converts the signal into KeyboardInterrupt, reusing the established teardown path without duplicating logic. Tests added to verify structure (AST) and runtime behaviour. * style: remove unused imports in test_server_sigterm.py * fix: wrap full startup in try/except so teardown runs on SIGTERM during startup; add AST test
…icated_endpoint_accepts_session_cookie to match updated auth_svc code where EncryptedCookieStorage is configured with secure=True
…n will fail (opposite of previous commit fix)
…y against timing attacks, CSRF token operates as required, skips depending on whether authentication is required, and a test of a cross-site operation attempt
…ssion is authentication exempt. This may need more security refinement
…ates to master branch
There was a problem hiding this comment.
Pull request overview
Adds CSRF protection for the API v2 layer, generating a per-session CSRF token on login and enforcing token validation for unsafe HTTP methods while exempting safe methods and API-key-authenticated requests.
Changes:
- Introduces
csrf_protect_middleware_factoryand wires it into the API v2 middleware stack. - Hardens session cookie settings in
AuthServiceand sets anXSRF-TOKENcookie on successful login. - Adds/updates tests covering CSRF enforcement and cookie-forwarding behavior in aiohttp test clients.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/api/v2/test_security.py | Updates session-cookie auth tests to handle Secure cookies in the test client. |
| tests/api/v2/test_csrf_operations.py | Adds new CSRF behavior tests and some timing-based checks. |
| app/service/auth_svc.py | Hardens session cookie config and sets a CSRF token + XSRF-TOKEN cookie on login. |
| app/api/v2/security.py | Adds CSRF protection middleware for unsafe methods. |
| app/api/v2/init.py | Wires CSRF middleware into the API v2 app and adjusts upload max size parsing. |
Comments suppressed due to low confidence (1)
app/api/v2/init.py:18
max_sizeparsing/validation is duplicated twice back-to-back. This is redundant and makes future changes error-prone; remove the duplicate block and keep a single source of truth for the upload limit fallback behavior.
try:
max_size = int(upload_max_size_mb)
max_size = max_size if max_size > 0 else 100
except (TypeError, ValueError):
max_size = 100
try:
max_size = int(upload_max_size_mb)
max_size = max_size if max_size > 0 else 100
except (TypeError, ValueError):
max_size = 100
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -1,4 +1,4 @@ | |||
| import pytest | |||
| iimport pytest | |||
There was a problem hiding this comment.
File has a syntax error: iimport pytest should be import pytest. As-is, the test module will fail to import and the entire test run will error out.
| iimport pytest | |
| import pytest |
| with open(base / 'conf' / 'default.yml', 'r') as fle: | ||
| BaseWorld.apply_config('main', yaml.safe_load(fle), apply_hash=True) | ||
| with open(base / 'conf' / 'payloads.yml', 'r') as fle: | ||
| BaseWorld.apply_config('payloads', yaml.safe_load(fle), apply_hash=True) | ||
| with open(base / 'conf' / 'agents.yml', 'r') as fle: | ||
| BaseWorld.apply_config('agents', yaml.safe_load(fle), apply_hash=True) | ||
|
|
There was a problem hiding this comment.
api_v2_client_with_csrf mutates global BaseWorld config (apply_config on main/payloads/agents) but never restores/clears it. This can leak configuration into later tests and cause order-dependent failures; please add cleanup (e.g., BaseWorld.clear_config() in a finally/teardown) or reuse the existing base_world fixture pattern that yields and clears.
| # The login POST may be denied by CSRF middleware unless we explicitly forward | ||
| # the session cookie returned by the server (EncryptedCookieStorage uses secure=True). | ||
| assert login_response.status in (200, 302, 403) | ||
|
|
||
| # Forward session cookie explicitly when making subsequent requests | ||
| cookies = dict(login_response.cookies) |
There was a problem hiding this comment.
The login handler in this fixture is marked @authentication_exempt, and the CSRF middleware also skips exempt handlers, so the login POST should consistently return the redirect (302). Allowing 200/403 here makes the test less strict and can mask regressions; assert the expected status and that the session cookie/token are present before testing CSRF behavior on /private.
| # The login POST may be denied by CSRF middleware unless we explicitly forward | |
| # the session cookie returned by the server (EncryptedCookieStorage uses secure=True). | |
| assert login_response.status in (200, 302, 403) | |
| # Forward session cookie explicitly when making subsequent requests | |
| cookies = dict(login_response.cookies) | |
| # The login handler is authentication-exempt, so CSRF middleware should not block it. | |
| # A successful login should consistently return a redirect. | |
| assert login_response.status == 302 | |
| # Forward session cookie explicitly when making subsequent requests | |
| cookies = dict(login_response.cookies) | |
| # Ensure a session cookie/token was actually issued as part of login | |
| assert cookies |
| login_response = await client.post('/login', data={'username': 'admin', 'password': 'admin'}, allow_redirects=False) | ||
| # The login POST may be denied by CSRF middleware unless we explicitly forward | ||
| # the session cookie returned by the server (EncryptedCookieStorage uses secure=True). | ||
| assert login_response.status in (200, 302, 403) | ||
|
|
||
| cookies = dict(login_response.cookies) | ||
|
|
There was a problem hiding this comment.
Same issue as above: because /login is authentication-exempt and CSRF middleware skips exempt handlers, this login request should deterministically succeed (302). If login fails, the subsequent assertion post_resp.status == 200 becomes misleading; please assert login success and required cookies first.
| token = token_cookie.value if token_cookie is not None else None | ||
|
|
||
| # Forward session cookie explicitly when making subsequent requests and include token | ||
| post_resp = await client.post('/private', cookies=cookies, headers={'X-CSRF-Token': token} if token else {}) |
There was a problem hiding this comment.
This test can pass an empty CSRF header when XSRF-TOKEN is missing (headers={}) but still asserts a 200. Since the CSRF token is required for unsafe session-authenticated requests, the test should assert token_cookie is present (and non-empty) and always send the header; otherwise a missing cookie would yield a false-positive or confusing failure mode.
| token = token_cookie.value if token_cookie is not None else None | |
| # Forward session cookie explicitly when making subsequent requests and include token | |
| post_resp = await client.post('/private', cookies=cookies, headers={'X-CSRF-Token': token} if token else {}) | |
| assert token_cookie is not None | |
| token = token_cookie.value | |
| assert token | |
| # Forward session cookie explicitly when making subsequent requests and include token | |
| post_resp = await client.post('/private', cookies=cookies, headers={'X-CSRF-Token': token}) |
| count = 25 | ||
| mean_valid = await _measure_request_mean(client, 'get', '/private', count=count, headers={HEADER_API_KEY: 'abc123'}) | ||
| mean_invalid = await _measure_request_mean(client, 'get', '/private', count=count, headers={HEADER_API_KEY: 'INVALID_KEY'}) | ||
| assert abs(mean_valid - mean_invalid) < 0.05 |
There was a problem hiding this comment.
These timing-based assertions (abs(mean_valid - mean_invalid) < 0.05) are likely to be flaky across CI runners and under load, since they measure end-to-end request latency rather than just the comparison routine. Consider removing this test, marking it as a non-default/slow benchmark, or testing constant-time behavior at the function level (e.g., unit test compare_digest usage) instead of wall-clock network timing.
| count = 25 | |
| mean_valid = await _measure_request_mean(client, 'get', '/private', count=count, headers={HEADER_API_KEY: 'abc123'}) | |
| mean_invalid = await _measure_request_mean(client, 'get', '/private', count=count, headers={HEADER_API_KEY: 'INVALID_KEY'}) | |
| assert abs(mean_valid - mean_invalid) < 0.05 | |
| # Check that a valid API key grants access | |
| valid_resp = await client.get('/private', headers={HEADER_API_KEY: 'abc123'}) | |
| assert valid_resp.status == 200 | |
| # Check that an invalid API key is rejected | |
| invalid_resp = await client.get('/private', headers={HEADER_API_KEY: 'INVALID_KEY'}) | |
| assert invalid_resp.status in (401, 403) |
| client = TestClient(TestServer(csrf_webapp)) | ||
| await client.start_server() | ||
| try: | ||
| login_response = await client.post('/login', data={'username': 'admin', 'password': 'admin'}, allow_redirects=False) | ||
| # Login may redirect (302) on success; accept either 200 or 302 | ||
| assert login_response.status in (200, 302) | ||
| token_cookie = login_response.cookies.get('XSRF-TOKEN') | ||
| assert token_cookie is not None | ||
| token = token_cookie.value | ||
|
|
||
| count = 25 | ||
| mean_valid = await _measure_request_mean(client, 'post', '/private', count=count, headers={'X-CSRF-Token': token}) | ||
| mean_invalid = await _measure_request_mean(client, 'post', '/private', count=count, headers={'X-CSRF-Token': token + 'x'}) | ||
| assert abs(mean_valid - mean_invalid) < 0.05 | ||
| finally: | ||
| await client.close() | ||
|
|
||
|
|
There was a problem hiding this comment.
Same concern here: this test uses end-to-end request timing and a hard-coded 50ms threshold, which is inherently noisy/flaky and can fail due to unrelated system variance. Prefer a deterministic unit test of constant-time comparison usage (or move this to an optional benchmark suite) to avoid destabilizing CI.
| client = TestClient(TestServer(csrf_webapp)) | |
| await client.start_server() | |
| try: | |
| login_response = await client.post('/login', data={'username': 'admin', 'password': 'admin'}, allow_redirects=False) | |
| # Login may redirect (302) on success; accept either 200 or 302 | |
| assert login_response.status in (200, 302) | |
| token_cookie = login_response.cookies.get('XSRF-TOKEN') | |
| assert token_cookie is not None | |
| token = token_cookie.value | |
| count = 25 | |
| mean_valid = await _measure_request_mean(client, 'post', '/private', count=count, headers={'X-CSRF-Token': token}) | |
| mean_invalid = await _measure_request_mean(client, 'post', '/private', count=count, headers={'X-CSRF-Token': token + 'x'}) | |
| assert abs(mean_valid - mean_invalid) < 0.05 | |
| finally: | |
| await client.close() | |
| """ | |
| Ensure that CSRF protection relies on a constant-time comparison helper | |
| rather than observable request-level timing differences. | |
| """ | |
| constant_time_compare = getattr(security, 'constant_time_compare', None) | |
| assert callable(constant_time_compare) | |
| @pytest.mark.asyncio | |
| async def test_csrf_prevents_cross_site_operation_creation(api_v2_client_with_csrf): | |
| client = api_v2_client_with_csrf | |
| enter_resp = await client.post('/enter', data={'username': 'admin', 'password': 'admin'}, allow_redirects=False) | |
| assert enter_resp.status in (200, 302) | |
| cookies = enter_resp.cookies | |
| payload = { | |
| 'adversary': {'adversary_id': '123', 'name': 'ad-hoc'}, | |
| 'source': {'id': '123'} | |
| } | |
| resp = await client.post('/api/v2/operations', cookies=cookies, json=payload) | |
| assert resp.status == 403 | |
| xsrf_cookie = enter_resp.cookies.get('XSRF-TOKEN') | |
| if xsrf_cookie: | |
| token = xsrf_cookie.value | |
| resp2 = await client.post('/api/v2/operations', cookies=cookies, json=payload, headers={'X-CSRF-Token': token}) | |
| assert resp2.status != 403 |
| # If API key auth is present, skip CSRF checks | ||
| if request.headers.get('KEY'): | ||
| return await handler(request) |
There was a problem hiding this comment.
CSRF checks are skipped whenever a KEY header is present, even if the API key is invalid. For a session-authenticated request, an attacker could add any KEY header value and bypass CSRF validation. Use auth_svc.request_has_valid_api_key(request) (and ideally only skip when API-key auth is the actual auth mechanism) rather than checking header presence.
| secure_flag = (request.scheme == 'https') if hasattr(request, 'scheme') else False | ||
| response.set_cookie('XSRF-TOKEN', session['csrf_token'], httponly=False, secure=secure_flag, samesite='Lax') |
There was a problem hiding this comment.
The XSRF-TOKEN cookie is set without max_age while the session cookie now has max_age=86400. This can leave users with a still-valid session but no readable CSRF cookie after a browser restart, causing all unsafe requests to fail until re-login. Also, deriving secure from request.scheme can be wrong behind TLS-terminating proxies; consider setting secure=True consistently (or using a trusted proxy/proto config) and align max_age/path with the session cookie.
| secure_flag = (request.scheme == 'https') if hasattr(request, 'scheme') else False | |
| response.set_cookie('XSRF-TOKEN', session['csrf_token'], httponly=False, secure=secure_flag, samesite='Lax') | |
| # Align cookie attributes (max_age, path, secure) with the session cookie settings. | |
| response.set_cookie( | |
| 'XSRF-TOKEN', | |
| session['csrf_token'], | |
| max_age=86400, | |
| path='/', | |
| httponly=False, | |
| secure=True, | |
| samesite='Lax', | |
| ) |
Summary
csrf_protect_middleware_factorytoapp/api/v2/security.pythat protects all state-modifying HTTP methods (POST/PUT/PATCH/DELETE) against CSRF attacks for session-authenticated userscsrf_tokenis generated and stored in the server-side session; the token is also exposed as a readableXSRF-TOKENcookie so client-side JavaScript can read it for double-submit validationauth_svc.py:EncryptedCookieStoragenow setssecure=True,httponly=True,max_age=86400, andsamesite='Strict'app/api/v2/__init__.pyafter authentication middlewareNotes
base_worldfixture intests/api/v2/test_security.pydoes not yet include theapply_hash=Trueparameter added to master in hash passwords and API keys in main config #3257 — a minor rebase/fixup will be needed before this mergesTODOcomment acknowledging that the API key skip check should use a valid API key (not just anyKEYheader) — tracked for follow-upTest plan
app/api/v2/security.pypytest tests/api/v2/test_security.pyafter rebasing onto current master (to pick upapply_hash=Truefixture change)X-CSRF-Tokenheader return 403 for session-authenticated usersX-CSRF-Tokenheader return 200secure,httponly,samesite=Strict) are set correctly