Skip to content

Commit 7421d11

Browse files
committed
csrf tokens; retries with exponential backoff for specific requests
1 parent 039854d commit 7421d11

File tree

9 files changed

+290
-36
lines changed

9 files changed

+290
-36
lines changed

backend/app/api/routes/auth.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,11 @@ async def login(
9191
path="/",
9292
)
9393

94-
return {"message": "Login successful", "username": user.username}
94+
# Generate CSRF token for the session
95+
session_id = security_service.get_session_id_from_request(request)
96+
csrf_token = security_service.generate_csrf_token(session_id)
97+
98+
return {"message": "Login successful", "username": user.username, "csrf_token": csrf_token}
9599

96100

97101
@router.post("/register", response_model=UserResponse)
@@ -176,7 +180,11 @@ async def verify_token(
176180
"user_agent": request.headers.get("user-agent"),
177181
},
178182
)
179-
return {"valid": True, "username": current_user.username}
183+
# Generate fresh CSRF token for authenticated session
184+
session_id = security_service.get_session_id_from_request(request)
185+
csrf_token = security_service.generate_csrf_token(session_id)
186+
187+
return {"valid": True, "username": current_user.username, "csrf_token": csrf_token}
180188

181189
except Exception as e:
182190
logger.error(

backend/app/api/routes/execution.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from app.core.exceptions import IntegrationException
22
from app.core.logging import logger
33
from app.core.metrics import ACTIVE_EXECUTIONS, EXECUTION_DURATION, SCRIPT_EXECUTIONS
4+
from app.core.security import validate_csrf_token
45
from app.schemas.execution import (
56
ExampleScripts,
67
ExecutionRequest,

backend/app/api/routes/saved_scripts.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import List
22

33
from app.core.logging import logger
4-
from app.core.security import security_service
4+
from app.core.security import security_service, validate_csrf_token
55
from app.schemas.saved_script import SavedScriptCreateRequest, SavedScriptResponse, SavedScriptUpdate
66
from app.schemas.user import UserInDB
77
from app.services.saved_script_service import (
@@ -49,6 +49,7 @@ async def create_saved_script(
4949
saved_script: SavedScriptCreateRequest,
5050
current_user: UserInDB = Depends(security_service.get_current_user),
5151
saved_script_service: SavedScriptService = Depends(get_saved_script_service),
52+
csrf_token: str = Depends(validate_csrf_token),
5253
) -> SavedScriptResponse:
5354
logger.info(
5455
"Creating new saved script",
@@ -158,6 +159,7 @@ async def update_saved_script(
158159
script_in_db: SavedScriptResponse = Depends(get_script_or_404),
159160
current_user: UserInDB = Depends(get_validated_user),
160161
saved_script_service: SavedScriptService = Depends(get_saved_script_service),
162+
csrf_token: str = Depends(validate_csrf_token),
161163
) -> SavedScriptResponse:
162164
logger.info(
163165
"Updating saved script",
@@ -210,6 +212,7 @@ async def delete_saved_script(
210212
script_in_db: SavedScriptResponse = Depends(get_script_or_404),
211213
current_user: UserInDB = Depends(get_validated_user),
212214
saved_script_service: SavedScriptService = Depends(get_saved_script_service),
215+
csrf_token: str = Depends(validate_csrf_token),
213216
) -> None:
214217
logger.info(
215218
"Deleting saved script",

backend/app/core/security.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from fastapi import Depends, HTTPException, Request, status
99
from fastapi.security import OAuth2PasswordBearer
1010
from passlib.context import CryptContext
11+
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
1112

1213
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/login")
1314

@@ -27,6 +28,10 @@ class SecurityService:
2728
def __init__(self) -> None:
2829
self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
2930
self.settings = get_settings()
31+
self.csrf_serializer = URLSafeTimedSerializer(
32+
secret_key=self.settings.SECRET_KEY,
33+
salt="csrf-token"
34+
)
3035

3136
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
3237
return self.pwd_context.verify(plain_password, hashed_password) # type: ignore
@@ -72,5 +77,71 @@ async def get_current_user(
7277
raise credentials_exception
7378
return user
7479

80+
def generate_csrf_token(self, session_id: str) -> str:
81+
"""Generate a CSRF token for the given session"""
82+
data = {
83+
"session_id": session_id,
84+
"timestamp": datetime.utcnow().isoformat()
85+
}
86+
return self.csrf_serializer.dumps(data)
87+
88+
def validate_csrf_token(self, token: str, session_id: str) -> bool:
89+
"""Validate a CSRF token"""
90+
try:
91+
data = self.csrf_serializer.loads(token, max_age=3600) # 1 hour
92+
return data.get("session_id") == session_id
93+
except (BadSignature, SignatureExpired):
94+
return False
95+
96+
def get_session_id_from_request(self, request: Request) -> str:
97+
"""Get session ID from request (using access token as session identifier)"""
98+
token = request.cookies.get("access_token")
99+
if token:
100+
return token[:32] # Use first 32 chars as session ID
101+
102+
# Fallback to client fingerprint
103+
client_ip = request.client.host if request.client else "unknown"
104+
user_agent = request.headers.get("user-agent", "unknown")
105+
return f"{client_ip}:{user_agent}"[:32]
106+
75107

76108
security_service = SecurityService()
109+
110+
111+
def validate_csrf_token(request: Request) -> str:
112+
"""FastAPI dependency to validate CSRF token"""
113+
# Skip CSRF validation for safe methods
114+
if request.method in ["GET", "HEAD", "OPTIONS"]:
115+
return "skip"
116+
117+
# Skip CSRF validation for auth endpoints
118+
if request.url.path in ["/api/v1/login", "/api/v1/register", "/api/v1/logout"]:
119+
return "skip"
120+
121+
# Skip CSRF validation for non-API endpoints
122+
if not request.url.path.startswith("/api/"):
123+
return "skip"
124+
125+
# Check if user is authenticated first (has access_token cookie)
126+
access_token = request.cookies.get("access_token")
127+
if not access_token:
128+
# If not authenticated, skip CSRF validation (auth will be handled by other dependencies)
129+
return "skip"
130+
131+
# Get CSRF token from request
132+
csrf_token = request.headers.get("X-CSRF-Token")
133+
if not csrf_token:
134+
raise HTTPException(
135+
status_code=status.HTTP_403_FORBIDDEN,
136+
detail="CSRF token missing"
137+
)
138+
139+
# Validate CSRF token
140+
session_id = security_service.get_session_id_from_request(request)
141+
if not security_service.validate_csrf_token(csrf_token, session_id):
142+
raise HTTPException(
143+
status_code=status.HTTP_403_FORBIDDEN,
144+
detail="CSRF token invalid"
145+
)
146+
147+
return csrf_token

backend/app/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ def create_app() -> FastAPI:
112112
"Accept",
113113
"Origin",
114114
"X-Requested-With",
115+
"X-CSRF-Token",
115116
],
116117
expose_headers=["Content-Length", "Content-Range"],
117118
)

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"autoprefixer": "^10.4.20",
3030
"axios": "^1.7.7",
3131
"codemirror": "^6.0.1",
32+
"exponential-backoff": "^3.1.2",
3233
"dotenv": "^16.4.5",
3334
"postcss": "^8.4.47",
3435
"rollup": "^3.15.0",

frontend/src/lib/api.js

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { backOff } from 'exponential-backoff';
2+
import { get } from 'svelte/store';
3+
import { csrfToken } from '../stores/auth.js';
4+
5+
/**
6+
* Check if an error should trigger a retry
7+
*/
8+
function shouldRetry(error) {
9+
// Network errors
10+
if (error.name === 'TypeError' && error.message.includes('fetch')) {
11+
return true;
12+
}
13+
14+
// If it's a Response object, check status codes
15+
if (error instanceof Response) {
16+
const status = error.status;
17+
return status >= 500 || status === 408 || status === 429;
18+
}
19+
20+
return false;
21+
}
22+
23+
/**
24+
* Fetch with retry logic using exponential-backoff
25+
* @param {string} url - The URL to fetch
26+
* @param {Object} options - Fetch options
27+
* @param {Object} retryOptions - Retry configuration
28+
* @returns {Promise<Response>} - The fetch response
29+
*/
30+
export async function fetchWithRetry(url, options = {}, retryOptions = {}) {
31+
const {
32+
numOfAttempts = 3,
33+
maxDelay = 10000,
34+
jitter = 'none',
35+
...otherRetryOptions
36+
} = retryOptions;
37+
38+
return backOff(
39+
async () => {
40+
const response = await fetch(url, {
41+
credentials: 'include',
42+
...options
43+
});
44+
45+
// For retryable errors, throw to trigger retry logic
46+
if (!response.ok && shouldRetry(response)) {
47+
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
48+
error.response = response;
49+
throw error;
50+
}
51+
52+
return response;
53+
},
54+
{
55+
numOfAttempts,
56+
maxDelay,
57+
jitter,
58+
retry: (error) => shouldRetry(error) || shouldRetry(error.response),
59+
...otherRetryOptions
60+
}
61+
);
62+
}
63+
64+
/**
65+
* Make an authenticated request with CSRF token and retry logic
66+
* @param {string} url - The URL to request
67+
* @param {Object} options - Request options
68+
* @param {Object} retryOptions - Retry configuration
69+
* @returns {Promise<Response>} - The response
70+
*/
71+
export async function makeAuthenticatedRequest(url, options = {}, retryOptions = {}) {
72+
return fetchWithRetry(url, {
73+
...options,
74+
headers: {
75+
...options.headers,
76+
...addCSRFTokenToHeaders(options.method)
77+
}
78+
}, retryOptions);
79+
}
80+
81+
/**
82+
* Add CSRF token to headers if needed
83+
* @param {string} method - HTTP method
84+
* @returns {Object} - Headers object with CSRF token if applicable
85+
*/
86+
function addCSRFTokenToHeaders(method) {
87+
const headers = {};
88+
89+
// Add CSRF token for state-changing requests
90+
if (method && !['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())) {
91+
const token = get(csrfToken);
92+
if (token) {
93+
headers['X-CSRF-Token'] = token;
94+
}
95+
}
96+
97+
return headers;
98+
}
99+
100+
/**
101+
* Generic API call helper with retry logic
102+
* @param {string} url - The URL to request
103+
* @param {Object} options - Request options
104+
* @param {Object} retryOptions - Retry configuration
105+
* @returns {Promise<any>} - Parsed JSON response
106+
*/
107+
export async function apiCall(url, options = {}, retryOptions = {}) {
108+
const response = await makeAuthenticatedRequest(url, options, retryOptions);
109+
110+
if (!response.ok) {
111+
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
112+
error.response = response;
113+
throw error;
114+
}
115+
116+
// Only parse JSON if response has content
117+
const contentType = response.headers.get('content-type');
118+
if (contentType && contentType.includes('application/json')) {
119+
return response.json();
120+
}
121+
122+
return response;
123+
}

0 commit comments

Comments
 (0)