Skip to content

Commit bf398dd

Browse files
jonpspriclaude
andcommitted
fix: harden OpenAPI schema endpoint — SSRF, size limits, dead code removal
Security: - Disable HTTP redirects (follow_redirects=False) to prevent SSRF bypass via attacker-controlled 302 redirects to internal addresses - Add 10 MiB response size cap to prevent memory exhaustion from malicious servers - Remove broken frontend buttons that called undefined JS functions - Require 'url' parameter (not just openapi_url) to prevent empty URL parsing producing invalid base_url/path Complexity reduction (-1,900 lines): - Remove unused extract_all_schemas_from_openapi and fetch_and_extract_all_schemas (zero production callers) - Extract _extract_rest_url_components helper to deduplicate URL parsing in ToolCreate and ToolUpdate validators - Remove excessive doctests that duplicated unit test coverage - Collapse phantom tests mocking requests.get (never called by validators) into focused tests of actual validator behaviour - Consolidate admin endpoint error-mapping tests via parametrize - Remove unused _db dependency from endpoint signature - Remove debug logging from ToolCreate validator Closes #2784 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Jonathan Springer <jps@s390x.com>
1 parent c147290 commit bf398dd

10 files changed

Lines changed: 695 additions & 2013 deletions

File tree

.secrets.baseline

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"files": "^.secrets.baseline|package-lock.json|Cargo.lock|scripts/sign_image.sh|scripts/zap|sonar-project.properties|uv.lock|^.secrets.baseline$",
44
"lines": null
55
},
6-
"generated_at": "2026-04-13T06:27:05Z",
6+
"generated_at": "2026-04-13T06:40:07Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"
@@ -5086,31 +5086,31 @@
50865086
"hashed_secret": "fa9beb99e4029ad5a6615399e7bbae21356086b3",
50875087
"is_secret": false,
50885088
"is_verified": false,
5089-
"line_number": 4239,
5089+
"line_number": 4240,
50905090
"type": "Secret Keyword",
50915091
"verified_result": null
50925092
},
50935093
{
50945094
"hashed_secret": "559b05f1b2863e725b76e216ac3dadecbf92e244",
50955095
"is_secret": false,
50965096
"is_verified": false,
5097-
"line_number": 4840,
5097+
"line_number": 4841,
50985098
"type": "Secret Keyword",
50995099
"verified_result": null
51005100
},
51015101
{
51025102
"hashed_secret": "a8af4759392d4f7496d613174f33afe2074a4b8d",
51035103
"is_secret": false,
51045104
"is_verified": false,
5105-
"line_number": 4842,
5105+
"line_number": 4843,
51065106
"type": "Secret Keyword",
51075107
"verified_result": null
51085108
},
51095109
{
51105110
"hashed_secret": "85b60d811d16ff56b3654587d4487f713bfa33b7",
51115111
"is_secret": false,
51125112
"is_verified": false,
5113-
"line_number": 15050,
5113+
"line_number": 15169,
51145114
"type": "Secret Keyword",
51155115
"verified_result": null
51165116
}
@@ -5962,47 +5962,47 @@
59625962
"hashed_secret": "c377074d6473f35a91001981355da793dc808ffd",
59635963
"is_secret": false,
59645964
"is_verified": false,
5965-
"line_number": 4197,
5965+
"line_number": 4220,
59665966
"type": "Hex High Entropy String",
59675967
"verified_result": null
59685968
},
59695969
{
59705970
"hashed_secret": "6367c48dd193d56ea7b0baad25b19455e529f5ee",
59715971
"is_secret": false,
59725972
"is_verified": false,
5973-
"line_number": 5310,
5973+
"line_number": 5333,
59745974
"type": "Secret Keyword",
59755975
"verified_result": null
59765976
},
59775977
{
59785978
"hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511",
59795979
"is_secret": false,
59805980
"is_verified": false,
5981-
"line_number": 5474,
5981+
"line_number": 5497,
59825982
"type": "Secret Keyword",
59835983
"verified_result": null
59845984
},
59855985
{
59865986
"hashed_secret": "f42a3fabe1e9bed059d727f47eb752e3aa61b977",
59875987
"is_secret": false,
59885988
"is_verified": false,
5989-
"line_number": 5531,
5989+
"line_number": 5554,
59905990
"type": "Secret Keyword",
59915991
"verified_result": null
59925992
},
59935993
{
59945994
"hashed_secret": "b85788b459aa4d67e1070930dae6d0827756aadb",
59955995
"is_secret": false,
59965996
"is_verified": false,
5997-
"line_number": 5569,
5997+
"line_number": 5592,
59985998
"type": "Secret Keyword",
59995999
"verified_result": null
60006000
},
60016001
{
60026002
"hashed_secret": "52dcc83ec1e54426ad58a64854d1eb8d5f5d9685",
60036003
"is_secret": false,
60046004
"is_verified": false,
6005-
"line_number": 5570,
6005+
"line_number": 5593,
60066006
"type": "Secret Keyword",
60076007
"verified_result": null
60086008
}
@@ -9958,31 +9958,31 @@
99589958
"hashed_secret": "c00dbbc9dadfbe1e232e93a729dd4752fade0abf",
99599959
"is_secret": false,
99609960
"is_verified": false,
9961-
"line_number": 14402,
9961+
"line_number": 14411,
99629962
"type": "Secret Keyword",
99639963
"verified_result": null
99649964
},
99659965
{
99669966
"hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511",
99679967
"is_secret": false,
99689968
"is_verified": false,
9969-
"line_number": 17159,
9969+
"line_number": 17168,
99709970
"type": "Secret Keyword",
99719971
"verified_result": null
99729972
},
99739973
{
99749974
"hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c",
99759975
"is_secret": false,
99769976
"is_verified": false,
9977-
"line_number": 17178,
9977+
"line_number": 17187,
99789978
"type": "Secret Keyword",
99799979
"verified_result": null
99809980
},
99819981
{
99829982
"hashed_secret": "dc8002865f92070749b264e76045b04fa3b8de71",
99839983
"is_secret": false,
99849984
"is_verified": false,
9985-
"line_number": 20836,
9985+
"line_number": 20845,
99869986
"type": "Secret Keyword",
99879987
"verified_result": null
99889988
}

