Skip to content

Commit 8563ef8

Browse files
Merge pull request #559 from joshjacobson/fix/xss-oauth-responses
fix: escape HTML in OAuth callback responses to prevent XSS
2 parents 25093bf + 0c62cc5 commit 8563ef8

3 files changed

Lines changed: 64 additions & 3 deletions

File tree

auth/oauth_responses.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
to eliminate duplication between server.py and oauth_callback_server.py.
66
"""
77

8+
from html import escape as html_escape
89
from fastapi.responses import HTMLResponse
910
from typing import Optional
1011

@@ -25,7 +26,7 @@ def create_error_response(error_message: str, status_code: int = 400) -> HTMLRes
2526
<head><title>Authentication Error</title></head>
2627
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; text-align: center;">
2728
<h2 style="color: #d32f2f;">Authentication Error</h2>
28-
<p>{error_message}</p>
29+
<p>{html_escape(error_message)}</p>
2930
<p>Please ensure you grant the requested permissions. You can close this tab and try again.</p>
3031
</body>
3132
</html>
@@ -193,7 +194,7 @@ def create_success_response(verified_user_id: Optional[str] = None) -> HTMLRespo
193194
<div class="icon">✓</div>
194195
<h1>Authentication Successful</h1>
195196
<div class="message">
196-
You've been authenticated as <span class="user-id">{user_display}</span>
197+
You've been authenticated as <span class="user-id">{html_escape(user_display)}</span>
197198
</div>
198199
<div class="message">
199200
Your credentials have been securely saved. You can now close this tab and retry your original command.
@@ -221,7 +222,7 @@ def create_server_error_response(error_detail: str) -> HTMLResponse:
221222
<head><title>Authentication Processing Error</title></head>
222223
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; text-align: center;">
223224
<h2 style="color: #d32f2f;">Authentication Processing Error</h2>
224-
<p>An unexpected error occurred while processing your authentication: {error_detail}</p>
225+
<p>An unexpected error occurred while processing your authentication: {html_escape(error_detail)}</p>
225226
<p>Please try again. You can close this tab.</p>
226227
</body>
227228
</html>

tests/auth/__init__.py

Whitespace-only changes.

tests/auth/test_oauth_responses.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Tests for XSS prevention in OAuth callback HTML responses."""
2+
3+
from auth.oauth_responses import (
4+
create_error_response,
5+
create_server_error_response,
6+
create_success_response,
7+
)
8+
9+
10+
class TestXSSPrevention:
11+
def test_error_response_escapes_script_tag(self):
12+
xss_payload = '<script>alert("xss")</script>'
13+
response = create_error_response(xss_payload)
14+
body = response.body.decode()
15+
assert "<script>alert" not in body
16+
assert "&lt;script&gt;" in body
17+
18+
def test_error_response_escapes_html_entities(self):
19+
response = create_error_response('Test <b>bold</b> & "quotes"')
20+
body = response.body.decode()
21+
assert "<b>" not in body
22+
assert "&lt;b&gt;" in body
23+
assert "&amp;" in body
24+
25+
def test_success_response_escapes_user_display(self):
26+
xss_email = "<img src=x onerror=alert(1)>@evil.com"
27+
response = create_success_response(verified_user_id=xss_email)
28+
body = response.body.decode()
29+
# The raw <img> tag should not appear — only the escaped version
30+
assert "<img src=" not in body
31+
assert "&lt;img src=x onerror=alert(1)&gt;@evil.com" in body
32+
33+
def test_success_response_normal_email_displays_correctly(self):
34+
response = create_success_response(verified_user_id="user@example.com")
35+
body = response.body.decode()
36+
assert "user@example.com" in body
37+
38+
def test_success_response_none_user_shows_default(self):
39+
response = create_success_response(verified_user_id=None)
40+
body = response.body.decode()
41+
assert "Google User" in body
42+
43+
def test_server_error_response_escapes_exception(self):
44+
xss_detail = "FileNotFoundError: /secret/path/<script>alert(1)</script>"
45+
response = create_server_error_response(xss_detail)
46+
body = response.body.decode()
47+
assert "<script>alert" not in body
48+
assert "&lt;script&gt;" in body
49+
50+
def test_error_response_status_code(self):
51+
response = create_error_response("test", status_code=403)
52+
assert response.status_code == 403
53+
54+
def test_server_error_response_status_code(self):
55+
response = create_server_error_response("test")
56+
assert response.status_code == 500
57+
58+
def test_success_response_status_code(self):
59+
response = create_success_response("user@example.com")
60+
assert response.status_code == 200

0 commit comments

Comments
 (0)