Skip to content

Commit 5be0dbc

Browse files
martinalexandruibmcrivetimihaijonpspriclaude
authored andcommitted
fix(api): apply query and header mappings on tool invocation (#1405) (#3369)
* fix(api): apply query and header mappings on tool invocation (#1405) When a tool has query_mapping or header_mapping configured, apply those mappings during REST tool invocation so that argument fields are correctly translated into query parameters and HTTP headers. Closes #1405 Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(test): add unit tests for apply_mapping_into_target and fix regressions - Add 9 unit tests for the apply_mapping_into_target utility covering key renaming, target merging, overwrite semantics, None/empty mapping handling, unmapped key exclusion, and input immutability - Fix 2 TestRustMcpExecutionPlan tests broken by missing query_mapping and header_mapping attributes on mock tool SimpleNamespace objects - Add assertions verifying query_mapping and header_mapping are included in _build_tool_cache_payload output - Remove spurious query_mapping/header_mapping from gateway mock (gateways do not have these fields) - Apply Black formatting to apply_mapping_into_target Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(api): harden query/header mapping with security validation, type guards, and tests Tighten Dict[str, Any] to Dict[str, str] for query_mapping and header_mapping in schemas, DB model, and Pydantic validation. Add schema-level size constraints (max 50 entries, 128 char keys/values) on ToolCreate and ToolUpdate. At invocation time, validate header mapping targets against sensitive header patterns (Authorization, Proxy-Authorization, X-API-Key, etc.) and RFC 7230 header name syntax to prevent auth header overwrite and CRLF injection. Add isinstance guards with dict-content validation matching the tool_oauth_config pattern, and wrap mapping call sites in try/except for clear error messages when mappings are corrupt. Includes debug-level logging for unmapped keys, improved docstrings and inline comments, mock_tool fixture defaults, and test coverage for GET method, empty-dict mappings, URL-template param availability in headers, sensitive header rejection, CRLF injection rejection, and content validation. Closes #1405 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Jonathan Springer <jps@s390x.com> --------- Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> Signed-off-by: Jonathan Springer <jps@s390x.com> Co-authored-by: Mihai Criveti <crivetimihai@gmail.com> Co-authored-by: Jonathan Springer <jps@s390x.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2b2b1a1 commit 5be0dbc

5 files changed

Lines changed: 685 additions & 33 deletions

File tree

.secrets.baseline

Lines changed: 23 additions & 23 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-12T14:33:23Z",
6+
"generated_at": "2026-04-13T06:27:05Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"
@@ -384,39 +384,39 @@
384384
"hashed_secret": "d3ac7a4ef1a838b4134f2f6e7f3c0d249d74b674",
385385
"is_secret": false,
386386
"is_verified": false,
387-
"line_number": 6141,
387+
"line_number": 6145,
388388
"type": "Secret Keyword",
389389
"verified_result": null
390390
},
391391
{
392392
"hashed_secret": "5932862bcd24dd27d0dc0407ec94fe9d6ea24aeb",
393393
"is_secret": false,
394394
"is_verified": false,
395-
"line_number": 6638,
395+
"line_number": 6642,
396396
"type": "Secret Keyword",
397397
"verified_result": null
398398
},
399399
{
400400
"hashed_secret": "c77c805e32f173e4321ee9187de9c29cb3804513",
401401
"is_secret": false,
402402
"is_verified": false,
403-
"line_number": 6650,
403+
"line_number": 6654,
404404
"type": "Secret Keyword",
405405
"verified_result": null
406406
},
407407
{
408408
"hashed_secret": "8fe3df8a68ddd0d4ab2214186cbb8e38ccd0e06a",
409409
"is_secret": false,
410410
"is_verified": false,
411-
"line_number": 6722,
411+
"line_number": 6726,
412412
"type": "Secret Keyword",
413413
"verified_result": null
414414
},
415415
{
416416
"hashed_secret": "93ac8946882128457cd9e283b30ca851945e6690",
417417
"is_secret": false,
418418
"is_verified": false,
419-
"line_number": 7793,
419+
"line_number": 7797,
420420
"type": "Secret Keyword",
421421
"verified_result": null
422422
}
@@ -5962,47 +5962,47 @@
59625962
"hashed_secret": "c377074d6473f35a91001981355da793dc808ffd",
59635963
"is_secret": false,
59645964
"is_verified": false,
5965-
"line_number": 4111,
5965+
"line_number": 4197,
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": 5224,
5973+
"line_number": 5310,
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": 5388,
5981+
"line_number": 5474,
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": 5445,
5989+
"line_number": 5531,
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": 5483,
5997+
"line_number": 5569,
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": 5484,
6005+
"line_number": 5570,
60066006
"type": "Secret Keyword",
60076007
"verified_result": null
60086008
}
@@ -9682,87 +9682,87 @@
96829682
"hashed_secret": "d63b39580934e062f89aae63426d2f2c77c3e258",
96839683
"is_secret": false,
96849684
"is_verified": false,
9685-
"line_number": 504,
9685+
"line_number": 509,
96869686
"type": "Base64 High Entropy String",
96879687
"verified_result": null
96889688
},
96899689
{
96909690
"hashed_secret": "586a55a9b8b97f0cd88e24ce8279ebc955949688",
96919691
"is_secret": false,
96929692
"is_verified": false,
9693-
"line_number": 505,
9693+
"line_number": 510,
96949694
"type": "Secret Keyword",
96959695
"verified_result": null
96969696
},
96979697
{
96989698
"hashed_secret": "00cafd126182e8a9e7c01bb2f0dfd00496be724f",
96999699
"is_secret": false,
97009700
"is_verified": false,
9701-
"line_number": 521,
9701+
"line_number": 526,
97029702
"type": "Secret Keyword",
97039703
"verified_result": null
97049704
},
97059705
{
97069706
"hashed_secret": "7b1552c7c7ffb8bd70b5666e5997c8e017630aab",
97079707
"is_secret": false,
97089708
"is_verified": false,
9709-
"line_number": 1936,
9709+
"line_number": 1941,
97109710
"type": "Base64 High Entropy String",
97119711
"verified_result": null
97129712
},
97139713
{
97149714
"hashed_secret": "9fb7fe1217aed442b04c0f5e43b5d5a7d3287097",
97159715
"is_secret": false,
97169716
"is_verified": false,
9717-
"line_number": 2872,
9717+
"line_number": 2877,
97189718
"type": "Secret Keyword",
97199719
"verified_result": null
97209720
},
97219721
{
97229722
"hashed_secret": "72cb70dbbafe97e5ea13ad88acd65d08389439b0",
97239723
"is_secret": false,
97249724
"is_verified": false,
9725-
"line_number": 3500,
9725+
"line_number": 3505,
97269726
"type": "Secret Keyword",
97279727
"verified_result": null
97289728
},
97299729
{
97309730
"hashed_secret": "ee977806d7286510da8b9a7492ba58e2484c0ecc",
97319731
"is_secret": false,
97329732
"is_verified": false,
9733-
"line_number": 5793,
9733+
"line_number": 6259,
97349734
"type": "Secret Keyword",
97359735
"verified_result": null
97369736
},
97379737
{
97389738
"hashed_secret": "f2e7745f43b0ef0e2c2faf61d6c6a28be2965750",
97399739
"is_secret": false,
97409740
"is_verified": false,
9741-
"line_number": 6285,
9741+
"line_number": 6751,
97429742
"type": "Secret Keyword",
97439743
"verified_result": null
97449744
},
97459745
{
97469746
"hashed_secret": "4a249743d4d2241bd2ae085b4fe654d089488295",
97479747
"is_secret": false,
97489748
"is_verified": false,
9749-
"line_number": 7632,
9749+
"line_number": 8098,
97509750
"type": "Secret Keyword",
97519751
"verified_result": null
97529752
},
97539753
{
97549754
"hashed_secret": "0c8d051d3c7eada5d31b53d9936fce6bcc232ae2",
97559755
"is_secret": false,
97569756
"is_verified": false,
9757-
"line_number": 7770,
9757+
"line_number": 8240,
97589758
"type": "Secret Keyword",
97599759
"verified_result": null
97609760
},
97619761
{
97629762
"hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511",
97639763
"is_secret": false,
97649764
"is_verified": false,
9765-
"line_number": 8146,
9765+
"line_number": 8616,
97669766
"type": "Secret Keyword",
97679767
"verified_result": null
97689768
}

