Repository navigation
feat(api): optional application-layer security with fastapi-guard (off by default) - #1980
Merged
pancacake merged 3 commits intoOct 18, 2026
Merged
Conversation
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.
2 tasks done
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
This adds an opt-in, off-by-default
security_guardsetting that mounts fastapi-guard (PureASGISecurityMiddleware) indeeptutor/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
security_guardblock insystem.json(defaults inDEFAULT_SYSTEM_SETTINGS, normalized in_normalize_system) with process overridesDEEPTUTOR_GUARD_ENABLED,DEEPTUTOR_GUARD_RATE_LIMIT,DEEPTUTOR_GUARD_RATE_LIMIT_WINDOW,DEEPTUTOR_GUARD_TRUSTED_PROXIES,DEEPTUTOR_GUARD_EXEMPT_IPS,DEEPTUTOR_GUARD_WHITELISTandDEEPTUTOR_GUARD_BLACKLIST. The Docker entrypoint ignores process overrides, so containers usesystem.json.guardis never imported and the middleware stack is unchanged. Like any new setting,system.jsongains the disabledsecurity_guardblock with its defaults on the next load, and a malformed block is replaced by the defaults. A test importsdeeptutor.api.mainin a subprocess and asserts neitherguardnorguard_coreis insys.modules.pip install 'deeptutor[guard]'(orDEEPTUTOR_EXTRAS=guardfor Docker) in the message.guard = ["fastapi-guard>=8.1.1,<9.0.0"], mirrored inrequirements/guard.txt.tests.ymlinstalls it in the Python Tests job so the new tests run in CI. Containers get it through the existingDEEPTUTOR_EXTRASpath (scripts/install_extras.pyreads the extra frompyproject.toml). There is no lock file in this repo to regenerate. Installing it pullsguard-core, which requiresredis,maxminddb,cachetools,aiohttp,requestsandpydantic. Against a full install ofrequirements/server.txtandrequirements/partners.txt, the only new packages arefastapi-guard,guard-core,maxminddbandcachetools(I diffed the two environments).redisis already pinned atrequirements/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.ssrfdetection category is turned off (enabled_detection_categoriesis every category exceptssrf) because browsers send the page address asRefererand 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.backend_workersabove 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 andCORSMiddlewareregistrations. 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 assertsCORSMiddlewareis first and the guard is present once inapp.user_middleware. Another test runs the realdeeptutor.api.mainstack with the guard enabled and asserts the access log records a guard 429 with the client address.Client address
X-Forwarded-Foris believed only fromtrusted_proxies(default127.0.0.1and::1; an explicit empty list trusts no proxy).scope["client"]fromX-Forwarded-Forbefore the guard runs. Every launch path ondevalready disables it:deeptutor/api/run_server.py:91,deeptutor_cli/main.py:217,deeptutor/runtime/launcher.py:1582and bothDockerfilelaunch 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 throughuvicorn.Config(app, log_config=None, proxy_headers=False).load()withFORWARDED_ALLOW_IPS=*and asserts rotatingX-Forwarded-Forfrom one peer still gets 429. A control test withproxy_headers=Trueshows the same rotation getting no 429. The existingtests/runtime/test_uvicorn_launch_flags.pyalready guards that every launch path passes the flag.::ffff:a.b.c.d) is not matched against IPv4trusted_proxiesentries, so a proxy'sX-Forwarded-Foris ignored. The default launch paths bind0.0.0.0and 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.--no-proxy-headers(documented indocs-for-user/SECURITY_GUARD.md).X-Forwarded-Forfrom browser requests (web/lib/backend-forward.ts:9) and does not add the browser's address. Withnext dev16.2.3 the backend saw peer127.0.0.1and no forwarded header for a LAN client. One shared limit would throttle every user together, soexempt_ips(default127.0.0.1and::1, envDEEPTUTOR_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 setsX-Forwarded-Forand is listed intrusted_proxies; a loopback request carryingX-Forwarded-Foris limited as the forwarded client. Ifwhitelistis 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)
exclude_pathsskip the attack-pattern scan only. The rate limit and IP lists still apply (a burst on/static/xgot 429 on the third request with a limit of 2).Live probe (real server bound to
0.0.0.0, curl from the LAN address and loopback, route/api/auth/status)X-Forwarded-ForX-Forwarded-For(trusted proxy)X-Forwarded-ForX-Forwarded-For(the bundled frontend case, loopback exempt from the rate limit)FORWARDED_ALLOW_IPS=*, launched with--no-proxy-headers: 25 LAN requests with rotatingX-Forwarded-ForFORWARDED_ALLOW_IPS=*, flag omittedRelated Issues
Module(s) Affected
agentsapiconfigcoreknowledgeloggingservicestoolsutilsweb(Frontend)docs(Documentation)scriptstestsrequirements/,pyproject.toml(optionalguardextra),.github/workflows/tests.ymlChecklist
dev(notmain- see the base-branch selector above).pre-commit run --all-filesand fixed any issues.Additional Notes
Verification, run the way
.github/workflows/tests.ymlruns it on Python 3.12 (ruff 0.16.0, import-linter 2.11):ruff check .,ruff format --check .,lint-importsandpython scripts/check_architecture.pypass, andscripts/check_workspace_hygiene.pypasses 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 pristinedevthe same command gives 4 failed, 10321 passed, 8 skipped. Those 4 failures are in tests this change does not touch and reproduce on pristinedevon my machine. The threetests/scripts/test_update.pytests fail locally only because a global git pre-commit hook on my machine refuses thegit committhey run in temporary repositories.tests/runtime/test_launcher.py::test_noninteractive_restart_recovers_an_actual_orphaned_backend_listeneralso fails locally on pristinedev(macOS, cause not investigated). The Tests workflow ondevat e60fb15 passed in CI.pre-commit run --all-filespasses exceptend-of-file-fixeronweb/public/provider-icons/onomeo.svg, which already fails ondevand 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.