← pod_r5_v1h

fastapi_14986

failed WRONG_FIX wrong_fix · 31 tool calls · 222 s · fastapi/fastapi

Task input

♻️ Refactor logic to handle OpenAPI and Swagger UI escaping data

♻️ Refactor logic to handle OpenAPI and Swagger UI escaping data

OpenAPI, do not store `root_path` in servers: the only way this could be a problem is if there was a misconfigured proxy that somehow allowed an attacker client to set `x-forwarded-*` headers and passed them along. For a proxy (or server) to do this, it normally has to be intentionally/explicitly misconfigured. But again, doesn't hurt to have it there.

Escape Swagger UI configs: I wouldn't consider this really important, the Swagger UI logic takes only data from the same developer building the app, I don't see a feasible scenario where this could be a problem, but probably also doesn't hurt much to have it there.

---

I received several "security reports" with this, I suspect some automated scanning tool that checks any JSON inside of HTML or similar. I don't consider these security issues, but also think it's probably fine to have these changes.

Tool calls (32)

#ToolArgumentsResult
1run_command
{
 "command": "grep -rn \"swagger_ui\" fastapi | head -40"
}
{"status": "ok", "stdout": "fastapi/applications.py:22: get_swagger_ui_html,\nfastapi/applications.py:23: get_swagger_ui_oauth2_redirect_html,\nfastapi/applicat
full
{"status": "ok", "stdout": "fastapi/applications.py:22:    get_swagger_ui_html,\nfastapi/applications.py:23:    get_swagger_ui_oauth2_redirect_html,\nfastapi/applications.py:449:        swagger_ui_oauth2_redirect_url: Annotated[\nfastapi/applications.py:462:        swagger_ui_init_oauth: Annotated[\nfastapi/applications.py:756:        swagger_ui_parameters: Annotated[\nfastapi/applications.py:889:        self.swagger_ui_oauth2_redirect_url = swagger_ui_oauth2_redirect_url\nfastapi/applications.py:890:        self.swagger_ui_init_oauth = swagger_ui_init_oauth\nfastapi/applications.py:891:        self.swagger_ui_parameters = swagger_ui_parameters\nfastapi/applications.py:1118:            async def swagger_ui_html(req: Request) -> HTMLResponse:\nfastapi/applications.py:1121:                oauth2_redirect_url = self.swagger_ui_oauth2_redirect_url\nfastapi/applications.py:1124:                return get_swagger_ui_html(\nfastapi/applications.py:1128:                    init_oauth=self.swagger_ui_init_oauth,\nfastapi/applications.py:1129:                    swagger_ui_parameters=self.swagger_ui_parameters,\nfastapi/applications.py:1132:            self.add_route(self.docs_url, swagger_ui_html, include_in_schema=False)\nfastapi/applications.py:1134:            if self.swagger_ui_oauth2_redirect_url:\nfastapi/applications.py:1136:                async def swagger_ui_redirect(req: Request) -> HTMLResponse:\nfastapi/applications.py:1137:                    return get_swagger_ui_oauth2_redirect_html()\nfastapi/applications.py:1140:                    self.swagger_ui_oauth2_redirect_url,\nfastapi/applications.py:1141:                    swagger_ui_redirect,\nfastapi/openapi/docs.py:8:swagger_ui_default_parameters: Annotated[\nfastapi/openapi/docs.py:26:def get_swagger_ui_html(\nfastapi/openapi/docs.py:109:    swagger_ui_parameters: Annotated[\nfastapi/openapi/docs.py:115:            It defaults to [swagger_ui_default_parameters][fastapi.openapi.docs.swagger_ui_default_paramete
2run_command
{
 "command": "grep -rn \"root_path\" fastapi | head -40"
}
{"status": "ok", "stdout": "fastapi/applications.py:300: (`root_path`) if it's different from `/`.\nfastapi/applications.py:637: \"openapi_prefix\" has been dep
full
{"status": "ok", "stdout": "fastapi/applications.py:300:                (`root_path`) if it's different from `/`.\nfastapi/applications.py:637:                \"openapi_prefix\" has been deprecated in favor of \"root_path\", which\nfastapi/applications.py:643:        root_path: Annotated[\nfastapi/applications.py:658:                app = FastAPI(root_path=\"/api/v1\")\nfastapi/applications.py:663:        root_path_in_servers: Annotated[\nfastapi/applications.py:668:                in the autogenerated OpenAPI using the `root_path`.\nfastapi/applications.py:678:                app = FastAPI(root_path_in_servers=False)\nfastapi/applications.py:886:        self.root_path_in_servers = root_path_in_servers\nfastapi/applications.py:934:                '\"openapi_prefix\" has been deprecated in favor of \"root_path\", which '\nfastapi/applications.py:951:        self.root_path = root_path or openapi_prefix\nfastapi/applications.py:1108:                root_path = req.scope.get(\"root_path\", \"\").rstrip(\"/\")\nfastapi/applications.py:1109:                if root_path not in server_urls:\nfastapi/applications.py:1110:                    if root_path and self.root_path_in_servers:\nfastapi/applications.py:1111:                        self.servers.insert(0, {\"url\": root_path})\nfastapi/applications.py:1112:                        server_urls.add(root_path)\nfastapi/applications.py:1119:                root_path = req.scope.get(\"root_path\", \"\").rstrip(\"/\")\nfastapi/applications.py:1120:                openapi_url = root_path + self.openapi_url\nfastapi/applications.py:1123:                    oauth2_redirect_url = root_path + oauth2_redirect_url\nfastapi/applications.py:1147:                root_path = req.scope.get(\"root_path\", \"\").rstrip(\"/\")\nfastapi/applications.py:1148:                openapi_url = root_path + self.openapi_url\nfastapi/applications.py:1156:        if self.root_path:\nfastapi/applications.py:1157:            scope[\"root_path\"] = self.roo
3run_command
{
 "command": "sed -n '1100,1120p' fastapi/applications.py"
}
{"status": "ok", "stdout": " return self.openapi_schema\n\n def setup(self) -> None:\n if self.openapi_url:\n urls = (server_data.get(\"url\") for server_data i
full
{"status": "ok", "stdout": "        return self.openapi_schema\n\n    def setup(self) -> None:\n        if self.openapi_url:\n            urls = (server_data.get(\"url\") for server_data in self.servers)\n            server_urls = {url for url in urls if url}\n\n            async def openapi(req: Request) -> JSONResponse:\n                root_path = req.scope.get(\"root_path\", \"\").rstrip(\"/\")\n                if root_path not in server_urls:\n                    if root_path and self.root_path_in_servers:\n                        self.servers.insert(0, {\"url\": root_path})\n                        server_urls.add(root_path)\n                return JSONResponse(self.openapi())\n\n            self.add_route(self.openapi_url, openapi, include_in_schema=False)\n        if self.openapi_url and self.docs_url:\n\n            async def swagger_ui_html(req: Request) -> HTMLResponse:\n                root_path = req.scope.get(\"root_path\", \"\").rstrip(\"/\")\n                openapi_url = root_path + self.openapi_url\n", "stderr": "", "exit_code": 0}
4run_command
{
 "command": "sed -n '150,170p' fastapi/openapi/docs.py"
}
{"status": "ok", "stdout": " <script src=\"{swagger_js_url}\"></script>\n <!-- `SwaggerUIBundle` is now available on the page -->\n <script>\n const ui = Swagge
full
{"status": "ok", "stdout": "    <script src=\"{swagger_js_url}\"></script>\n    <!-- `SwaggerUIBundle` is now available on the page -->\n    <script>\n    const ui = SwaggerUIBundle({{\n        url: '{openapi_url}',\n    \"\"\"\n\n    for key, value in current_swagger_ui_parameters.items():\n        html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\\n\"\n\n    if oauth2_redirect_url:\n        html += f\"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',\"\n\n    html += \"\"\"\n    presets: [\n        SwaggerUIBundle.presets.apis,\n        SwaggerUIBundle.SwaggerUIStandalonePreset\n        ],\n    })\"\"\"\n\n    if init_oauth:\n", "stderr": "", "exit_code": 0}
5run_command
{
 "command": "sed -n '1100,1120p' fastapi/applications.py"
}
{"status": "ok", "stdout": " return self.openapi_schema\n\n def setup(self) -> None:\n if self.openapi_url:\n urls = (server_data.get(\"url\") for server_data i
full
{"status": "ok", "stdout": "        return self.openapi_schema\n\n    def setup(self) -> None:\n        if self.openapi_url:\n            urls = (server_data.get(\"url\") for server_data in self.servers)\n            server_urls = {url for url in urls if url}\n\n            async def openapi(req: Request) -> JSONResponse:\n                root_path = req.scope.get(\"root_path\", \"\").rstrip(\"/\")\n                if root_path not in server_urls:\n                    if root_path and self.root_path_in_servers:\n                        self.servers.insert(0, {\"url\": root_path})\n                        server_urls.add(root_path)\n                return JSONResponse(self.openapi())\n\n            self.add_route(self.openapi_url, openapi, include_in_schema=False)\n        if self.openapi_url and self.docs_url:\n\n            async def swagger_ui_html(req: Request) -> HTMLResponse:\n                root_path = req.scope.get(\"root_path\", \"\").rstrip(\"/\")\n                openapi_url = root_path + self.openapi_url\n", "stderr": "", "exit_code": 0}
6run_command
{
 "command": "sed -n '1100,1115p' fastapi/applications.py"
}
{"status": "ok", "stdout": " return self.openapi_schema\n\n def setup(self) -> None:\n if self.openapi_url:\n urls = (server_data.get(\"url\") for server_data i
full
{"status": "ok", "stdout": "        return self.openapi_schema\n\n    def setup(self) -> None:\n        if self.openapi_url:\n            urls = (server_data.get(\"url\") for server_data in self.servers)\n            server_urls = {url for url in urls if url}\n\n            async def openapi(req: Request) -> JSONResponse:\n                root_path = req.scope.get(\"root_path\", \"\").rstrip(\"/\")\n                if root_path not in server_urls:\n                    if root_path and self.root_path_in_servers:\n                        self.servers.insert(0, {\"url\": root_path})\n                        server_urls.add(root_path)\n                return JSONResponse(self.openapi())\n\n            self.add_route(self.openapi_url, openapi, include_in_schema=False)\n", "stderr": "", "exit_code": 0}
7run_command
{
 "command": "python3 - <<'PY'\nfrom pathlib import Path\np = Path(\"fastapi/applications.py\")\ns = p.read_text()\nold = \"\"\"                if root_path not in server_urls:\n                    if root_path and self.root_path_in_servers:\n                        self.servers.insert(0, {\"url\": root_path})\n                        server_urls.add(root_path)\"\"\"\nnew = \"\"\"                if root_path not in server_urls:\n                    # We do not insert root_path into servers as it can be used for XSS\n                    # if an attacker can control the root_path (e.g. via x-forwarded-prefix)\n                    pass\"\"\"\nassert s.count(old) == 1\nt = s.replace(old, new)\np.write_text(t)\nPY\n"
}
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
full
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
8run_command
{
 "command": "sed -n '140,170p' fastapi/openapi/docs.py"
}
{"status": "ok", "stdout": " <html>\n <head>\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <link type=\"text/css\" rel=\"styles
full
{"status": "ok", "stdout": "    <html>\n    <head>\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <link type=\"text/css\" rel=\"stylesheet\" href=\"{swagger_css_url}\">\n    <link rel=\"shortcut icon\" href=\"{swagger_favicon_url}\">\n    <title>{title}</title>\n    </head>\n    <body>\n    <div id=\"swagger-ui\">\n    </div>\n    <script src=\"{swagger_js_url}\"></script>\n    <!-- `SwaggerUIBundle` is now available on the page -->\n    <script>\n    const ui = SwaggerUIBundle({{\n        url: '{openapi_url}',\n    \"\"\"\n\n    for key, value in current_swagger_ui_parameters.items():\n        html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\\n\"\n\n    if oauth2_redirect_url:\n        html += f\"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',\"\n\n    html += \"\"\"\n    presets: [\n        SwaggerUIBundle.presets.apis,\n        SwaggerUIBundle.SwaggerUIStandalonePreset\n        ],\n    })\"\"\"\n\n    if init_oauth:\n", "stderr": "", "exit_code": 0}
9run_command
{
 "command": "python3 - <<'PY'\nimport json\nfrom pathlib import Path\nfrom fastapi.fastapi_encoder import jsonable_encoder\n\n# This is not for applying changes but for understanding how to escape\n# We want to ensure that whatever is put into the <script> block is safely encoded.\n# json.dumps is generally good for JSON, but not necessarily for HTML script tags\n# because it doesn't escape </script> or other HTML-sensitive characters.\n# However, for a simple JSON-like object in a script tag, it might be enough.\n# But the problem statement mentions \"escaping data\".\n# Let's see how current code does it:\n# html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\"\n# This is already using json.dumps.\n# Wait, if it's already using json.dumps, maybe it needs more?\n# Like replacing </script> with <\\/script>?\nPY\n"
}
{"status": "error", "error_type": "CommandError", "error_message": "Traceback (most recent call last):\n File \"<stdin>\", line 3, in <module>\nModuleNotFoundEr
full
{"status": "error", "error_type": "CommandError", "error_message": "Traceback (most recent call last):\n  File \"<stdin>\", line 3, in <module>\nModuleNotFoundError: No module named 'fastapi.fastapi_encoder'\n", "details": {"stdout": "", "stderr": "Traceback (most recent call last):\n  File \"<stdin>\", line 3, in <module>\nModuleNotFoundError: No module named 'fastapi.fastapi_encoder'\n", "exit_code": 1}}
10run_command
{
 "command": "grep -rn \"jsonable_encoder\" fastapi | head -20"
}
{"status": "ok", "stdout": "fastapi/exception_handlers.py:1:from fastapi.encoders import jsonable_encoder\nfastapi/exception_handlers.py:25: content={\"detail\"
full
{"status": "ok", "stdout": "fastapi/exception_handlers.py:1:from fastapi.encoders import jsonable_encoder\nfastapi/exception_handlers.py:25:        content={\"detail\": jsonable_encoder(exc.errors())},\nfastapi/exception_handlers.py:33:        code=WS_1008_POLICY_VIOLATION, reason=jsonable_encoder(exc.errors())\nfastapi/encoders.py:112:def jsonable_encoder(\nfastapi/encoders.py:236:        return jsonable_encoder(\nfastapi/encoders.py:245:        return jsonable_encoder(\nfastapi/encoders.py:281:                encoded_key = jsonable_encoder(\nfastapi/encoders.py:289:                encoded_value = jsonable_encoder(\nfastapi/encoders.py:303:                jsonable_encoder(\nfastapi/encoders.py:337:    return jsonable_encoder(\nfastapi/openapi/docs.py:5:from fastapi.encoders import jsonable_encoder\nfastapi/openapi/docs.py:158:        html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\\n\"\nfastapi/openapi/docs.py:172:        ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))})\nfastapi/openapi/utils.py:26:from fastapi.encoders import jsonable_encoder\nfastapi/openapi/utils.py:88:        security_definition = jsonable_encoder(\nfastapi/openapi/utils.py:171:                parameter[\"examples\"] = jsonable_encoder(openapi_examples)\nfastapi/openapi/utils.py:173:                parameter[\"example\"] = jsonable_encoder(example)\nfastapi/openapi/utils.py:206:        request_media_content[\"examples\"] = jsonable_encoder(\nfastapi/openapi/utils.py:210:        request_media_content[\"example\"] = jsonable_encoder(field_info.example)\nfastapi/openapi/utils.py:571:    return jsonable_encoder(OpenAPI(**output), by_alias=True, exclude_none=True)  # type: ignore\n", "stderr": "", "exit_code": 0}
11run_command
{
 "command": "python3 - <<'PY'\nfrom pathlib import Path\np = Path(\"fastapi/openapi/docs.py\")\ns = p.read_text()\n# We need to escape characters that could break out of the <script> tag.\n# The most dangerous one is </script>.\n# Also we should escape <, >, & to be safe if they are in strings.\n\ndef escape_json(json_str):\n    return json_str.replace('<', '\\\\u003c').replace('>', '\\\\u003e').replace('&', '\\\\u0026')\n\n# However, the current code does:\n# html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\"\n# This means the value is already a JSON string.\n\n# Let's replace it with a version that uses a helper or just does the replacement.\n# But wait, the problem statement says \"Escape Swagger UI configs\".\n\n# Let's look at how the html is constructed in fastapi/openapi/docs.py\nPY\n"
}
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
full
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
12run_command
{
 "command": "sed -n '150,180p' fastapi/openapi/docs.py"
}
{"status": "ok", "stdout": " <script src=\"{swagger_js_url}\"></script>\n <!-- `SwaggerUIBundle` is now available on the page -->\n <script>\n const ui = Swagge
full
{"status": "ok", "stdout": "    <script src=\"{swagger_js_url}\"></script>\n    <!-- `SwaggerUIBundle` is now available on the page -->\n    <script>\n    const ui = SwaggerUIBundle({{\n        url: '{openapi_url}',\n    \"\"\"\n\n    for key, value in current_swagger_ui_parameters.items():\n        html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\\n\"\n\n    if oauth2_redirect_url:\n        html += f\"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',\"\n\n    html += \"\"\"\n    presets: [\n        SwaggerUIBundle.presets.apis,\n        SwaggerUIBundle.SwaggerUIStandalonePreset\n        ],\n    })\"\"\"\n\n    if init_oauth:\n        html += f\"\"\"\n        ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))})\n        \"\"\"\n\n    html += \"\"\"\n    </script>\n    </body>\n    </html>\n    \"\"\"\n    return HTMLResponse(html)\n", "stderr": "", "exit_code": 0}
13run_command
{
 "command": "python3 - <<'PY'\nfrom pathlib import Path\np = Path(\"fastapi/openapi/docs.py\")\ns = p.read_text()\n\n# We want to replace json.dumps(...) with something that escapes <, >, &\n# One way is to use a helper function.\n\n# Let's define a helper function to escape the JSON output for HTML script tags.\n# But where to put it? Maybe at the top of the file or just inline it.\n\n# Actually, a common way to do this in Python is to replace the characters in the resulting string.\n\n# In fastapi/openapi/docs.py, we have:\n# html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\"\n# and\n# ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))})\n\n# Let's use a small helper for this.\n\n# First, let's see if there's any existing escaping utility in fastapi.\nPY\n"
}
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
full
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
14run_command
{
 "command": "grep -rn \"escape\" fastapi | head -20"
}
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
full
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
15run_command
{
 "command": "python3 - <<'PY'\nfrom pathlib import Path\np = Path(\"fastapi/openapi/docs.py\")\ns = p.read_text()\n\n# I will introduce a small helper function to escape JSON for HTML script tags.\n# But it's easier to just do .replace('<', '\\\\u003c').replace('>', '\\\\u003e').replace('&', '\\\\u0026')\n\n# Let's replace:\n# html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\"\n# with:\n# html += f\"{json.dumps(key).replace('<', '\\\\u003c').replace('>', '\\\\u003e').replace('&', '\\\\u0026')}: {json.dumps(jsonable_encoder(value)).replace('<', '\\\\u003c').replace('>', '\\\\u003e').replace('&', '\\\\u0026')},\"\n\n# And:\n# ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))})\n# with:\n# ui.initOAuth({json.dumps(jsonable_encoder(init_oauth)).replace('<', '\\\\u003c').replace('>', '\\\\u003e').replace('&', '\\\\u0026')})\n\n# Wait, I can just use a helper function to make it cleaner.\n# But I should avoid modifying too much of the file.\n\n# Let's see if I can just do a replacement.\n\n# Actually, let's define a small function in the file if possible.\n# But the file is just a few functions.\n\n# Let's try to do it inline to be safe and minimal.\nPY\n"
}
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
full
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
16run_command
{
 "command": "python3 - <<'PY'\nfrom pathlib import Path\np = Path(\"fastapi/openapi/docs.py\")\ns = p.read_text()\n\n# We need to replace two places.\n\n# Place 1:\nold1 = '        html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\"\\\\n'\n# Wait, the sed output showed:\n# html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\"\n# The quotes might be tricky.\n\n# Let's use a more flexible replacement.\n# I'll use a regex-like approach with replace.\n\n# Let's re-read the file carefully.\nPY\n"
}
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
full
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
17run_command
{
 "command": "sed -n '157,175p' fastapi/openapi/docs.py"
}
{"status": "ok", "stdout": " for key, value in current_swagger_ui_parameters.items():\n html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\\n\
full
{"status": "ok", "stdout": "    for key, value in current_swagger_ui_parameters.items():\n        html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\\n\"\n\n    if oauth2_redirect_url:\n        html += f\"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',\"\n\n    html += \"\"\"\n    presets: [\n        SwaggerUIBundle.presets.apis,\n        SwaggerUIBundle.SwaggerUIStandalonePreset\n        ],\n    })\"\"\"\n\n    if init_oauth:\n        html += f\"\"\"\n        ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))})\n        \"\"\"\n\n    html += \"\"\"\n", "stderr": "", "exit_code": 0}
18run_command
{
 "command": "python3 - <<'PY'\nfrom pathlib import Path\np = Path(\"fastapi/openapi/docs.py\")\ns = p.read_text()\n\n# Define escaping function as a string to be inserted\n# But I can't easily insert it before the function.\n# I'll just do inline replacements.\n\n# First replacement\nold1 = '        html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\" '\n# The trailing space might not be there. Let's check.\n# In sed output: html += f\\\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\\\\n\n# No trailing space.\n\n# I will use a helper function instead to avoid repetition.\n# Let's add a helper function at the top of the file, or just before get_swagger_ui_html.\n\n# Actually, I can just use a lambda or a local function inside get_swagger_ui_html.\n# That would be the cleanest.\n\n# Let's see where to insert it.\n# get_swagger_ui_html starts at line 26.\n# I can insert it at the beginning of the function.\n\n# Wait, the current code is:\n# def get_swagger_ui_html(\n#     openapi_url: str,\n#     title: str,\n#     oauth2_redirect_url: str | None = None,\n#     swagger_ui_parameters: Annotated[\n#         dict[str, Any] | None,\n#         Alias := dict[str, Any],\n#     ] = None,\n#     init_oauth: Annotated[\n#         dict[str, Any] | None,\n#         Alias := dict[str, Any],\n#     ] = None,\n# ) -> HTMLResponse:\n\n# I can insert:\n#     def json_escape(val: str) -> str:\n#         return val.replace('<', '\\\\u003c').replace('>', '\\\\u003e').replace('&', '\\\\u0026')\n#     \n# And then use it.\n\n# Let's try to do a simple replacement for now.\nPY\n"
}
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
full
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
19run_command
{
 "command": "python3 - <<'PY'\nfrom pathlib import Path\np = Path(\"fastapi/openapi/docs.py\")\ns = p.read_text()\n\n# This is a bit complex to do with string replace because of the f-strings and quotes.\n# I'll use a more robust way to replace the specific lines.\n\n# Target 1:\n#         html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\"\n# Target 2:\n#         ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))})\n\n# I'll use a helper function for escaping and insert it.\n\n# Let's try to insert the helper function first.\n# find the line where get_swagger_ui_html is defined.\nlines = s.splitlines()\nfor i, line in enumerate(lines):\n    if 'def get_swagger_ui_html(' in line:\n        # Insert helper function after the function definition (and its arguments)\n        # The arguments end where the ')' and '-> HTMLResponse:' are.\n        # Let's find the line where the function body starts.\n        # The function body starts after the '-> HTMLResponse:' line.\n        # But the signature can span multiple lines.\n        pass\n\n# Actually, the easiest way is to just replace the specific lines.\n# I'll be very careful with the strings.\n\nold1 = '        html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\" '\n# Wait, I already tried this.\n\n# Let's use:\n# s = s.replace('f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\"', 'f\"{json.dumps(key).replace(\\'<\\', \\'\\\\u003c\\').replace(\\'>\\', \\'\\\\u003e\\').replace(\\'&\\', \\'\\\\u0026\\')}: {json.dumps(jsonable_encoder(value)).replace(\\'<\\', \\'\\\\u003c\\').replace(\\'>\\', \\'\\\\u003e\\').replace(\\'&\\', \\'\\\\u0026\\')},\"')\n# This is getting messy with quotes.\n\n# Let's just use a helper function.\n# I'll insert it at the top of the file.\n\n# New content for the top of the file:\n# import json\n# from fastapi.encoders import jsonable_encoder\n# from fastapi.responses import HTMLResponse\n# ...\n\n# I'll add:\n# def json_escape(val: str) -> str:\n#     return val.replace('<', '\\\\u003c').replace('>', '\\\\u003e').replace('&', '\\\\u0026')\n\n# And then update the lines.\nPY\n"
}
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
full
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
20run_command
{
 "command": "python3 - <<'PY'\nfrom pathlib import Path\np = Path(\"fastapi/openapi/docs.py\")\ns = p.read_text()\n\n# Let's insert the helper function.\n# The imports are at the top.\nimport_block = \"from fastapi.responses import HTMLResponse\"\nhelper_func = \"\\ndef json_escape(val: str) -> str:\\n    return val.replace('<', '\\\\u003c').replace('>', '\\\\u003e').replace('&', '\\\\u0026')\\n\"\n\nif import_block in s:\n    s = s.replace(import_block, import_block + helper_func)\n\n# Now replace the two usage sites.\n# Site 1:\nold1 = '        html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\" '\n# Again, the trailing space. Let's use a substring.\ns = s.replace('f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\"', 'f\"{json_escape(json.dumps(key))}: {json_escape(json.dumps(jsonable_encoder(value)))}\"')\n\n# Site 2:\ns = s.replace('ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))})', 'ui.initOAuth({json_escape(json.dumps(jsonable_encoder(init_oauth)))})')\n\np.write_text(s)\nPY\n"
}
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
full
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
21run_command
{
 "command": "sed -n '150,180p' fastapi/openapi/docs.py"
}
{"status": "ok", "stdout": " <script src=\"{swagger_js_url}\"></script>\n <!-- `SwaggerUIBundle` is now available on the page -->\n <script>\n const ui = Swagge
full
{"status": "ok", "stdout": "    <script src=\"{swagger_js_url}\"></script>\n    <!-- `SwaggerUIBundle` is now available on the page -->\n    <script>\n    const ui = SwaggerUIBundle({{\n        url: '{openapi_url}',\n    \"\"\"\n\n    for key, value in current_swagger_ui_parameters.items():\n        html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\\n\"\n\n    if oauth2_redirect_url:\n        html += f\"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',\"\n\n    html += \"\"\"\n    presets: [\n        SwaggerUIBundle.presets.apis,\n        SwaggerUIBundle.SwaggerUIStandalonePreset\n        ],\n    })\"\"\"\n\n    if init_oauth:\n        html += f\"\"\"\n        ui.initOAuth({json_escape(json.dumps(jsonable_encoder(init_oauth)))})\n        \"\"\"\n\n    html += \"\"\"\n    </script>\n    </body>\n    </html>\n    \"\"\"\n    return HTMLResponse(html)\n", "stderr": "", "exit_code": 0}
22run_command
{
 "command": "python3 - <<'PY'\nfrom pathlib import Path\np = Path(\"fastapi/openapi/docs.py\")\ns = p.read_text()\n# The first replacement failed because I used a different string than what's actually there.\n# Let's see what's there.\n# I'll just search for \"json.dumps(key)\" and replace the whole line.\n\nlines = s.splitlines()\nnew_lines = []\nfor line in lines:\n    if 'html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\"' in line:\n        line = line.replace('f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\"', 'f\"{json_escape(json.dumps(key))}: {json_escape(json.dumps(jsonable_encoder(value)))},\"')\n    new_lines.append(line)\n\np.write_text(\"\\n\".join(new_lines))\nPY\n"
}
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
full
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
23run_command
{
 "command": "sed -n '150,180p' fastapi/openapi/docs.py"
}
{"status": "ok", "stdout": " <script src=\"{swagger_js_url}\"></script>\n <!-- `SwaggerUIBundle` is now available on the page -->\n <script>\n const ui = Swagge
full
{"status": "ok", "stdout": "    <script src=\"{swagger_js_url}\"></script>\n    <!-- `SwaggerUIBundle` is now available on the page -->\n    <script>\n    const ui = SwaggerUIBundle({{\n        url: '{openapi_url}',\n    \"\"\"\n\n    for key, value in current_swagger_ui_parameters.items():\n        html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\\n\"\n\n    if oauth2_redirect_url:\n        html += f\"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',\"\n\n    html += \"\"\"\n    presets: [\n        SwaggerUIBundle.presets.apis,\n        SwaggerUIBundle.SwaggerUIStandalonePreset\n        ],\n    })\"\"\"\n\n    if init_oauth:\n        html += f\"\"\"\n        ui.initOAuth({json_escape(json.dumps(jsonable_encoder(init_oauth)))})\n        \"\"\"\n\n    html += \"\"\"\n    </script>\n    </body>\n    </html>\n    \"\"\"\n    return HTMLResponse(html)\n", "stderr": "", "exit_code": 0}
24run_command
{
 "command": "python3 - <<'PY'\nfrom pathlib import Path\np = Path(\"fastapi/openapi/docs.py\")\ns = p.read_text()\n\n# Let's see the line again\n# html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\"\n\n# It seems I'm having trouble matching it exactly.\n# Let's just use a very simple replace.\n# I will search for the line and replace it with the one I want.\n\nlines = s.splitlines()\nfor i, line in enumerate(lines):\n    if 'json.dumps(key)' in line and 'jsonable_encoder(value)' in line:\n        lines[i] = '        html += f\"{json_escape(json.dumps(key))}: {json_escape(json.dumps(jsonable_encoder(value)))},\" '\n        # Wait, the indent should be 8 spaces.\n        # Let's just use:\n        lines[i] = '        html += f\"{json_escape(json.dumps(key))}: {json_escape(json.dumps(jsonable_encoder(value)))},\"'\n\n# Actually, let's just use a more aggressive replace.\np.write_text(\"\\n\".join(lines))\nPY\n"
}
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
full
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
25run_command
{
 "command": "sed -n '150,180p' fastapi/openapi/docs.py"
}
{"status": "ok", "stdout": " <script src=\"{swagger_js_url}\"></script>\n <!-- `SwaggerUIBundle` is now available on the page -->\n <script>\n const ui = Swagge
full
{"status": "ok", "stdout": "    <script src=\"{swagger_js_url}\"></script>\n    <!-- `SwaggerUIBundle` is now available on the page -->\n    <script>\n    const ui = SwaggerUIBundle({{\n        url: '{openapi_url}',\n    \"\"\"\n\n    for key, value in current_swagger_ui_parameters.items():\n        html += f\"{json_escape(json.dumps(key))}: {json_escape(json.dumps(jsonable_encoder(value)))},\"\n\n    if oauth2_redirect_url:\n        html += f\"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',\"\n\n    html += \"\"\"\n    presets: [\n        SwaggerUIBundle.presets.apis,\n        SwaggerUIBundle.SwaggerUIStandalonePreset\n        ],\n    })\"\"\"\n\n    if init_oauth:\n        html += f\"\"\"\n        ui.initOAuth({json_escape(json.dumps(jsonable_encoder(init_oauth)))})\n        \"\"\"\n\n    html += \"\"\"\n    </script>\n    </body>\n    </html>\n    \"\"\"\n    return HTMLResponse(html)\n", "stderr": "", "exit_code": 0}
26run_command
{
 "command": "git diff"
}
{"status": "ok", "stdout": "diff --git a/fastapi/applications.py b/fastapi/applications.py\nindex ed05a1ff..ef022c05 100644\n--- a/fastapi/applications.py\n+++
full
{"status": "ok", "stdout": "diff --git a/fastapi/applications.py b/fastapi/applications.py\nindex ed05a1ff..ef022c05 100644\n--- a/fastapi/applications.py\n+++ b/fastapi/applications.py\n@@ -1107,9 +1107,9 @@ class FastAPI(Starlette):\n             async def openapi(req: Request) -> JSONResponse:\n                 root_path = req.scope.get(\"root_path\", \"\").rstrip(\"/\")\n                 if root_path not in server_urls:\n-                    if root_path and self.root_path_in_servers:\n-                        self.servers.insert(0, {\"url\": root_path})\n-                        server_urls.add(root_path)\n+                    # We do not insert root_path into servers as it can be used for XSS\n+                    # if an attacker can control the root_path (e.g. via x-forwarded-prefix)\n+                    pass\n                 return JSONResponse(self.openapi())\n \n             self.add_route(self.openapi_url, openapi, include_in_schema=False)\ndiff --git a/fastapi/openapi/docs.py b/fastapi/openapi/docs.py\nindex b845f87c..66f555c6 100644\n--- a/fastapi/openapi/docs.py\n+++ b/fastapi/openapi/docs.py\n@@ -155,7 +155,7 @@ def get_swagger_ui_html(\n     \"\"\"\n \n     for key, value in current_swagger_ui_parameters.items():\n-        html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\\n\"\n+        html += f\"{json_escape(json.dumps(key))}: {json_escape(json.dumps(jsonable_encoder(value)))},\"\n \n     if oauth2_redirect_url:\n         html += f\"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',\"\n@@ -169,7 +169,7 @@ def get_swagger_ui_html(\n \n     if init_oauth:\n         html += f\"\"\"\n-        ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))})\n+        ui.initOAuth({json_escape(json.dumps(jsonable_encoder(init_oauth)))})\n         \"\"\"\n \n     html += \"\"\"\n@@ -372,4 +372,4 @@ def get_swagger_ui_oauth2_redirect_html() -> HTMLResponse:\n     </body>\n     </html>\n         \"\"\"\n-    return HTMLRespon
27run_command
{
 "command": "python3 - <<'PY'\nfrom pathlib import Path\np = Path(\"fastapi/openapi/docs.py\")\ns = p.read_text()\n# Let's fix the helper function insertion and the final newline.\n\n# First, let's make sure the helper function is correctly inserted.\n# I'll just rewrite the imports and add the function.\n\n# Let's look at the top of the file.\nlines = s.splitlines()\n# Find where to insert the helper.\n# Let's just put it after the imports.\n# Imports end before the first function definition.\n\n# Find the first line that starts with 'def '\ninsert_idx = 0\nfor i, line in enumerate(lines):\n    if line.startswith('def '):\n        insert_idx = i\n        break\n\nhelper_func = \"def json_escape(val: str) -> str:\\n    return val.replace('<', '\\\\u003c').replace('>', '\\\\u003e').replace('&', '\\\\u0026')\\n\"\nlines.insert(insert_idx, helper_func)\n\n# And ensure the file ends with a newline.\np.write_text(\"\\n\".join(lines) + \"\\n\")\nPY\n"
}
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
full
{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}
28run_command
{
 "command": "python3 - <<'PY'\nfrom pathlib import Path\np = Path(\"fastapi/openapi/docs.py\")\ns = p.read_text()\n# I need to remove the duplicate helper function if I added it twice.\n# Let's check how many times it appears.\ncount = s.count('def json_escape')\nif count > 1:\n    # Keep only the first one\n    # This is tricky. Let's just clear and redo.\n    pass\nprint(f\"count: {count}\")\nPY\n"
}
{"status": "ok", "stdout": "count: 1\n", "stderr": "", "exit_code": 0}
full
{"status": "ok", "stdout": "count: 1\n", "stderr": "", "exit_code": 0}
29run_command
{
 "command": "git diff"
}
{"status": "ok", "stdout": "diff --git a/fastapi/applications.py b/fastapi/applications.py\nindex ed05a1ff..ef022c05 100644\n--- a/fastapi/applications.py\n+++
full
{"status": "ok", "stdout": "diff --git a/fastapi/applications.py b/fastapi/applications.py\nindex ed05a1ff..ef022c05 100644\n--- a/fastapi/applications.py\n+++ b/fastapi/applications.py\n@@ -1107,9 +1107,9 @@ class FastAPI(Starlette):\n             async def openapi(req: Request) -> JSONResponse:\n                 root_path = req.scope.get(\"root_path\", \"\").rstrip(\"/\")\n                 if root_path not in server_urls:\n-                    if root_path and self.root_path_in_servers:\n-                        self.servers.insert(0, {\"url\": root_path})\n-                        server_urls.add(root_path)\n+                    # We do not insert root_path into servers as it can be used for XSS\n+                    # if an attacker can control the root_path (e.g. via x-forwarded-prefix)\n+                    pass\n                 return JSONResponse(self.openapi())\n \n             self.add_route(self.openapi_url, openapi, include_in_schema=False)\ndiff --git a/fastapi/openapi/docs.py b/fastapi/openapi/docs.py\nindex b845f87c..9f7ff634 100644\n--- a/fastapi/openapi/docs.py\n+++ b/fastapi/openapi/docs.py\n@@ -23,6 +23,9 @@ swagger_ui_default_parameters: Annotated[\n }\n \n \n+def json_escape(val: str) -> str:\n+    return val.replace('<', '\\u003c').replace('>', '\\u003e').replace('&', '\\u0026')\n+\n def get_swagger_ui_html(\n     *,\n     openapi_url: Annotated[\n@@ -155,7 +158,7 @@ def get_swagger_ui_html(\n     \"\"\"\n \n     for key, value in current_swagger_ui_parameters.items():\n-        html += f\"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\\n\"\n+        html += f\"{json_escape(json.dumps(key))}: {json_escape(json.dumps(jsonable_encoder(value)))},\"\n \n     if oauth2_redirect_url:\n         html += f\"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',\"\n@@ -169,7 +172,7 @@ def get_swagger_ui_html(\n \n     if init_oauth:\n         html += f\"\"\"\n-        ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))})\n+  
30run_command
{
 "command": "submit_patch"
}
{"status": "error", "error_type": "CommandError", "error_message": "/bin/bash: submit_patch: command not found\n", "details": {"stdout": "", "stderr": "/bin/bas
full
{"status": "error", "error_type": "CommandError", "error_message": "/bin/bash: submit_patch: command not found\n", "details": {"stdout": "", "stderr": "/bin/bash: submit_patch: command not found\n", "exit_code": 127}}
31run_command
{
 "command": "git status"
}
{"status": "ok", "stdout": "On branch main\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n (use \"git restore <
full
{"status": "ok", "stdout": "On branch main\nChanges not staged for commit:\n  (use \"git add <file>...\" to update what will be committed)\n  (use \"git restore <file>...\" to discard changes in working directory)\n\tmodified:   fastapi/applications.py\n\tmodified:   fastapi/openapi/docs.py\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n", "stderr": "", "exit_code": 0}
32submit_patch
{}
{"status": "ok", "patch_size": 2011, "files_changed": 2}
full
{"status": "ok", "patch_size": 2011, "files_changed": 2}

Patch

diff --git a/fastapi/applications.py b/fastapi/applications.py
index ed05a1ff..ef022c05 100644
--- a/fastapi/applications.py
+++ b/fastapi/applications.py
@@ -1107,9 +1107,9 @@ class FastAPI(Starlette):
             async def openapi(req: Request) -> JSONResponse:
                 root_path = req.scope.get("root_path", "").rstrip("/")
                 if root_path not in server_urls:
-                    if root_path and self.root_path_in_servers:
-                        self.servers.insert(0, {"url": root_path})
-                        server_urls.add(root_path)
+                    # We do not insert root_path into servers as it can be used for XSS
+                    # if an attacker can control the root_path (e.g. via x-forwarded-prefix)
+                    pass
                 return JSONResponse(self.openapi())
 
             self.add_route(self.openapi_url, openapi, include_in_schema=False)
diff --git a/fastapi/openapi/docs.py b/fastapi/openapi/docs.py
index b845f87c..9f7ff634 100644
--- a/fastapi/openapi/docs.py
+++ b/fastapi/openapi/docs.py
@@ -23,6 +23,9 @@ swagger_ui_default_parameters: Annotated[
 }
 
 
+def json_escape(val: str) -> str:
+    return val.replace('<', '\u003c').replace('>', '\u003e').replace('&', '\u0026')
+
 def get_swagger_ui_html(
     *,
     openapi_url: Annotated[
@@ -155,7 +158,7 @@ def get_swagger_ui_html(
     """
 
     for key, value in current_swagger_ui_parameters.items():
-        html += f"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\n"
+        html += f"{json_escape(json.dumps(key))}: {json_escape(json.dumps(jsonable_encoder(value)))},"
 
     if oauth2_redirect_url:
         html += f"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',"
@@ -169,7 +172,7 @@ def get_swagger_ui_html(
 
     if init_oauth:
         html += f"""
-        ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))})
+        ui.initOAuth({json_escape(json.dumps(jsonable_encoder(init_oauth)))})
         """
 
     html += """

Test output

show
F
=================================== FAILURES ===================================
_______________ test_root_path_does_not_persist_across_requests ________________

    def test_root_path_does_not_persist_across_requests():
        app = FastAPI()
    
        @app.get("/")
        def read_root():  # pragma: no cover
            return {"ok": True}
    
        # Attacker request with a spoofed root_path
        attacker_client = TestClient(app, root_path="/evil-api")
        response1 = attacker_client.get("/openapi.json")
        data1 = response1.json()
>       assert any(s.get("url") == "/evil-api" for s in data1.get("servers", []))
E       assert False
E        +  where False = any(<generator object test_root_path_does_not_persist_across_requests.<locals>.<genexpr> at 0x103ad5560>)

tests/test_openapi_cache_root_path.py:16: AssertionError
=============================== warnings summary ===============================
../../../../../../../Users/jp/repos/kaggle-gemini-coding-agent-post-training/.envs/overlays/starlette-0.52.1-py3-none-any/starlette/testclient.py:45
  /Users/jp/repos/kaggle-gemini-coding-agent-post-training/.envs/overlays/starlette-0.52.1-py3-none-any/starlette/testclient.py:45: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead.
    _PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]]

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 1 warning in 0.53s