Skip to content

feat: add CSRF protection middleware#3332

Open
deacon-mp wants to merge 26 commits intomasterfrom
VIRTS-1953
Open

feat: add CSRF protection middleware#3332
deacon-mp wants to merge 26 commits intomasterfrom
VIRTS-1953

Conversation

@deacon-mp
Copy link
Copy Markdown
Contributor

Summary

  • Adds csrf_protect_middleware_factory to app/api/v2/security.py that protects all state-modifying HTTP methods (POST/PUT/PATCH/DELETE) against CSRF attacks for session-authenticated users
  • Safe methods (GET/HEAD/OPTIONS) and API-key-authenticated requests are exempt from CSRF checks
  • On successful login, a per-session csrf_token is generated and stored in the server-side session; the token is also exposed as a readable XSRF-TOKEN cookie so client-side JavaScript can read it for double-submit validation
  • Cookie security hardened in auth_svc.py: EncryptedCookieStorage now sets secure=True, httponly=True, max_age=86400, and samesite='Strict'
  • Middleware wired into app/api/v2/__init__.py after authentication middleware

Notes

  • The base_world fixture in tests/api/v2/test_security.py does not yet include the apply_hash=True parameter added to master in hash passwords and API keys in main config #3257 — a minor rebase/fixup will be needed before this merges
  • Test file includes a TODO comment acknowledging that the API key skip check should use a valid API key (not just any KEY header) — tracked for follow-up

Test plan

  • Review CSRF middleware logic in app/api/v2/security.py
  • Run pytest tests/api/v2/test_security.py after rebasing onto current master (to pick up apply_hash=True fixture change)
  • Verify that POST requests without X-CSRF-Token header return 403 for session-authenticated users
  • Verify that POST requests with a valid X-CSRF-Token header return 200
  • Verify that API-key-authenticated requests bypass CSRF checks
  • Confirm cookie attributes (secure, httponly, samesite=Strict) are set correctly

@deacon-mp deacon-mp requested a review from Copilot March 16, 2026 03:56
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown

gitguardian Bot commented Mar 16, 2026

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
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
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. 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


🦉 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.

@fionamccrae
Copy link
Copy Markdown
Contributor

Want to remove plugins and conf from this PR - not relevant to changes

deacon-mp and others added 21 commits March 16, 2026 15:49
- 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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_factory and wires it into the API v2 middleware stack.
  • Hardens session cookie settings in AuthService and sets an XSRF-TOKEN cookie 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_size parsing/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
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
iimport pytest
import pytest

Copilot uses AI. Check for mistakes.
Comment on lines +86 to +92
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)

Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +162 to +167
# 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)
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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

Copilot uses AI. Check for mistakes.
Comment on lines +181 to +187
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)

Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +189 to +192
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 {})
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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})

Copilot uses AI. Check for mistakes.
Comment on lines +240 to +243
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
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +250 to +267
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()


Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment thread app/api/v2/security.py
Comment on lines +91 to +93
# If API key auth is present, skip CSRF checks
if request.headers.get('KEY'):
return await handler(request)
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread app/service/auth_svc.py
Comment on lines +174 to +175
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')
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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',
)

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants