Skip to content

Commit 137d609

Browse files
authored
Merge pull request #27 from naspirato/fix_redirects
Fix redirects
2 parents 9dc578b + 340026c commit 137d609

7 files changed

Lines changed: 470 additions & 88 deletions

File tree

backend/routes/auth.py

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44
import secrets
55
import logging
6+
from urllib.parse import urlparse
67
from fastapi import APIRouter, Request, HTTPException
78
from fastapi.responses import RedirectResponse
89

@@ -12,23 +13,80 @@
1213
router = APIRouter()
1314

1415

16+
def validate_redirect_url(url: str, request: Request) -> str:
17+
"""
18+
Validate and normalize redirect URL for security.
19+
Only allows internal paths (starting with /) to prevent open redirect attacks.
20+
21+
Args:
22+
url: URL to validate
23+
request: Request object to get base URL
24+
25+
Returns:
26+
Normalized internal path (relative URL)
27+
28+
Raises:
29+
HTTPException: If URL is invalid or external
30+
"""
31+
if not url:
32+
return "/"
33+
34+
# Parse URL
35+
parsed = urlparse(url)
36+
37+
# If URL has scheme (http/https), check if it's internal
38+
if parsed.scheme:
39+
# Get base URL from request
40+
base_url = str(request.base_url).rstrip('/')
41+
request_host = request.url.hostname
42+
43+
# Allow only same host
44+
if parsed.netloc and parsed.netloc != request_host:
45+
logger.warning(f"External redirect URL blocked: {url}")
46+
return "/"
47+
48+
# Extract path and query
49+
path = parsed.path or "/"
50+
query = parsed.query
51+
if query:
52+
path = f"{path}?{query}"
53+
return path
54+
55+
# If no scheme, treat as relative path
56+
# Ensure it starts with /
57+
if not url.startswith('/'):
58+
url = '/' + url
59+
60+
return url
61+
62+
1563
@router.get("/github")
1664
async def github_login(request: Request, redirect_after: str = None):
17-
"""Initiate GitHub OAuth login"""
65+
"""
66+
Initiate GitHub OAuth login
67+
68+
Saves redirect URL for post-authentication redirect.
69+
Only saves if explicitly provided via parameter or query string.
70+
"""
1871
# Generate state for CSRF protection
1972
state = secrets.token_urlsafe(32)
2073
request.session["oauth_state"] = state
2174

22-
# Save redirect URL if provided
23-
if redirect_after:
24-
request.session["oauth_redirect_after"] = redirect_after
25-
elif "oauth_redirect_after" not in request.session:
26-
# Get redirect_after from query parameter if not in session
75+
# Get redirect_after from parameter or query string
76+
if not redirect_after:
2777
redirect_after = request.query_params.get("redirect_after")
28-
if redirect_after:
29-
request.session["oauth_redirect_after"] = redirect_after
3078

31-
logger.info(f"OAuth login initiated, state generated: {state[:10]}..., redirect_after: {request.session.get('oauth_redirect_after', 'not set')}")
79+
# Validate and save redirect URL if provided
80+
if redirect_after:
81+
try:
82+
validated_url = validate_redirect_url(redirect_after, request)
83+
request.session["oauth_redirect_after"] = validated_url
84+
logger.info(f"OAuth login initiated, state: {state[:10]}..., redirect_after: {validated_url}")
85+
except Exception as e:
86+
logger.warning(f"Invalid redirect URL provided: {redirect_after}, error: {e}")
87+
# Continue without redirect_after, will redirect to / after auth
88+
else:
89+
logger.info(f"OAuth login initiated, state: {state[:10]}..., no redirect_after (will redirect to /)")
3290

3391
# Redirect to GitHub OAuth
3492
oauth_url = get_oauth_url(state=state)
@@ -85,8 +143,15 @@ async def github_callback(request: Request, code: str = None, state: str = None)
85143
request.session.pop("oauth_state", None)
86144
logger.info(f"Session updated for user: {user_info['login']}")
87145

88-
# Redirect to saved URL or main page
146+
# Get and validate redirect URL
89147
redirect_url = request.session.pop("oauth_redirect_after", "/")
148+
try:
149+
# Validate redirect URL for security (prevent open redirect attacks)
150+
redirect_url = validate_redirect_url(redirect_url, request)
151+
except Exception as e:
152+
logger.warning(f"Invalid redirect URL in session: {redirect_url}, error: {e}, redirecting to /")
153+
redirect_url = "/"
154+
90155
logger.info(f"Redirecting after OAuth to: {redirect_url}")
91156
return RedirectResponse(url=redirect_url, status_code=303)
92157
except HTTPException:

