Skip to content

feat(api): optional application-layer security with fastapi-guard (off by default) - #1980

Merged
pancacake merged 3 commits into
HKUDS:devfrom
rennf93:feat/fastapi-guard-security-middleware
Oct 18, 2026
Merged

pancacake merged 3 commits into
HKUDS:devfrom
rennf93:feat/fastapi-guard-security-middleware

Conversation

@rennf93

@rennf93 rennf93 commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Description

This adds an opt-in, off-by-default security_guard setting that mounts fastapi-guard (PureASGISecurityMiddleware) in deeptutor/api/main.py. It adds application-layer security in front of the routers: attack-pattern screening of the URL path, query string and headers with automatic bans for clients that keep probing, an IP allowlist and denylist, and a per-client rate limit. This PR wires that safe first subset; #1979 lists what else the engine offers (per-route policies, honeypots, behavior rules, emergency and passive modes) for follow-ups. It is an application-layer control and complements whatever reverse proxy, WAF or firewall already runs at the edge.

Disclosure: I maintain fastapi-guard, the library this wires in. I used AI assistance to prepare this and reviewed and verified everything myself.

Opt-in and configuration

  • A security_guard block in system.json (defaults in DEFAULT_SYSTEM_SETTINGS, normalized in _normalize_system) with process overrides DEEPTUTOR_GUARD_ENABLED, DEEPTUTOR_GUARD_RATE_LIMIT, DEEPTUTOR_GUARD_RATE_LIMIT_WINDOW, DEEPTUTOR_GUARD_TRUSTED_PROXIES, DEEPTUTOR_GUARD_EXEMPT_IPS, DEEPTUTOR_GUARD_WHITELIST and DEEPTUTOR_GUARD_BLACKLIST. The Docker entrypoint ignores process overrides, so containers use system.json.
  • Disabled (the default): guard is never imported and the middleware stack is unchanged. Like any new setting, system.json gains the disabled security_guard block with its defaults on the next load, and a malformed block is replaced by the defaults. A test imports deeptutor.api.main in a subprocess and asserts neither guard nor guard_core is in sys.modules.
  • Enabled without the package: the backend fails at startup with pip install 'deeptutor[guard]' (or DEEPTUTOR_EXTRAS=guard for Docker) in the message.
  • The dependency is an optional extra, guard = ["fastapi-guard>=8.1.1,<9.0.0"], mirrored in requirements/guard.txt. tests.yml installs it in the Python Tests job so the new tests run in CI. Containers get it through the existing DEEPTUTOR_EXTRAS path (scripts/install_extras.py reads the extra from pyproject.toml). There is no lock file in this repo to regenerate. Installing it pulls guard-core, which requires redis, maxminddb, cachetools, aiohttp, requests and pydantic. Against a full install of requirements/server.txt and requirements/partners.txt, the only new packages are fastapi-guard, guard-core, maxminddb and cachetools (I diffed the two environments). redis is already pinned at requirements/server.txt:28.