mcpgateway/db.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3231,8 +3231,8 @@ class Tool(Base):
32313231
# Passthrough REST fields
32323232
base_url: Mapped[Optional[str]] = mapped_column(String, nullable=True)
32333233
path_template: Mapped[Optional[str]] = mapped_column(String, nullable=True)
3234-
query_mapping: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
3235-
header_mapping: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
3234+
query_mapping: Mapped[Optional[Dict[str, str]]] = mapped_column(JSON, nullable=True)
3235+
header_mapping: Mapped[Optional[Dict[str, str]]] = mapped_column(JSON, nullable=True)
32363236
timeout_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, default=None)
32373237
expose_passthrough: Mapped[bool] = mapped_column(Boolean, default=True)
32383238
allowlist: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True)

mcpgateway/schemas.py

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,68 @@
5858

5959
_VALID_VISIBILITY = {"private", "team", "public"}
6060

61+
_MAX_MAPPING_ENTRIES = 50
62+
_MAX_MAPPING_KEY_LENGTH = 128
63+
64+
_VALID_HTTP_HEADER_NAME = re.compile(r"^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$")
65+
_BLOCKED_HEADER_MAPPING_TARGETS = frozenset(
66+
name.lower()
67+
for name in (
68+
"authorization",
69+
"proxy-authorization",
70+
"cookie",
71+
"set-cookie",
72+
"host",
73+
"transfer-encoding",
74+
"content-length",
75+
"connection",
76+
"upgrade",
77+
)
78+
)
79+
_SENSITIVE_HEADER_MAPPING_PATTERNS = (
80+
re.compile(r"^x-api-key$", re.IGNORECASE),
81+
re.compile(r"^api-key$", re.IGNORECASE),
82+
re.compile(r"^apikey$", re.IGNORECASE),
83+
re.compile(r"^x-(?:auth|api|access|refresh|client|bearer|session|security)[-_]?(?:token|secret|key)$", re.IGNORECASE),
84+
re.compile(r"^(?:auth|api|access|refresh|client|bearer|session|security)[-_]?(?:token|secret|key)$", re.IGNORECASE),
85+
)
86+
87+
88+
def _validate_mapping_size(v: dict | None) -> dict | None:
89+
"""Validate that a mapping dict does not exceed size limits.
90+
91+
Shared by ToolCreate and ToolUpdate field validators.
92+
"""
93+
if v is None:
94+
return v
95+
if len(v) > _MAX_MAPPING_ENTRIES:
96+
raise ValueError(f"Mapping must not contain more than {_MAX_MAPPING_ENTRIES} entries")
97+
for k, val in v.items():
98+
if len(k) > _MAX_MAPPING_KEY_LENGTH:
99+
raise ValueError(f"Mapping key exceeds {_MAX_MAPPING_KEY_LENGTH} characters: '{k[:32]}...'")
100+
if len(val) > _MAX_MAPPING_KEY_LENGTH:
101+
raise ValueError(f"Mapping value exceeds {_MAX_MAPPING_KEY_LENGTH} characters: '{val[:32]}...'")
102+
return v
103+
104+
105+
def _validate_header_mapping_targets(v: dict | None) -> dict | None:
106+
"""Validate that header_mapping target names are safe and well-formed.
107+
108+
Rejects sensitive headers (Authorization, Cookie, Host, etc.) and
109+
names that violate RFC 7230 token syntax. Applied at registration time;
110+
tool_service applies the same checks at invocation as defense-in-depth.
111+
"""
112+
if v is None:
113+
return v
114+
for target in v.values():
115+
if target.strip().lower() in _BLOCKED_HEADER_MAPPING_TARGETS:
116+
raise ValueError(f"header_mapping targets blocked header {repr(target[:64])}")
117+
if any(p.match(target) for p in _SENSITIVE_HEADER_MAPPING_PATTERNS):
118+
raise ValueError(f"header_mapping targets sensitive header {repr(target[:64])}")
119+
if not _VALID_HTTP_HEADER_NAME.match(target):
120+
raise ValueError(f"header_mapping contains invalid header name {repr(target[:64])}")
121+
return v
122+
61123

