GSoC 2026 Final Submission: OWASP FinBot CTF — Challenge Library, Rate Limiting, MCP Server, Bug Fixes - #567
Open
ashike24 wants to merge 16 commits into
Open
Conversation
…n _extract_texts across all 6 detectors
mekaizen flagged a race condition on PR GenAI-Security-Project#532: if the process crashed or lost its Redis connection between the separate INCR and EXPIRE calls, a key could be left permanently incremented with no TTL, silently blocking that namespace forever. A previous attempt to fix this (commit a03481a) used a Redis pipeline and switched the limiter to fail-closed on Redis errors, but that commit was accidentally left stranded on a feature branch and never merged - and it also changed EXPIRE to run on every request instead of only the first, altering the fixed-window semantics, and would have broken the existing test_expire_only_set_on_first_request test and removed the just-added Retry-After header. This commit takes a narrower approach: a single atomic Lua script (EVAL) that performs the INCR and conditionally sets EXPIRE (only on the first request in the window) in one Redis round trip. This fully eliminates the race - there is no window between the two operations where a crash could leave an orphaned key - while preserving the original fixed-window behavior, the Retry-After header, and the deliberate fail-open design (rate limiting is a soft quota guard, not an auth control, so a Redis outage should not take down agent endpoints entirely). Test suite updated to mock eval() instead of incr()/expire() separately, with a new test asserting the operation happens in a single call. Verified live against real Redis in Docker: 10 requests succeed, 11th correctly returns 429, no fail-open errors logged. Full suite: 398 passed, 26 skipped, 6 pre-existing failures unchanged, 0 regressions.
- New finbot/mcp_server/ package with submit_tool_call and get_scoring_results tools, wrapping VendorChatAssistant and UserChallengeProgressRepository respectively - Auth via existing SessionManager (session_id -> SessionContext), no new auth mechanism needed - Runs as separate Docker service (mcp_server) on port 8100, sharing sqlite_data volume + Redis with the main app - Fixed pre-existing .env bug: DATABASE_URL pointed outside the mounted sqlite_data volume, causing data loss on container rebuild for both services - Verified end-to-end: get_scoring_results fully working; submit_tool_call reaches the LLM call correctly, blocked only by a missing OPENAI_API_KEY in local dev env (pre-existing gap, unrelated to this change) - start_challenge_session from original proposal wording dropped: no such concept exists in the codebase; sessions already exist via login and challenges progress implicitly
Wraps the OpenAI responses.create() call in ChatAssistantBase.stream_response() with a try/except. Previously any failure (auth error, timeout, transient outage) crashed the SSE stream unhandled, leaving the user's browser stuck on 'Thinking...' forever with no feedback. Now logs the error and streams a clean user-facing message instead. Found during Week 9-10 integration testing. Verified via live browser test and full LLM test suite (82 passed, 0 regressions).
request.url.path retains the mount prefix (e.g. /vendor/api/v1/chat) for requests handled by mounted sub-apps like the vendor, ctf, and admin portals. is_api_request() only checked for a /api/ prefix, so it never matched these paths - API error responses (429, 403, 404, 500, etc.) from those apps were rendering the generic HTML error page instead of a JSON response, with the wrong status text shown (e.g. a 429 rate-limit response displayed as '400 - Bad Request'). Fixed to check for /api/ as a path segment anywhere in the path, so it works both for mounted sub-apps and routes handled directly by the root app. Found and fixed during Week 11 final QA pass. Verified live in browser (429 now returns correct JSON body/status) and via full test suite (398 passed, 26 skipped, 6 pre-existing failures unchanged, 0 regressions).
Walks through adding a new CTF challenge end-to-end: YAML definition, detector class, the required-but-non-obvious detector registration step in detectors/implementations/__init__.py, unit test coverage, and a pre-PR checklist. Uses the asi03-ghost-in-the-machine challenge and AgentImpersonationDetector as a worked example throughout, based on the actual patterns in the codebase. Written for Week 11 per the proposal timeline.
This was referenced Aug 12, 2026
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.
GSoC 2026 Final Submission: OWASP FinBot CTF — Challenge Library, Rate Limiting, MCP Server, Bug Fixes
This pull request is my Google Summer of Code 2026 final work submission for OWASP FinBot CTF, covering the full coding period, May 25 to August 31, 2026.
Contributor: Ashik E (@ashike24) | IIT Kanpur | ashike24@iitk.ac.in Mentors: Nirupam Ghosh & Sanjeev Agarwal
Project Goal
FinBot CTF is OWASP's capture-the-flag platform for AI agent security. Two vulnerability categories, ASI-03 (Identity and Impersonation) and ASI-05 (Indirect Prompt Injection), had no challenges at the start of this program. The goal of this project was to build out those categories, harden the platform's agent-facing surface against real operational risk (rate limiting, MCP access), and improve platform reliability through live testing.
What I Did
Challenge Library (ASI-03 and ASI-05)
Built six new CTF challenges, each pairing a realistic prompt-injection attack scenario against a FinBot agent with an LLM-judge detector class.
Each challenge follows the same shape: a vendor embeds an instruction payload disguised as a legitimate system message inside data the platform treats as trusted (a registration field, an invoice line item, a document, an email, or tool output), and the target agent acts on it as if it were a real directive. Every detector is an LLM judge that checks whether the agent's own output shows it treated attacker-controlled content as a legitimate authority.
Testing: 78 pytest unit tests across all six detectors, 97% coverage (target was 85%).
Rate Limiting
Built a per-namespace, fixed-window Redis rate limiter and wired it into all five agent-triggering vendor routes, keyed as
finbot:ratelimit:{namespace}:agent, reusing the existing event-bus Redis client. Ten integration tests cover the counting logic, namespace isolation, Redis-failure behavior, and the error response.Race condition found and corrected: the original increment-then-expire logic used two separate Redis calls, which allowed concurrent requests in a new window to both skip setting the key's expiry. This is fixed with a single atomic Lua script (
EVAL) that performs the increment and conditional expiry in one round-trip, eliminating the race without changing the response contract. The design intentionally remains fail-open — rate limiting here is a soft usage guard, not an access control, and failing closed would take every agent endpoint offline platform-wide during any transient Redis disruption.Verified with the full test suite (398 passed, 26 skipped, 6 pre-existing failures unchanged, 0 regressions) and live testing against real Redis in Docker (first 10 requests succeed, 11th onward correctly rejected).
MCP Server Layer
Added an MCP (Model Context Protocol) server layer (
finbot/mcp_server/) exposing FinBot to MCP-compatible clients such as Claude Desktop and Codex Desktop, via two tools:submit_tool_call(session_id, message)→VendorChatAssistant.stream_response(), the same code path behind the rate-limited chat endpointget_scoring_results(session_id)→UserChallengeProgressRepository, namespace- and user-scoped challenge dataReuses the existing session-based
SessionManagerfor auth rather than introducing a new mechanism. Astart_challenge_sessiontool from the original proposal wording was deliberately dropped — no such concept exists in the platform, since challenge progress advances passively as a player interacts with the agent.Along the way, found and fixed a pre-existing
.envmisconfiguration that placed the SQLite database outside the mounted Docker volume, silently losing data across container rebuilds for both the main app and the new MCP service.Bug Fixes
Three real, previously unknown bugs were found through live testing of the running application (not through code review or unit tests alone) and fixed:
ChatAssistantBase.stream_response()had no error handling around the LLM call. Fixed with try/except and a clean user-facing error instead of a silent hang.is_api_request()matched/api/only as a path prefix, but/vendor,/ctf, and/adminare mounted as independent ASGI sub-apps whose paths retain the mount prefix. Fixed by checking for/api/as a path segment instead of a prefix.Documentation
docs/contributing-challenges.md) covering the full YAML + detector + registration + test workflow for future challenge authors, using the real "Ghost in the Machine" challenge as a worked example. Flags the non-obvious, silently-failing step of forgetting to register a new detector indetectors/implementations/__init__.py.Current State
All work across the program is consolidated into this single pull request: 16 commits, 33 files changed, a clean diff against
upstream/main. Verified with a full clean test-suite run and a live Docker stack across all three services (app, redis, mcp_server) — the app, database, and Redis all start healthy together, with all 25 challenges and 43 badges loading correctly.What's Left
What Got Merged
This pull request represents the complete, current state of the contribution and is open against
GenAI-Security-Project/finbot-ctf:main.Challenges and Learnings
- The most significant lesson of the program came late: a review comment I had previously replied to as "fixed" turned out not to actually be reflected in the codebase, because the fix commits had only been pushed to a feature branch and never merged into
- Building the rate limiter's first fix attempt taught me that a superficially working solution (passing tests, correct behavior under light load) can still hide a race condition that only shows up under true concurrency. The eventual atomic Lua-script approach was a better fix precisely because it removed the possibility of the race entirely, rather than narrowing the window.
- Two of the three real bugs in this program (the chat-stream hang and the mounted sub-app error-handling bug) were only found by exercising the running application through a browser, not through unit tests or code review. This reinforced, for me, that automated test coverage and live integration testing catch fundamentally different classes of defects, and a mature workflow needs both.
# GSoC 2026 Final Submission: OWASP FinBot CTF — Challenge Library, Rate Limiting, MCP Server, Bug Fixesmain. This was a process failure, not a code failure, and it changed how I now verify my own claims to a reviewer — I no longer consider a review thread resolved until I can point to the exact commit on the branch actually under review.This pull request is my Google Summer of Code 2026 final work submission for OWASP FinBot CTF, covering the full coding period, May 25 to August 31, 2026.
Contributor: Ashik E ([@ashike24](https://github.com/ashike24)) | IIT Kanpur | ashike24@iitk.ac.in
Mentor: Nirupam Ghosh
Project Goal
FinBot CTF is OWASP's capture-the-flag platform for AI agent security. Two vulnerability categories, ASI-03 (Identity and Impersonation) and ASI-05 (Indirect Prompt Injection), had no challenges at the start of this program. The goal of this project was to build out those categories, harden the platform's agent-facing surface against real operational risk (rate limiting, MCP access), and improve platform reliability through live testing.
What I Did
Challenge Library (ASI-03 and ASI-05)
Built six new CTF challenges, each pairing a realistic prompt-injection attack scenario against a FinBot agent with an LLM-judge detector class.
AgentImpersonationDetectorCrossAgentTrustDetectorRoleHijackDetectorDocumentInjectionDetectorEmailInjectionDetectorToolOutputInjectionDetectorEach challenge follows the same shape: a vendor embeds an instruction payload disguised as a legitimate system message inside data the platform treats as trusted (a registration field, an invoice line item, a document, an email, or tool output), and the target agent acts on it as if it were a real directive. Every detector is an LLM judge that checks whether the agent's own output shows it treated attacker-controlled content as a legitimate authority.
Testing: 78 pytest unit tests across all six detectors, 97% coverage (target was 85%).
Rate Limiting
Built a per-namespace, fixed-window Redis rate limiter and wired it into all five agent-triggering vendor routes, keyed as
finbot:ratelimit:{namespace}:agent, reusing the existing event-bus Redis client. Ten integration tests cover the counting logic, namespace isolation, Redis-failure behavior, and the error response.Race condition found and corrected: the original increment-then-expire logic used two separate Redis calls, which allowed concurrent requests in a new window to both skip setting the key's expiry. This is fixed with a single atomic Lua script (
EVAL) that performs the increment and conditional expiry in one round-trip, eliminating the race without changing the response contract. The design intentionally remains fail-open — rate limiting here is a soft usage guard, not an access control, and failing closed would take every agent endpoint offline platform-wide during any transient Redis disruption.Verified with the full test suite (398 passed, 26 skipped, 6 pre-existing failures unchanged, 0 regressions) and live testing against real Redis in Docker (first 10 requests succeed, 11th onward correctly rejected).
MCP Server Layer
Added an MCP (Model Context Protocol) server layer (
finbot/mcp_server/) exposing FinBot to MCP-compatible clients such as Claude Desktop and Codex Desktop, via two tools:submit_tool_call(session_id, message)→VendorChatAssistant.stream_response(), the same code path behind the rate-limited chat endpointget_scoring_results(session_id)→UserChallengeProgressRepository, namespace- and user-scoped challenge dataReuses the existing session-based
SessionManagerfor auth rather than introducing a new mechanism. Astart_challenge_sessiontool from the original proposal wording was deliberately dropped — no such concept exists in the platform, since challenge progress advances passively as a player interacts with the agent.Along the way, found and fixed a pre-existing
.envmisconfiguration that placed the SQLite database outside the mounted Docker volume, silently losing data across container rebuilds for both the main app and the new MCP service.Bug Fixes
Three real, previously unknown bugs were found through live testing of the running application (not through code review or unit tests alone) and fixed:
ChatAssistantBase.stream_response()had no error handling around the LLM call. Fixed with try/except and a clean user-facing error instead of a silent hang.is_api_request()matched/api/only as a path prefix, but/vendor,/ctf, and/adminare mounted as independent ASGI sub-apps whose paths retain the mount prefix. Fixed by checking for/api/as a path segment instead of a prefix.Documentation
docs/contributing-challenges.md) covering the full YAML + detector + registration + test workflow for future challenge authors, using the real "Ghost in the Machine" challenge as a worked example. Flags the non-obvious, silently-failing step of forgetting to register a new detector indetectors/implementations/__init__.py.Current State
All work across the program is consolidated into this single pull request: 16 commits, 33 files changed, a clean diff against
upstream/main. Verified with a full clean test-suite run and a live Docker stack across all three services (app, redis, mcp_server) — the app, database, and Redis all start healthy together, with all 25 challenges and 43 badges loading correctly.What's Left
What Got Merged
This pull request represents the complete, current state of the contribution and is open against
GenAI-Security-Project/finbot-ctf:main.Challenges and Learnings
main. This was a process failure, not a code failure, and it changed how I now verify my own claims to a reviewer — I no longer consider a review thread resolved until I can point to the exact commit on the branch actually under review.