backend/routes/workflow.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,13 @@ async def _trigger_and_show_result(
4141
access_token = request.session.get("access_token")
4242

4343
if not user or not access_token:
44-
# Save current URL for redirect after OAuth
45-
current_url = str(request.url)
46-
request.session["oauth_redirect_after"] = current_url
47-
logger.info(f"No session found, saving redirect URL: {current_url}")
44+
# Save current URL (relative path with query) for redirect after OAuth
45+
# Use relative path for security (prevents open redirect attacks)
46+
redirect_path = request.url.path
47+
if request.url.query:
48+
redirect_path = f"{redirect_path}?{request.url.query}"
49+
request.session["oauth_redirect_after"] = redirect_path
50+
logger.info(f"No session found, saving redirect path: {redirect_path}")
4851

4952
# Redirect to login
5053
oauth_url = get_oauth_url()

frontend/static/style.css

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,14 +266,26 @@ header h1 {
266266
width: 100%;
267267
}
268268

269-
.btn-primary:hover:not(:disabled) {
269+
/* Green color when button is active (can run workflow) */
270+
.btn-primary.btn-active {
271+
background: #10b981;
272+
color: white;
273+
border-color: #059669;
274+
}
275+
276+
.btn-primary.btn-active:hover:not(:disabled) {
277+
background: #059669;
278+
border-color: #047857;
279+
}
280+
281+
.btn-primary:not(.btn-active):hover:not(:disabled) {
270282
background: #f3f4f6;
271283
}
272284

273285
.btn-primary:active:not(:disabled) {
274-
background: #10b981; /* Зеленый цвет при нажатии */
286+
background: #047857;
275287
color: white;
276-
border-color: #059669;
288+
border-color: #065f46;
277289
}
278290

279291
.btn-primary:disabled {

frontend/templates/index.html

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
<div class="auth-modal">
1515
<h2>Authentication Required</h2>
1616
<p>Please sign in with GitHub to run workflows</p>
17-
<a href="/auth/github" class="btn btn-primary">Sign in with GitHub</a>
17+
<a href="/auth/github" id="auth-link" class="btn btn-primary">Sign in with GitHub</a>
1818
</div>
1919
</div>
2020
{% endif %}
@@ -135,7 +135,7 @@ <h1>Run GitHub Action</h1>
135135
{% endif %}
136136
<span class="user-login">{{ user.login }}</span>
137137
</div>
138-
<a href="/auth/logout" class="btn btn-secondary btn-small">Выйти</a>
138+
<a href="/auth/logout" class="btn btn-secondary btn-small">Logout</a>
139139
</div>
140140
</footer>
141141
</main>
@@ -434,6 +434,7 @@ <h1>Run GitHub Action</h1>
434434
// Disable button and show message if workflow doesn't support manual trigger
435435
if (runButton) {
436436
runButton.disabled = true;
437+
runButton.classList.remove('btn-active');
437438
runButton.style.opacity = '0.5';
438439
runButton.style.cursor = 'not-allowed';
439440
}
@@ -934,6 +935,7 @@ <h1>Run GitHub Action</h1>
934935
// If owner or repo are empty, disable button and show message
935936
if (!owner || !repo) {
936937
runButton.disabled = true;
938+
runButton.classList.remove('btn-active');
937939
runButton.style.opacity = '0.5';
938940
runButton.style.cursor = 'not-allowed';
939941
permissionMessage.style.display = 'block';
@@ -947,6 +949,7 @@ <h1>Run GitHub Action</h1>
947949
if (response.status === 401) {
948950
// Not authenticated
949951
runButton.disabled = true;
952+
runButton.classList.remove('btn-active');
950953
runButton.style.opacity = '0.5';
951954
runButton.style.cursor = 'not-allowed';
952955
permissionMessage.style.display = 'block';
@@ -965,6 +968,7 @@ <h1>Run GitHub Action</h1>
965968

966969
if (data.can_trigger && hasWorkflowDispatch) {
967970
runButton.disabled = false;
971+
runButton.classList.add('btn-active');
968972
runButton.style.opacity = '1';
969973
runButton.style.cursor = 'pointer';
970974
permissionMessage.textContent = '';
@@ -974,6 +978,7 @@ <h1>Run GitHub Action</h1>
974978
}
975979
} else {
976980
runButton.disabled = true;
981+
runButton.classList.remove('btn-active');
977982
runButton.style.opacity = '0.5';
978983
runButton.style.cursor = 'not-allowed';
979984
permissionMessage.style.display = 'block';
@@ -991,24 +996,33 @@ <h1>Run GitHub Action</h1>
991996
console.error('Error checking permissions:', error);
992997
// In case of error, allow attempt (check will be on server)
993998
runButton.disabled = false;
999+
runButton.classList.add('btn-active');
9941000
runButton.style.opacity = '1';
9951001
runButton.style.cursor = 'pointer';
9961002
permissionMessage.textContent = '';
9971003
permissionMessage.style.display = 'none';
9981004
}
9991005
}
10001006

1001-
// Инициализация при загрузке
1007+
// Initialize on page load
10021008
function initAll() {
1003-
// Инициализируем скрытое поле return_url из URL параметров, если его нет
1009+
// Update the authorization link to pass the current URL with parameters
1010+
const authLink = document.getElementById('auth-link');
1011+
if (authLink && window.location.search) {
1012+
const currentUrl = window.location.href;
1013+
const redirectAfter = encodeURIComponent(currentUrl);
1014+
authLink.href = `/auth/github?redirect_after=${redirectAfter}`;
1015+
}
1016+
1017+
// Initialize the hidden return_url field from URL parameters if not present
10041018
const form = document.getElementById('workflowForm');
10051019
if (form) {
10061020
const urlParams = new URLSearchParams(window.location.search);
10071021
const returnUrlFromUrl = urlParams.get('return_url');
10081022
if (returnUrlFromUrl) {
10091023
let returnUrlInput = form.querySelector('input[name="return_url"]');
10101024
if (!returnUrlInput) {
1011-
// Создаем скрытое поле, если его нет
1025+
// Create hidden field if it doesn't exist
10121026
returnUrlInput = document.createElement('input');
10131027
returnUrlInput.type = 'hidden';
10141028
returnUrlInput.name = 'return_url';

frontend/templates/result.html

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ <h2>Success</h2>
7676

7777
<div id="run-loader" class="run-loader" style="display: {% if run_url %}none{% else %}flex{% endif %};">
7878
<div class="loader"></div>
79-
<p class="loading-message">Ищем запуск workflow...</p>
79+
<p class="loading-message">Searching for workflow run...</p>
8080
</div>
8181

8282
<div class="action-links" id="action-links" style="display: {% if run_url %}flex{% else %}none{% endif %};">
@@ -175,15 +175,15 @@ <h2>Error</h2>
175175
})
176176
.then(data => {
177177
if (data.found && data.run_url) {
178-
// Нашли run!
178+
// Found run!
179179
const loader = document.getElementById('run-loader');
180180
const actionLinks = document.getElementById('action-links');
181181

182182
if (loader) loader.style.display = 'none';
183183
if (actionLinks) {
184184
actionLinks.style.display = 'flex';
185185

186-
// Создаем или обновляем ссылку
186+
// Create or update the link
187187
let runLink = document.getElementById('run-link');
188188
if (!runLink) {
189189
runLink = document.createElement('a');
@@ -199,22 +199,22 @@ <h2>Error</h2>
199199
}
200200
}
201201

202-
// Убеждаемся, что второй ряд кнопок виден
202+
// Ensure the second row of buttons is visible
203203
const secondRow = actionLinks ? actionLinks.nextElementSibling : null;
204204
if (secondRow && secondRow.classList.contains('action-links')) {
205205
secondRow.style.display = 'flex';
206206
}
207207
} else if (attempts < maxAttempts) {
208-
// Продолжаем опрос
208+
// Continue polling
209209
setTimeout(findRun, pollInterval);
210210
} else {
211-
// Превысили лимит попыток
211+
// Exceeded attempt limit
212212
const loader = document.getElementById('run-loader');
213213
if (loader) {
214-
loader.innerHTML = '<p class="loading-message">Не удалось найти запуск. <a href="' + workflowUrl + '" target="_blank" style="color: #000; text-decoration: underline;">Открыть список workflow</a></p>';
214+
loader.innerHTML = '<p class="loading-message">Failed to find run. <a href="' + workflowUrl + '" target="_blank" style="color: #000; text-decoration: underline;">Open workflow list</a></p>';
215215
}
216216

217-
// Показываем кнопки даже если не нашли run
217+
// Show buttons even if run was not found
218218
const actionLinks = document.getElementById('action-links');
219219
if (actionLinks) {
220220
actionLinks.style.display = 'flex';
@@ -228,13 +228,13 @@ <h2>Error</h2>
228228
} else {
229229
const loader = document.getElementById('run-loader');
230230
if (loader) {
231-
loader.innerHTML = '<p class="loading-message" style="color: #999;">Ошибка при поиске запуска. <a href="' + workflowUrl + '" target="_blank" style="color: #000; text-decoration: underline;">Открыть список workflow</a></p>';
231+
loader.innerHTML = '<p class="loading-message" style="color: #999;">Error searching for run. <a href="' + workflowUrl + '" target="_blank" style="color: #000; text-decoration: underline;">Open workflow list</a></p>';
232232
}
233233
}
234234
});
235235
}
236236

237-
// Начинаем поиск через небольшую задержку
237+
// Start search after a short delay
238238
setTimeout(findRun, 500);
239239
})();
240240
</script>

0 commit comments

Comments
 (0)