From e35453348d47ad775c38aa323362c912ce093ba7 Mon Sep 17 00:00:00 2001 From: Josh Bendson Date: Wed, 14 Jan 2026 23:26:11 -0600 Subject: [PATCH 1/4] fix: Pass CSRF token to auto-discovery HTMX partials Fixes "invalid CSRF token" error when clicking Add button on auto-discovery page. Root cause: HTMX partials for GitLab/GitHub repository listings were not passing csrf_token to template context, causing forms to submit with empty CSRF tokens. Changes: - _build_gitlab_repos_response(): Get csrf_token from cookie and pass to template - _build_github_repos_response(): Same fix for GitHub partials - Added CSRF_TOKEN_BUG_ANALYSIS.md documenting the issue and solution The fix retrieves the existing CSRF token from the cookie (preserving session token) or generates a new one if needed. --- CSRF_TOKEN_BUG_ANALYSIS.md | 234 ++++++++++++++++++++++++++ src/code_indexer/server/web/routes.py | 12 +- 2 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 CSRF_TOKEN_BUG_ANALYSIS.md diff --git a/CSRF_TOKEN_BUG_ANALYSIS.md b/CSRF_TOKEN_BUG_ANALYSIS.md new file mode 100644 index 00000000..50b468bd --- /dev/null +++ b/CSRF_TOKEN_BUG_ANALYSIS.md @@ -0,0 +1,234 @@ +# CSRF Token Bug Analysis - Auto-Discovery Page + +## Problem + +Users get "invalid CSRF token" error when clicking "Add" button on auto-discovery repo listing. + +## Root Cause + +**Bug Location**: `src/code_indexer/server/web/routes.py` + +The HTMX partials for GitLab and GitHub repository listings **do not pass `csrf_token` to their templates**: + +```python +# Lines 4367-4392 +def _build_gitlab_repos_response( + request: Request, + repositories: Optional[list] = None, + # ... other params ... +): + """Build GitLab repos partial template response.""" + return templates.TemplateResponse( + "partials/gitlab_repos.html", + { + "request": request, + "repositories": repositories or [], + # ... other context ... + # ❌ MISSING: "csrf_token": csrf_token + }, + ) + +# Lines 4395-4420 - Same issue for GitHub +def _build_github_repos_response(...): + # ❌ MISSING: "csrf_token": csrf_token +``` + +But the templates **require** csrf_token: + +```html + + + + + +``` + +## The Flow (Broken) + +``` +1. User loads /auto-discovery + └─> auto_discovery_page() generates csrf_token + └─> Template includes csrf_token ✅ + +2. User clicks "GitLab" or "GitHub" tab + └─> HTMX loads /partials/auto-discovery/gitlab + └─> gitlab_repos_partial() called + └─> _build_gitlab_repos_response() renders template + └─> Template context DOES NOT include csrf_token ❌ + └─> Forms render with: (EMPTY!) + +3. User clicks "Add" button on a repo + └─> Form submits with empty csrf_token + └─> add_golden_repo() validates CSRF + └─> validate_login_csrf_token() returns False + └─> Error: "Invalid CSRF token" ❌ +``` + +## Additional Issues + +### CSRF Token Expiration (10 Minutes) + +```python +# Line 80 +CSRF_MAX_AGE_SECONDS = 600 # 10 minutes +``` + +If user browses repos for >10 minutes before clicking "Add", token expires. + +### HTMX Partial Requests Don't Refresh Token + +When HTMX loads partials: +- Main page has csrf_token (10 min lifetime) +- Partials loaded later don't refresh the token +- User might spend 5 minutes browsing → token has 5 min left +- After more browsing, token expires + +## The Fix + +### Quick Fix (Immediate) + +Pass csrf_token from cookie to partials: + +```python +def _build_gitlab_repos_response( + request: Request, + repositories: Optional[list] = None, + # ... other params ... +): + """Build GitLab repos partial template response.""" + # Get existing csrf_token from cookie (or generate new one) + csrf_token = get_csrf_token_from_cookie(request) or generate_csrf_token() + + return templates.TemplateResponse( + "partials/gitlab_repos.html", + { + "request": request, + "repositories": repositories or [], + # ... other context ... + "csrf_token": csrf_token, # ✅ ADD THIS + }, + ) + +# Same fix for _build_github_repos_response +``` + +### Better Fix (Recommended) + +1. **Increase CSRF token lifetime** (reduce UX friction): + ```python + CSRF_MAX_AGE_SECONDS = 3600 # 1 hour (or match session timeout) + ``` + +2. **Refresh CSRF token on partial loads**: + ```python + # In gitlab_repos_partial() and github_repos_partial() + csrf_token = get_csrf_token_from_cookie(request) + if not csrf_token: + csrf_token = generate_csrf_token() + + response = _build_gitlab_repos_response( + request, + result.repositories, + # ... other params ... + csrf_token=csrf_token, # Pass to builder + ) + + # Refresh the cookie if it's close to expiration + set_csrf_cookie(response, csrf_token) + return response + ``` + +3. **Update function signature** to accept csrf_token: + ```python + def _build_gitlab_repos_response( + request: Request, + repositories: Optional[list] = None, + total_count: int = 0, + # ... other params ... + csrf_token: Optional[str] = None, # ADD THIS + ): + # Use provided token or generate new one + if not csrf_token: + csrf_token = get_csrf_token_from_cookie(request) or generate_csrf_token() + + return templates.TemplateResponse( + "partials/gitlab_repos.html", + { + "request": request, + "csrf_token": csrf_token, # ✅ INCLUDE IN CONTEXT + # ... rest of context ... + }, + ) + ``` + +## Testing + +### Reproduce the Bug + +1. Open auto-discovery page: http://localhost:8090/auto-discovery +2. Click "GitLab" or "GitHub" tab +3. Open browser DevTools → Network tab +4. Click "Add" on any repo +5. Check form data in network request: + ``` + csrf_token: (empty string) + ``` +6. See error: "Invalid CSRF token" + +### Verify the Fix + +1. Apply fix to pass csrf_token to partials +2. Reload auto-discovery page +3. Click "GitLab" or "GitHub" tab +4. Open browser DevTools → Inspect form +5. Check hidden input: + ```html + + ``` +6. Click "Add" → Should succeed + +### Check Token Expiration + +1. Open auto-discovery page +2. Wait 11 minutes (longer than CSRF_MAX_AGE_SECONDS) +3. Click "Add" → Should fail (token expired) +4. Refresh page → New token generated +5. Click "Add" → Should succeed + +## Files Affected + +### Primary Fix +- `src/code_indexer/server/web/routes.py` + - `_build_gitlab_repos_response()` (line 4367) + - `_build_github_repos_response()` (line 4395) + - `gitlab_repos_partial()` (line 4444) + - `github_repos_partial()` (line 4504) + +### Optional Improvement +- `src/code_indexer/server/web/routes.py` + - `CSRF_MAX_AGE_SECONDS` (line 80) - Increase to 3600 + +### Templates (No changes needed) +- `src/code_indexer/server/web/templates/partials/gitlab_repos.html` (line 124) +- `src/code_indexer/server/web/templates/partials/github_repos.html` (line 136) + +## Impact + +### Severity +**HIGH** - Primary workflow broken + +### Affected Users +- All users trying to add repos from auto-discovery +- Especially users who browse repos before selecting (token expires) + +### Workaround (for users) +1. Refresh the page before clicking "Add" +2. Don't spend >10 minutes browsing repos + +## Estimated Fix Time +- **Quick fix**: 15 minutes (just pass csrf_token to partials) +- **Better fix**: 30 minutes (add token refresh logic) +- **Testing**: 15 minutes + +## Priority +**P0** - User-facing bug in primary workflow, should fix immediately diff --git a/src/code_indexer/server/web/routes.py b/src/code_indexer/server/web/routes.py index 3b4e89c0..cc10aa1f 100644 --- a/src/code_indexer/server/web/routes.py +++ b/src/code_indexer/server/web/routes.py @@ -2530,7 +2530,9 @@ def _get_all_jobs( ] # Sort by started_at (most recently started first), fall back to created_at - all_jobs.sort(key=lambda x: x.get("started_at") or x.get("created_at") or "", reverse=True) + all_jobs.sort( + key=lambda x: x.get("started_at") or x.get("created_at") or "", reverse=True + ) # Pagination total_count = len(all_jobs) @@ -4376,10 +4378,14 @@ def _build_gitlab_repos_response( search_term: Optional[str] = None, ): """Build GitLab repos partial template response.""" + # Get existing CSRF token from cookie or generate new one + csrf_token = get_csrf_token_from_cookie(request) or generate_csrf_token() + return templates.TemplateResponse( "partials/gitlab_repos.html", { "request": request, + "csrf_token": csrf_token, "repositories": repositories or [], "total_count": total_count, "page": page, @@ -4404,10 +4410,14 @@ def _build_github_repos_response( search_term: Optional[str] = None, ): """Build GitHub repos partial template response.""" + # Get existing CSRF token from cookie or generate new one + csrf_token = get_csrf_token_from_cookie(request) or generate_csrf_token() + return templates.TemplateResponse( "partials/github_repos.html", { "request": request, + "csrf_token": csrf_token, "repositories": repositories or [], "total_count": total_count, "page": page, From e7d64b51fd9b8ae37d84ef2f7adbc9419a0f183a Mon Sep 17 00:00:00 2001 From: Josh Bendson Date: Wed, 14 Jan 2026 23:27:26 -0600 Subject: [PATCH 2/4] chore: Remove CSRF token bug analysis doc --- CSRF_TOKEN_BUG_ANALYSIS.md | 234 ------------------------------------- 1 file changed, 234 deletions(-) delete mode 100644 CSRF_TOKEN_BUG_ANALYSIS.md diff --git a/CSRF_TOKEN_BUG_ANALYSIS.md b/CSRF_TOKEN_BUG_ANALYSIS.md deleted file mode 100644 index 50b468bd..00000000 --- a/CSRF_TOKEN_BUG_ANALYSIS.md +++ /dev/null @@ -1,234 +0,0 @@ -# CSRF Token Bug Analysis - Auto-Discovery Page - -## Problem - -Users get "invalid CSRF token" error when clicking "Add" button on auto-discovery repo listing. - -## Root Cause - -**Bug Location**: `src/code_indexer/server/web/routes.py` - -The HTMX partials for GitLab and GitHub repository listings **do not pass `csrf_token` to their templates**: - -```python -# Lines 4367-4392 -def _build_gitlab_repos_response( - request: Request, - repositories: Optional[list] = None, - # ... other params ... -): - """Build GitLab repos partial template response.""" - return templates.TemplateResponse( - "partials/gitlab_repos.html", - { - "request": request, - "repositories": repositories or [], - # ... other context ... - # ❌ MISSING: "csrf_token": csrf_token - }, - ) - -# Lines 4395-4420 - Same issue for GitHub -def _build_github_repos_response(...): - # ❌ MISSING: "csrf_token": csrf_token -``` - -But the templates **require** csrf_token: - -```html - - - - - -``` - -## The Flow (Broken) - -``` -1. User loads /auto-discovery - └─> auto_discovery_page() generates csrf_token - └─> Template includes csrf_token ✅ - -2. User clicks "GitLab" or "GitHub" tab - └─> HTMX loads /partials/auto-discovery/gitlab - └─> gitlab_repos_partial() called - └─> _build_gitlab_repos_response() renders template - └─> Template context DOES NOT include csrf_token ❌ - └─> Forms render with: (EMPTY!) - -3. User clicks "Add" button on a repo - └─> Form submits with empty csrf_token - └─> add_golden_repo() validates CSRF - └─> validate_login_csrf_token() returns False - └─> Error: "Invalid CSRF token" ❌ -``` - -## Additional Issues - -### CSRF Token Expiration (10 Minutes) - -```python -# Line 80 -CSRF_MAX_AGE_SECONDS = 600 # 10 minutes -``` - -If user browses repos for >10 minutes before clicking "Add", token expires. - -### HTMX Partial Requests Don't Refresh Token - -When HTMX loads partials: -- Main page has csrf_token (10 min lifetime) -- Partials loaded later don't refresh the token -- User might spend 5 minutes browsing → token has 5 min left -- After more browsing, token expires - -## The Fix - -### Quick Fix (Immediate) - -Pass csrf_token from cookie to partials: - -```python -def _build_gitlab_repos_response( - request: Request, - repositories: Optional[list] = None, - # ... other params ... -): - """Build GitLab repos partial template response.""" - # Get existing csrf_token from cookie (or generate new one) - csrf_token = get_csrf_token_from_cookie(request) or generate_csrf_token() - - return templates.TemplateResponse( - "partials/gitlab_repos.html", - { - "request": request, - "repositories": repositories or [], - # ... other context ... - "csrf_token": csrf_token, # ✅ ADD THIS - }, - ) - -# Same fix for _build_github_repos_response -``` - -### Better Fix (Recommended) - -1. **Increase CSRF token lifetime** (reduce UX friction): - ```python - CSRF_MAX_AGE_SECONDS = 3600 # 1 hour (or match session timeout) - ``` - -2. **Refresh CSRF token on partial loads**: - ```python - # In gitlab_repos_partial() and github_repos_partial() - csrf_token = get_csrf_token_from_cookie(request) - if not csrf_token: - csrf_token = generate_csrf_token() - - response = _build_gitlab_repos_response( - request, - result.repositories, - # ... other params ... - csrf_token=csrf_token, # Pass to builder - ) - - # Refresh the cookie if it's close to expiration - set_csrf_cookie(response, csrf_token) - return response - ``` - -3. **Update function signature** to accept csrf_token: - ```python - def _build_gitlab_repos_response( - request: Request, - repositories: Optional[list] = None, - total_count: int = 0, - # ... other params ... - csrf_token: Optional[str] = None, # ADD THIS - ): - # Use provided token or generate new one - if not csrf_token: - csrf_token = get_csrf_token_from_cookie(request) or generate_csrf_token() - - return templates.TemplateResponse( - "partials/gitlab_repos.html", - { - "request": request, - "csrf_token": csrf_token, # ✅ INCLUDE IN CONTEXT - # ... rest of context ... - }, - ) - ``` - -## Testing - -### Reproduce the Bug - -1. Open auto-discovery page: http://localhost:8090/auto-discovery -2. Click "GitLab" or "GitHub" tab -3. Open browser DevTools → Network tab -4. Click "Add" on any repo -5. Check form data in network request: - ``` - csrf_token: (empty string) - ``` -6. See error: "Invalid CSRF token" - -### Verify the Fix - -1. Apply fix to pass csrf_token to partials -2. Reload auto-discovery page -3. Click "GitLab" or "GitHub" tab -4. Open browser DevTools → Inspect form -5. Check hidden input: - ```html - - ``` -6. Click "Add" → Should succeed - -### Check Token Expiration - -1. Open auto-discovery page -2. Wait 11 minutes (longer than CSRF_MAX_AGE_SECONDS) -3. Click "Add" → Should fail (token expired) -4. Refresh page → New token generated -5. Click "Add" → Should succeed - -## Files Affected - -### Primary Fix -- `src/code_indexer/server/web/routes.py` - - `_build_gitlab_repos_response()` (line 4367) - - `_build_github_repos_response()` (line 4395) - - `gitlab_repos_partial()` (line 4444) - - `github_repos_partial()` (line 4504) - -### Optional Improvement -- `src/code_indexer/server/web/routes.py` - - `CSRF_MAX_AGE_SECONDS` (line 80) - Increase to 3600 - -### Templates (No changes needed) -- `src/code_indexer/server/web/templates/partials/gitlab_repos.html` (line 124) -- `src/code_indexer/server/web/templates/partials/github_repos.html` (line 136) - -## Impact - -### Severity -**HIGH** - Primary workflow broken - -### Affected Users -- All users trying to add repos from auto-discovery -- Especially users who browse repos before selecting (token expires) - -### Workaround (for users) -1. Refresh the page before clicking "Add" -2. Don't spend >10 minutes browsing repos - -## Estimated Fix Time -- **Quick fix**: 15 minutes (just pass csrf_token to partials) -- **Better fix**: 30 minutes (add token refresh logic) -- **Testing**: 15 minutes - -## Priority -**P0** - User-facing bug in primary workflow, should fix immediately From 2fd98c61bed7b67bbc03d496dd1b0ac9627f0444 Mon Sep 17 00:00:00 2001 From: Josh Bendson Date: Thu, 15 Jan 2026 10:08:18 -0600 Subject: [PATCH 3/4] fix: Use existing CSRF token from cookie in detail endpoints Fixes CSRF validation errors for golden repo delete/refresh and activated repo deactivate actions. Changes: - golden_repo_details(): Retrieve csrf_token from cookie instead of generating new one - repo_details(): Same fix for activated repos Previously these endpoints generated NEW tokens that didn't match the cookie, causing CSRF validation to fail when forms were submitted. --- src/code_indexer/server/web/routes.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/code_indexer/server/web/routes.py b/src/code_indexer/server/web/routes.py index cc10aa1f..fb947fdf 100644 --- a/src/code_indexer/server/web/routes.py +++ b/src/code_indexer/server/web/routes.py @@ -2010,11 +2010,14 @@ async def golden_repo_details( ) # Return repository details as JSON-like HTML response + # Get existing CSRF token from cookie or generate new one + csrf_token = get_csrf_token_from_cookie(request) or generate_csrf_token() + return templates.TemplateResponse( "partials/golden_repos_list.html", { "request": request, - "csrf_token": generate_csrf_token(), + "csrf_token": csrf_token, "repos": [repo.to_dict()], }, ) @@ -2376,11 +2379,14 @@ async def repo_details( repo["username"] = username # Return repository details as HTML partial + # Get existing CSRF token from cookie or generate new one + csrf_token = get_csrf_token_from_cookie(request) or generate_csrf_token() + return templates.TemplateResponse( "partials/repos_list.html", { "request": request, - "csrf_token": generate_csrf_token(), + "csrf_token": csrf_token, "repos": [repo], }, ) From 202f91292919f7f560e3e878dc962f6e61fe87e4 Mon Sep 17 00:00:00 2001 From: Josh Bendson Date: Thu, 15 Jan 2026 10:22:53 -0600 Subject: [PATCH 4/4] fix: Set CSRF cookie in partial response builders The previous fix retrieved/generated CSRF tokens but didn't set the cookie, causing validation failures when new tokens were generated. Root cause: Partials that generate new CSRF tokens must also set the cookie so the browser sends the matching token on form submission. Fixed in 4 functions: - _build_gitlab_repos_response(): Added set_csrf_cookie() - _build_github_repos_response(): Added set_csrf_cookie() - golden_repo_details(): Added set_csrf_cookie() - repo_details(): Added set_csrf_cookie() Tested: Auto-discovery Add, golden repo Delete/Refresh, and activated repo Deactivate all now work without CSRF validation errors. --- src/code_indexer/server/web/routes.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/code_indexer/server/web/routes.py b/src/code_indexer/server/web/routes.py index fb947fdf..0900dd7b 100644 --- a/src/code_indexer/server/web/routes.py +++ b/src/code_indexer/server/web/routes.py @@ -2013,7 +2013,7 @@ async def golden_repo_details( # Get existing CSRF token from cookie or generate new one csrf_token = get_csrf_token_from_cookie(request) or generate_csrf_token() - return templates.TemplateResponse( + response = templates.TemplateResponse( "partials/golden_repos_list.html", { "request": request, @@ -2021,6 +2021,10 @@ async def golden_repo_details( "repos": [repo.to_dict()], }, ) + + # Set CSRF cookie to ensure token is available for form submission + set_csrf_cookie(response, csrf_token) + return response except HTTPException: raise except Exception as e: @@ -2382,7 +2386,7 @@ async def repo_details( # Get existing CSRF token from cookie or generate new one csrf_token = get_csrf_token_from_cookie(request) or generate_csrf_token() - return templates.TemplateResponse( + response = templates.TemplateResponse( "partials/repos_list.html", { "request": request, @@ -2390,6 +2394,10 @@ async def repo_details( "repos": [repo], }, ) + + # Set CSRF cookie to ensure token is available for form submission + set_csrf_cookie(response, csrf_token) + return response except HTTPException: raise except Exception: @@ -4387,7 +4395,7 @@ def _build_gitlab_repos_response( # Get existing CSRF token from cookie or generate new one csrf_token = get_csrf_token_from_cookie(request) or generate_csrf_token() - return templates.TemplateResponse( + response = templates.TemplateResponse( "partials/gitlab_repos.html", { "request": request, @@ -4403,6 +4411,10 @@ def _build_gitlab_repos_response( }, ) + # Set CSRF cookie to ensure token is available for form submission + set_csrf_cookie(response, csrf_token) + return response + def _build_github_repos_response( request: Request, @@ -4419,7 +4431,7 @@ def _build_github_repos_response( # Get existing CSRF token from cookie or generate new one csrf_token = get_csrf_token_from_cookie(request) or generate_csrf_token() - return templates.TemplateResponse( + response = templates.TemplateResponse( "partials/github_repos.html", { "request": request, @@ -4435,6 +4447,10 @@ def _build_github_repos_response( }, ) + # Set CSRF cookie to ensure token is available for form submission + set_csrf_cookie(response, csrf_token) + return response + @web_router.get("/auto-discovery", response_class=HTMLResponse) async def auto_discovery_page(request: Request):