Configuration the guard runs with (deeptutor/api/security_guard.py)

  • enable_redis=False, security_headers={"enabled": False}, enable_cors=False, detection_scan_body=False (prompts, documents and chat are free text), enable_agent=False.
  • Rate limit 300 requests per 60 seconds per address by default. It counts every HTTP request from an address, including the frontend's background polling (some panels poll every 1 to 5 seconds). The 10 per 60 seconds that fastapi-guard 8.1.1 uses when nothing is configured would trip on normal use. The Next.js frontend serves its static assets itself, so they do not reach the backend and are not counted.
  • The ssrf detection category is turned off (enabled_detection_categories is every category except ssrf) because browsers send the page address as Referer and guard-core 4.4.0 flags localhost and LAN addresses in it, which would return 400 on every UI request. I reproduced that through the real app before the fix (Referer: http://localhost:3000/ gave 400) and tests now cover localhost, 127.0.0.1 and LAN Referers (200) and an attack pattern in a Referer (400). To be revisited when the engine fix ships.
  • Counters are per process, so with backend_workers above 1 the effective limit is up to the limit times the worker count. WebSocket handshakes are not screened.

Middleware order and streaming

  • install_security_guard(app) runs before the access-log and CORSMiddleware registrations. Starlette makes the last-added middleware outermost, so CORS stays outermost (429 and 403 responses carry CORS headers) and the access log sees what the guard rejects. A test asserts CORSMiddleware is first and the guard is present once in app.user_middleware. Another test runs the real deeptutor.api.main stack with the guard enabled and asserts the access log records a guard 429 with the client address.
  • The guard is pure ASGI, so SSE and other streaming responses are not buffered. A test drives a generator that only yields the next chunk after the previous one was delivered, which would deadlock under a buffering middleware.

Client address

  • The address is the socket peer. X-Forwarded-For is believed only from trusted_proxies (default 127.0.0.1 and ::1; an explicit empty list trusts no proxy).
  • Uvicorn's own proxy handling would rewrite scope["client"] from X-Forwarded-For before the guard runs. Every launch path on dev already disables it: deeptutor/api/run_server.py:91, deeptutor_cli/main.py:217, deeptutor/runtime/launcher.py:1582 and both Dockerfile launch lines (the fix(api): preserve actual proxy peer identity #1426 hardening). So no launch path needs a new flag, and I did not touch any of them. A test builds the app through uvicorn.Config(app, log_config=None, proxy_headers=False).load() with FORWARDED_ALLOW_IPS=* and asserts rotating X-Forwarded-For from one peer still gets 429. A control test with proxy_headers=True shows the same rotation getting no 429. The existing tests/runtime/test_uvicorn_launch_flags.py already guards that every launch path passes the flag.
  • Dual-stack binds: with guard-core 4.4.0, a peer in IPv4-mapped form (::ffff:a.b.c.d) is not matched against IPv4 trusted_proxies entries, so a proxy's X-Forwarded-For is ignored. The default launch paths bind 0.0.0.0 and are unaffected. For a manual :: bind, listing the mapped form as well (for example ::ffff:10.0.0.0/104) works with 4.4.0, is documented and has a test that should keep passing when the engine handles mapped peers itself.
  • Starting the backend by hand: keep --no-proxy-headers (documented in docs-for-user/SECURITY_GUARD.md).
  • One limit I verified: the bundled Next.js proxy deletes X-Forwarded-For from browser requests (web/lib/backend-forward.ts:9) and does not add the browser's address. With next dev 16.2.3 the backend saw peer 127.0.0.1 and no forwarded header for a LAN client. One shared limit would throttle every user together, so exempt_ips (default 127.0.0.1 and ::1, env DEEPTUTOR_GUARD_EXEMPT_IPS) exempts loopback from the rate limit only. Through the frontend, requests are screened for attack patterns and the denylist, but not rate-limited. Per-client limits apply to requests that reach the backend directly, or through a reverse proxy that sets X-Forwarded-For and is listed in trusted_proxies; a loopback request carrying X-Forwarded-For is limited as the forwarded client. If whitelist is set, loopback must be listed or the frontend is refused (403); this is documented and tested. The guard does not ban loopback addresses. Passing the client address through the frontend proxy would be a separate change.

Engine behaviour I verified with fastapi-guard 8.1.1 / guard-core 4.4.0 (later engine versions may change it)

  • Paths in the default exclude_paths skip the attack-pattern scan only. The rate limit and IP lists still apply (a burst on /static/x got 429 on the third request with a limit of 2).
  • An allowlisted address also skips the attack-pattern scan and the rate limit (six requests over a limit of 2 and an attack query all returned 200).

Live probe (real server bound to 0.0.0.0, curl from the LAN address and loopback, route /api/auth/status)

Case Result
Guard off: 60 requests from LAN, 60 from loopback, attack in query all 200
Guard on, limit 20: attack in query from LAN 400
Guard on: 25 LAN requests with a rotating spoofed X-Forwarded-For 200 x19, then 429
Guard on: 25 loopback requests with rotating X-Forwarded-For (trusted proxy) all 200, each forwarded client has its own window
Guard on: 25 loopback requests with one X-Forwarded-For 200 x20, then 429
Guard on: 25 loopback requests with no X-Forwarded-For (the bundled frontend case, loopback exempt from the rate limit) all 200
Guard on, FORWARDED_ALLOW_IPS=*, launched with --no-proxy-headers: 25 LAN requests with rotating X-Forwarded-For 200 x20, then 429
Control, FORWARDED_ALLOW_IPS=*, flag omitted all 200
Guard on in an environment without fastapi-guard startup fails with the install hint

Related Issues

Module(s) Affected

  • agents
  • api
  • config
  • core
  • knowledge
  • logging
  • services
  • tools
  • utils
  • web (Frontend)
  • docs (Documentation)
  • scripts
  • tests
  • Other: requirements/, pyproject.toml (optional guard extra), .github/workflows/tests.yml

Checklist

  • This PR targets dev (not main - see the base-branch selector above).
  • I have read and followed the contribution guidelines.
  • My code follows the project's coding standards.
  • I have run pre-commit run --all-files and fixed any issues.
  • I have added relevant tests for my changes.
  • I have updated the documentation (if necessary).
  • My changes do not introduce any new security vulnerabilities.

Additional Notes

Verification, run the way .github/workflows/tests.yml runs it on Python 3.12 (ruff 0.16.0, import-linter 2.11):

  • ruff check ., ruff format --check ., lint-imports and python scripts/check_architecture.py pass, and scripts/check_workspace_hygiene.py passes on the committed tree.
  • pytest -q tests deeptutor/learning/tests: 4 failed, 10348 passed, 8 skipped (the 27 new tests account for the difference in passes). On pristine dev the same command gives 4 failed, 10321 passed, 8 skipped. Those 4 failures are in tests this change does not touch and reproduce on pristine dev on my machine. The three tests/scripts/test_update.py tests fail locally only because a global git pre-commit hook on my machine refuses the git commit they run in temporary repositories. tests/runtime/test_launcher.py::test_noninteractive_restart_recovers_an_actual_orphaned_backend_listener also fails locally on pristine dev (macOS, cause not investigated). The Tests workflow on dev at e60fb15 passed in CI.
  • The new file has 27 tests. I ran it repeatedly and with random ordering (pytest-randomly, six seeds), all green. Every test uses its own client address because guard state is process-wide.
  • pre-commit run --all-files passes except end-of-file-fixer on web/public/provider-icons/onomeo.svg, which already fails on dev and which I did not touch. I did not run the web gate because no frontend file changed.

Project background: Guard Core's first library is fastapi-guard, public since August 2024 (first PyPI release, 0.1.0, on 2024-08-26). It has 825 GitHub stars, 65 releases and about 62,000 PyPI downloads in the last month (pypistats.org). The framework-agnostic engines and the adapters for other frameworks and languages came later and run the same detection rules, checked in CI against shared conformance fixtures.

Add an off-by-default `security_guard` setting (system.json, with
DEEPTUTOR_GUARD_* deployment overrides) that mounts fastapi-guard's
pure-ASGI middleware in front of the routers: per-address rate limit,
IP allow/deny lists, ban list and an attack-pattern scan of path, query
and headers. Request bodies, WebSocket handshakes, Redis, CORS and
security headers are left alone.

The guard is added before the access-log and CORS middlewares so CORS
stays outermost and 429/403 responses carry CORS headers. When disabled
the package is never imported. When enabled without the package the
backend fails at startup with an install hint. fastapi-guard ships as
the optional `guard` extra (requirements/guard.txt, DEEPTUTOR_EXTRAS).
The bundled frontend proxy drops X-Forwarded-For and does not add the
browser address, so every browser reaches the backend as loopback and
would share one rate-limit bucket. Add `security_guard.exempt_ips`
(default 127.0.0.1 and ::1, DEEPTUTOR_GUARD_EXEMPT_IPS) passed to
SecurityConfig. It skips the rate limit only; the denylist, bans and the
attack-pattern scan still apply, and loopback requests that carry
X-Forwarded-For are still limited as the forwarded client.

Document that an allowlist must list loopback for the bundled frontend,
and qualify engine behaviour statements with the engine versions they
were verified on.
Browsers send the page URL as Referer, and guard-core 4.4.0 flags
localhost and LAN addresses in it as ssrf, which returned 400 for every
UI request. Pass every detection category except ssrf, with tests for
local-address Referers (200) and an attack pattern in a Referer (400).

Say that system.json gains the disabled security_guard block on the next
load instead of claiming nothing changes, document the IPv4-mapped peer
caveat of guard-core 4.4.0 with the mapped-form workaround, fix the
session handoff line citation, and drop the launch-flag test that
duplicates tests/runtime/test_uvicorn_launch_flags.py.

Add tests for the mapped-form workaround and for the access log
recording a guard rejection through the real application stack.
@pancacake
pancacake merged commit ab45d2a into HKUDS:dev Oct 18, 2026
15 checks passed
alvinets pushed a commit to alvinets/DeepTutor that referenced this pull request Nov 8, 2026
…y-middleware

feat(api): optional application-layer security with fastapi-guard (off by default)
alvinets pushed a commit to alvinets/DeepTutor that referenced this pull request Nov 8, 2026
…y-middleware

feat(api): optional application-layer security with fastapi-guard (off by default)
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.

2 participants