62124
def _coerce_visibility(v: Optional[str]) -> Optional[str]:
63125
"""Normalize legacy visibility values in Read/response schemas.
@@ -415,8 +477,8 @@ class ToolCreate(BaseModel):
415477
# Passthrough REST fields
416478
base_url: Optional[str] = Field(None, description="Base URL for REST passthrough")
417479
path_template: Optional[str] = Field(None, description="Path template for REST passthrough")
418-
query_mapping: Optional[Dict[str, Any]] = Field(None, description="Query mapping for REST passthrough")
419-
header_mapping: Optional[Dict[str, Any]] = Field(None, description="Header mapping for REST passthrough")
480+
query_mapping: Optional[Dict[str, str]] = Field(None, description="Query mapping for REST passthrough")
481+
header_mapping: Optional[Dict[str, str]] = Field(None, description="Header mapping for REST passthrough")
420482
timeout_ms: Optional[int] = Field(default=None, description="Timeout in milliseconds for REST passthrough (20000 if integration_type='REST', else None)")
421483
expose_passthrough: Optional[bool] = Field(True, description="Expose passthrough endpoint for this tool")
422484
allowlist: Optional[List[str]] = Field(None, description="Allowed upstream hosts/schemes for passthrough")
@@ -944,6 +1006,18 @@ def validate_plugin_chain(cls, v):
9441006
raise ValueError(f"Unknown plugin: {plugin}")
9451007
return v
9461008

1009+
@field_validator("query_mapping", "header_mapping")
1010+
@classmethod
1011+
def validate_mapping_size(cls, v: dict | None) -> dict | None:
1012+
"""Validate that mapping dicts do not exceed size limits."""
1013+
return _validate_mapping_size(v)
1014+
1015+
@field_validator("header_mapping")
1016+
@classmethod
1017+
def validate_header_mapping_targets(cls, v: dict | None) -> dict | None:
1018+
"""Reject header_mapping targets that are sensitive or malformed."""
1019+
return _validate_header_mapping_targets(v)
1020+
9471021
@model_validator(mode="after")
9481022
def handle_timeout_ms_defaults(self):
9491023
"""Handle timeout_ms defaults based on integration_type and expose_passthrough.
@@ -984,8 +1058,8 @@ class ToolUpdate(BaseModelWithConfigDict):
9841058
# Passthrough REST fields
9851059
base_url: Optional[str] = Field(None, description="Base URL for REST passthrough")
9861060
path_template: Optional[str] = Field(None, description="Path template for REST passthrough")
987-
query_mapping: Optional[Dict[str, Any]] = Field(None, description="Query mapping for REST passthrough")
988-
header_mapping: Optional[Dict[str, Any]] = Field(None, description="Header mapping for REST passthrough")
1061+
query_mapping: Optional[Dict[str, str]] = Field(None, description="Query mapping for REST passthrough")
1062+
header_mapping: Optional[Dict[str, str]] = Field(None, description="Header mapping for REST passthrough")
9891063
timeout_ms: Optional[int] = Field(default=None, description="Timeout in milliseconds for REST passthrough (20000 if integration_type='REST', else None)")
9901064
expose_passthrough: Optional[bool] = Field(True, description="Expose passthrough endpoint for this tool")
9911065
allowlist: Optional[List[str]] = Field(None, description="Allowed upstream hosts/schemes for passthrough")
@@ -1384,6 +1458,18 @@ def validate_plugin_chain(cls, v):
13841458
raise ValueError(f"Unknown plugin: {plugin}")
13851459
return v
13861460