mcpgateway/admin.py

Lines changed: 82 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -11774,10 +11774,12 @@ async def admin_edit_tool(
1177411774

1177511775

1177611776
@admin_router.post("/tools/generate-schemas-from-openapi")
11777+
# tools.create — this endpoint makes outbound HTTP requests to user-supplied
11778+
# URLs to fetch OpenAPI specs. tools.read would let viewers probe internal
11779+
# services; tools.create scopes it to users who can already register tools.
1177711780
@require_permission("tools.create", allow_admin_bypass=False)
1177811781
async def generate_schemas_from_openapi(
1177911782
request: Request,
11780-
_db: Session = Depends(get_db),
1178111783
_user=Depends(get_current_user_with_permissions),
1178211784
) -> JSONResponse:
1178311785
"""
@@ -11793,86 +11795,101 @@ async def generate_schemas_from_openapi(
1179311795

1179411796
Returns:
1179511797
JSONResponse with generated schemas or error message.
11796-
11797-
Examples:
11798-
>>> callable(generate_schemas_from_openapi)
11799-
True
1180011798
"""
1180111799
try:
11802-
body = await request.json()
11803-
tool_url = body.get("url", "")
11804-
request_type = body.get("request_type", "GET")
11805-
openapi_url = body.get("openapi_url", "")
11800+
body = await _read_request_json(request)
11801+
except Exception:
11802+
return ORJSONResponse(
11803+
content={"message": "Invalid JSON in request body", "success": False},
11804+
status_code=400,
11805+
)
1180611806

11807-
if not tool_url and not openapi_url:
11808-
return ORJSONResponse(
11809-
content={"message": "Either 'url' or 'openapi_url' is required", "success": False},
11810-
status_code=400,
11811-
)
11807+
if not isinstance(body, dict):
11808+
return ORJSONResponse(
11809+
content={"message": "Request body must be a JSON object", "success": False},
11810+
status_code=400,
11811+
)
1181211812

11813-
# Determine base URL and path
11814-
parsed = urllib.parse.urlparse(tool_url)
11815-
base_url = f"{parsed.scheme}://{parsed.netloc}"
11816-
tool_path = parsed.path
11813+
tool_url = body.get("url", "")
11814+
request_type = body.get("request_type", "GET")
11815+
openapi_url = body.get("openapi_url", "")
1181711816

11818-
# Use the service to fetch and extract schemas with SSRF protection
11819-
try:
11820-
input_schema, output_schema, spec_url = await fetch_and_extract_schemas(
11821-
base_url=base_url,
11822-
path=tool_path,
11823-
method=request_type,
11824-
openapi_url=openapi_url,
11825-
timeout=10.0,
11826-
)
11827-
except ValueError as e:
11828-
# Security validation failed
11829-
return ORJSONResponse(
11830-
content={"message": f"Security validation failed: {str(e)}", "success": False},
11831-
status_code=400,
11832-
)
11833-
except KeyError as e:
11834-
# Path or method not found in spec
11835-
return ORJSONResponse(
11836-
content={"message": str(e), "success": False},
11837-
status_code=404,
11838-
)
11839-
except httpx.HTTPError as e:
11840-
# HTTP request failed
11841-
return ORJSONResponse(
11842-
content={"message": f"Failed to fetch OpenAPI spec: {str(e)}", "success": False},
11843-
status_code=404,
11844-
)
11845-
except Exception as e:
11846-
# Other errors
11847-
LOGGER.error(f"Error fetching OpenAPI spec: {str(e)}", exc_info=True)
11848-
return ORJSONResponse(
11849-
content={"message": f"Error: {str(e)}", "success": False},
11850-
status_code=500,
11851-
)
11817+
if not isinstance(tool_url, str) or not isinstance(request_type, str) or not isinstance(openapi_url, str):
11818+
return ORJSONResponse(
11819+
content={"message": "'url', 'request_type', and 'openapi_url' must be strings", "success": False},
11820+
status_code=400,
11821+
)
1185211822

11823+
tool_url = tool_url.strip()
11824+
request_type = request_type.strip()
11825+
openapi_url = openapi_url.strip()
11826+
11827+
if not tool_url:
1185311828
return ORJSONResponse(
11854-
content={
11855-
"message": "Schemas generated successfully from OpenAPI spec",
11856-
"success": True,
11857-
"input_schema": input_schema,
11858-
"output_schema": output_schema,
11859-
"spec_url": spec_url,
11860-
},
11861-
status_code=200,
11829+
content={"message": "'url' is required to identify the API path and base URL", "success": False},
11830+
status_code=400,
1186211831
)
1186311832

11864-
except orjson.JSONDecodeError:
11833+
try:
11834+
SecurityValidator.validate_url(tool_url, "Tool URL")
11835+
except ValueError as e:
1186511836
return ORJSONResponse(
11866-
content={"message": "Invalid JSON in request body", "success": False},
11837+
content={"message": str(e), "success": False},
1186711838
status_code=400,
1186811839
)
11869-
except Exception as ex:
11870-
LOGGER.error(f"Error generating schemas from OpenAPI: {str(ex)}", exc_info=True)
11840+
11841+
parsed = urllib.parse.urlparse(tool_url)
11842+
base_url = f"{parsed.scheme}://{parsed.netloc}"
11843+
tool_path = parsed.path
11844+
11845+
try:
11846+
input_schema, output_schema, spec_url = await fetch_and_extract_schemas(
11847+
base_url=base_url,
11848+
path=tool_path,
11849+
method=request_type,
11850+
openapi_url=openapi_url,
11851+
timeout=10.0,
11852+
)
11853+
except ValueError as e:
11854+
return ORJSONResponse(
11855+
content={"message": f"Security validation failed: {str(e)}", "success": False},
11856+
status_code=400,
11857+
)
11858+
except KeyError as e:
1187111859
return ORJSONResponse(
11872-
content={"message": f"Error: {str(ex)}", "success": False},
11860+
content={"message": str(e), "success": False},
11861+
status_code=404,
11862+
)
11863+
except httpx.HTTPStatusError as e:
11864+
LOGGER.warning("OpenAPI spec server returned HTTP %s", e.response.status_code, exc_info=True)
11865+
return ORJSONResponse(
11866+
content={"message": f"OpenAPI spec server returned HTTP {e.response.status_code}", "success": False},
11867+
status_code=502,
11868+
)
11869+
except httpx.HTTPError:
11870+
LOGGER.warning("Failed to fetch OpenAPI spec", exc_info=True)
11871+
return ORJSONResponse(
11872+
content={"message": "Failed to fetch OpenAPI spec from the provided URL", "success": False},
11873+
status_code=502,
11874+
)
11875+
except Exception:
11876+
LOGGER.error("Error fetching OpenAPI spec", exc_info=True)
11877+
return ORJSONResponse(
11878+
content={"message": "An unexpected error occurred while processing the OpenAPI spec", "success": False},
1187311879
status_code=500,
1187411880
)
1187511881

11882+
return ORJSONResponse(
11883+
content={
11884+
"message": "Schemas generated successfully from OpenAPI spec",
11885+
"success": True,
11886+
"input_schema": input_schema,
11887+
"output_schema": output_schema,
11888+
"spec_url": spec_url,
11889+
},
11890+
status_code=200,
11891+
)
11892+
1187611893

1187711894
@admin_router.post("/tools/{tool_id}/delete")
1187811895
@require_permission("tools.delete", allow_admin_bypass=False)

0 commit comments

Comments
 (0)