Browse Source

fix: escape user-controlled values in Swagger UI and ReDoc HTML

The /docs and /redoc endpoints embed openapi_url, oauth2_redirect_url,
title, and CDN URLs directly into HTML and JavaScript using f-strings
without escaping. When root_path comes from a reverse proxy header
(like X-Forwarded-Prefix), an attacker can inject arbitrary JavaScript
into the generated page.

This applies html.escape() to values in HTML attribute and text contexts,
and uses the existing _html_safe_json() (which produces properly quoted
JSON strings) for values embedded in JavaScript contexts. This is the
same approach already used for swagger_ui_parameters.
pull/15423/head
Ismail Pelaseyed 3 months ago
parent
commit
b679bd3db2
  1. 21
      fastapi/openapi/docs.py
  2. 2
      tests/test_application.py
  3. 2
      tests/test_custom_swagger_ui_redirect.py

21
fastapi/openapi/docs.py

@ -1,4 +1,5 @@
import json import json
from html import escape as html_escape
from typing import Annotated, Any from typing import Annotated, Any
from annotated_doc import Doc from annotated_doc import Doc
@ -154,25 +155,25 @@ def get_swagger_ui_html(
<html> <html>
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link type="text/css" rel="stylesheet" href="{swagger_css_url}"> <link type="text/css" rel="stylesheet" href="{html_escape(swagger_css_url, quote=True)}">
<link rel="shortcut icon" href="{swagger_favicon_url}"> <link rel="shortcut icon" href="{html_escape(swagger_favicon_url, quote=True)}">
<title>{title}</title> <title>{html_escape(title)}</title>
</head> </head>
<body> <body>
<div id="swagger-ui"> <div id="swagger-ui">
</div> </div>
<script src="{swagger_js_url}"></script> <script src="{html_escape(swagger_js_url, quote=True)}"></script>
<!-- `SwaggerUIBundle` is now available on the page --> <!-- `SwaggerUIBundle` is now available on the page -->
<script> <script>
const ui = SwaggerUIBundle({{ const ui = SwaggerUIBundle({{
url: '{openapi_url}', url: {_html_safe_json(openapi_url)},
""" """
for key, value in current_swagger_ui_parameters.items(): for key, value in current_swagger_ui_parameters.items():
html += f"{_html_safe_json(key)}: {_html_safe_json(jsonable_encoder(value))},\n" html += f"{_html_safe_json(key)}: {_html_safe_json(jsonable_encoder(value))},\n"
if oauth2_redirect_url: if oauth2_redirect_url:
html += f"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}'," html += f"oauth2RedirectUrl: window.location.origin + {_html_safe_json(oauth2_redirect_url)},"
html += """ html += """
presets: [ presets: [
@ -265,7 +266,7 @@ def get_redoc_html(
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>{title}</title> <title>{html_escape(title)}</title>
<!-- needed for adaptive design --> <!-- needed for adaptive design -->
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@ -275,7 +276,7 @@ def get_redoc_html(
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
""" """
html += f""" html += f"""
<link rel="shortcut icon" href="{redoc_favicon_url}"> <link rel="shortcut icon" href="{html_escape(redoc_favicon_url, quote=True)}">
<!-- <!--
ReDoc doesn't change outer page styles ReDoc doesn't change outer page styles
--> -->
@ -290,8 +291,8 @@ def get_redoc_html(
<noscript> <noscript>
ReDoc requires Javascript to function. Please enable it to browse the documentation. ReDoc requires Javascript to function. Please enable it to browse the documentation.
</noscript> </noscript>
<redoc spec-url="{openapi_url}"></redoc> <redoc spec-url="{html_escape(openapi_url, quote=True)}"></redoc>
<script src="{redoc_js_url}"> </script> <script src="{html_escape(redoc_js_url, quote=True)}"> </script>
</body> </body>
</html> </html>
""" """

2
tests/test_application.py

@ -27,7 +27,7 @@ def test_swagger_ui():
assert response.headers["content-type"] == "text/html; charset=utf-8" assert response.headers["content-type"] == "text/html; charset=utf-8"
assert "swagger-ui-dist" in response.text assert "swagger-ui-dist" in response.text
assert ( assert (
"oauth2RedirectUrl: window.location.origin + '/docs/oauth2-redirect'" 'oauth2RedirectUrl: window.location.origin + "/docs/oauth2-redirect"'
in response.text in response.text
) )

2
tests/test_custom_swagger_ui_redirect.py

@ -21,7 +21,7 @@ def test_swagger_ui():
assert "swagger-ui-dist" in response.text assert "swagger-ui-dist" in response.text
print(client.base_url) print(client.base_url)
assert ( assert (
f"oauth2RedirectUrl: window.location.origin + '{swagger_ui_oauth2_redirect_url}'" f'oauth2RedirectUrl: window.location.origin + "{swagger_ui_oauth2_redirect_url}"'
in response.text in response.text
) )

Loading…
Cancel
Save