1461+
@field_validator("query_mapping", "header_mapping")
1462+
@classmethod
1463+
def validate_mapping_size(cls, v: dict | None) -> dict | None:
1464+
"""Validate that mapping dicts do not exceed size limits."""
1465+
return _validate_mapping_size(v)
1466+
1467+
@field_validator("header_mapping")
1468+
@classmethod
1469+
def validate_header_mapping_targets(cls, v: dict | None) -> dict | None:
1470+
"""Reject header_mapping targets that are sensitive or malformed."""
1471+
return _validate_header_mapping_targets(v)
1472+
13871473

13881474
class ToolRead(BaseModelWithConfigDict):
13891475
"""Schema for reading tool information.
@@ -1451,8 +1537,8 @@ class ToolRead(BaseModelWithConfigDict):
14511537
# Passthrough REST fields
14521538
base_url: Optional[str] = Field(None, description="Base URL for REST passthrough")
14531539
path_template: Optional[str] = Field(None, description="Path template for REST passthrough")
1454-
query_mapping: Optional[Dict[str, Any]] = Field(None, description="Query mapping for REST passthrough")
1455-
header_mapping: Optional[Dict[str, Any]] = Field(None, description="Header mapping for REST passthrough")
1540+
query_mapping: Optional[Dict[str, str]] = Field(None, description="Query mapping for REST passthrough")
1541+
header_mapping: Optional[Dict[str, str]] = Field(None, description="Header mapping for REST passthrough")
14561542
timeout_ms: Optional[int] = Field(20000, description="Timeout in milliseconds for REST passthrough")
14571543
expose_passthrough: Optional[bool] = Field(True, description="Expose passthrough endpoint for this tool")
14581544
allowlist: Optional[List[str]] = Field(None, description="Allowed upstream hosts/schemes for passthrough")

0 commit comments

Comments
 (0)