Skip to content

Commit 78ca5a1

Browse files
committed
Added testing and doctest
Signed-off-by: Mihai Criveti <[email protected]>
1 parent 45e587a commit 78ca5a1

File tree

4 files changed

+623
-28
lines changed

4 files changed

+623
-28
lines changed

mcpgateway/static/admin.js

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2390,10 +2390,16 @@ async function editGateway(gatewayId) {
23902390
}
23912391

23922392
// Handle passthrough headers
2393-
const passthroughHeadersField = safeGetElement("edit-gateway-passthrough-headers");
2393+
const passthroughHeadersField = safeGetElement(
2394+
"edit-gateway-passthrough-headers",
2395+
);
23942396
if (passthroughHeadersField) {
2395-
if (gateway.passthroughHeaders && Array.isArray(gateway.passthroughHeaders)) {
2396-
passthroughHeadersField.value = gateway.passthroughHeaders.join(", ");
2397+
if (
2398+
gateway.passthroughHeaders &&
2399+
Array.isArray(gateway.passthroughHeaders)
2400+
) {
2401+
passthroughHeadersField.value =
2402+
gateway.passthroughHeaders.join(", ");
23972403
} else {
23982404
passthroughHeadersField.value = "";
23992405
}
@@ -3671,16 +3677,24 @@ async function runToolTest() {
36713677
"Content-Type": "application/json",
36723678
};
36733679

3674-
const passthroughHeadersField = document.getElementById("test-passthrough-headers");
3680+
const passthroughHeadersField = document.getElementById(
3681+
"test-passthrough-headers",
3682+
);
36753683
if (passthroughHeadersField && passthroughHeadersField.value.trim()) {
3676-
const headerLines = passthroughHeadersField.value.trim().split('\n');
3684+
const headerLines = passthroughHeadersField.value
3685+
.trim()
3686+
.split("\n");
36773687
for (const line of headerLines) {
36783688
const trimmedLine = line.trim();
36793689
if (trimmedLine) {
3680-
const colonIndex = trimmedLine.indexOf(':');
3690+
const colonIndex = trimmedLine.indexOf(":");
36813691
if (colonIndex > 0) {
3682-
const headerName = trimmedLine.substring(0, colonIndex).trim();
3683-
const headerValue = trimmedLine.substring(colonIndex + 1).trim();
3692+
const headerName = trimmedLine
3693+
.substring(0, colonIndex)
3694+
.trim();
3695+
const headerValue = trimmedLine
3696+
.substring(colonIndex + 1)
3697+
.trim();
36843698
if (headerName && headerValue) {
36853699
requestHeaders[headerName] = headerValue;
36863700
}
@@ -4422,13 +4436,16 @@ async function handleGatewayFormSubmit(e) {
44224436
if (passthroughHeadersString && passthroughHeadersString.trim()) {
44234437
// Split by comma and clean up each header name
44244438
const passthroughHeaders = passthroughHeadersString
4425-
.split(',')
4426-
.map(header => header.trim())
4427-
.filter(header => header.length > 0);
4439+
.split(",")
4440+
.map((header) => header.trim())
4441+
.filter((header) => header.length > 0);
44284442

44294443
// Remove the original string and add as JSON array
44304444
formData.delete("passthrough_headers");
4431-
formData.append("passthrough_headers", JSON.stringify(passthroughHeaders));
4445+
formData.append(
4446+
"passthrough_headers",
4447+
JSON.stringify(passthroughHeaders),
4448+
);
44324449
}
44334450

44344451
const response = await fetchWithTimeout(
@@ -4843,12 +4860,16 @@ async function handleEditGatewayFormSubmit(e) {
48434860
}
48444861

48454862
// Handle passthrough headers
4846-
const passthroughHeadersString = formData.get("passthrough_headers") || "";
4863+
const passthroughHeadersString =
4864+
formData.get("passthrough_headers") || "";
48474865
const passthroughHeaders = passthroughHeadersString
4848-
.split(',')
4849-
.map(header => header.trim())
4850-
.filter(header => header.length > 0);
4851-
formData.append("passthrough_headers", JSON.stringify(passthroughHeaders));
4866+
.split(",")
4867+
.map((header) => header.trim())
4868+
.filter((header) => header.length > 0);
4869+
formData.append(
4870+
"passthrough_headers",
4871+
JSON.stringify(passthroughHeaders),
4872+
);
48524873

48534874
const isInactiveCheckedBool = isInactiveChecked("gateways");
48544875
formData.append("is_inactive_checked", isInactiveCheckedBool);

mcpgateway/templates/admin.html

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1904,7 +1904,9 @@ <h3 class="text-lg font-bold mb-4 dark:text-gray-200">
19041904
>Passthrough Headers</label
19051905
>
19061906
<small class="text-gray-500 dark:text-gray-400 block mb-2">
1907-
List of headers to pass through from client requests (comma-separated, e.g., "Authorization, X-Tenant-Id, X-Trace-Id"). Leave empty to use global defaults.
1907+
List of headers to pass through from client requests
1908+
(comma-separated, e.g., "Authorization, X-Tenant-Id,
1909+
X-Trace-Id"). Leave empty to use global defaults.
19081910
</small>
19091911
<input
19101912
type="text"
@@ -2233,11 +2235,15 @@ <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
22332235
</div>
22342236
<div class="mt-4 border-t pt-4">
22352237
<div>
2236-
<label for="test-passthrough-headers" class="block text-sm font-medium text-gray-700 dark:text-gray-400">
2238+
<label
2239+
for="test-passthrough-headers"
2240+
class="block text-sm font-medium text-gray-700 dark:text-gray-400"
2241+
>
22372242
Passthrough Headers (Optional)
22382243
</label>
22392244
<small class="text-gray-500 dark:text-gray-400 block mb-2">
2240-
Additional headers to send with the request (format: "Header-Name: Value", one per line)
2245+
Additional headers to send with the request (format:
2246+
"Header-Name: Value", one per line)
22412247
</small>
22422248
<textarea
22432249
id="test-passthrough-headers"
@@ -3102,11 +3108,16 @@ <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
31023108
</div>
31033109
</div>
31043110
<div>
3105-
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400"
3111+
<label
3112+
class="block text-sm font-medium text-gray-700 dark:text-gray-400"
31063113
>Passthrough Headers</label
31073114
>
3108-
<small class="text-gray-500 dark:text-gray-400 block mb-2">
3109-
List of headers to pass through from client requests (comma-separated, e.g., "Authorization, X-Tenant-Id, X-Trace-Id"). Leave empty to use global defaults.
3115+
<small
3116+
class="text-gray-500 dark:text-gray-400 block mb-2"
3117+
>
3118+
List of headers to pass through from client requests
3119+
(comma-separated, e.g., "Authorization, X-Tenant-Id,
3120+
X-Trace-Id"). Leave empty to use global defaults.
31103121
</small>
31113122
<input
31123123
type="text"

mcpgateway/utils/passthrough_headers.py

Lines changed: 143 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,43 @@
11
# -*- coding: utf-8 -*-
2+
"""HTTP Header Passthrough Utilities.
3+
4+
Copyright 2025
5+
SPDX-License-Identifier: Apache-2.0
6+
Authors: Mihai Criveti
7+
8+
This module provides utilities for handling HTTP header passthrough functionality
9+
in the MCP Gateway. It enables forwarding of specific headers from incoming
10+
client requests to backing MCP servers while preventing conflicts with
11+
existing authentication mechanisms.
12+
13+
Key Features:
14+
- Global configuration support via environment variables and database
15+
- Per-gateway header configuration overrides
16+
- Intelligent conflict detection with existing authentication headers
17+
- Security-first approach with explicit allowlist handling
18+
- Comprehensive logging for debugging and monitoring
19+
20+
The header passthrough system follows a priority hierarchy:
21+
1. Gateway-specific headers (highest priority)
22+
2. Global database configuration
23+
3. Environment variable defaults (lowest priority)
24+
25+
Example Usage:
26+
Basic header passthrough with global configuration:
27+
>>> from unittest.mock import Mock
28+
>>> mock_db = Mock()
29+
>>> mock_global_config = Mock()
30+
>>> mock_global_config.passthrough_headers = ["X-Tenant-Id"]
31+
>>> mock_db.query.return_value.first.return_value = mock_global_config
32+
>>> headers = get_passthrough_headers(
33+
... request_headers={"x-tenant-id": "123"},
34+
... base_headers={"Content-Type": "application/json"},
35+
... db=mock_db
36+
... )
37+
>>> sorted(headers.items())
38+
[('Content-Type', 'application/json'), ('X-Tenant-Id', '123')]
39+
"""
40+
241
# Standard
342
import logging
443
from typing import Dict, Optional
@@ -17,14 +56,113 @@
1756
def get_passthrough_headers(request_headers: Dict[str, str], base_headers: Dict[str, str], db: Session, gateway: Optional[DbGateway] = None) -> Dict[str, str]:
1857
"""Get headers that should be passed through to the target gateway.
1958
59+
This function implements the core logic for HTTP header passthrough in the MCP Gateway.
60+
It determines which headers from incoming client requests should be forwarded to
61+
backing MCP servers based on configuration settings and security policies.
62+
63+
Configuration Priority (highest to lowest):
64+
1. Gateway-specific passthrough_headers setting
65+
2. Global database configuration (GlobalConfig.passthrough_headers)
66+
3. Environment variable DEFAULT_PASSTHROUGH_HEADERS
67+
68+
Security Features:
69+
- Prevents conflicts with existing base headers (e.g., Content-Type)
70+
- Blocks Authorization header conflicts with gateway authentication
71+
- Logs all conflicts and skipped headers for debugging
72+
- Uses case-insensitive header matching for robustness
73+
2074
Args:
21-
request_headers: Headers from the incoming request
22-
base_headers: Base headers that should always be included
23-
gateway: Target gateway (optional)
24-
db: Database session for global config lookup
75+
request_headers (Dict[str, str]): Headers from the incoming HTTP request.
76+
Keys should be header names, values should be header values.
77+
Example: {"Authorization": "Bearer token123", "X-Tenant-Id": "acme"}
78+
base_headers (Dict[str, str]): Base headers that should always be included
79+
in the final result. These take precedence over passthrough headers.
80+
Example: {"Content-Type": "application/json", "User-Agent": "MCPGateway/1.0"}
81+
db (Session): SQLAlchemy database session for querying global configuration.
82+
Used to retrieve GlobalConfig.passthrough_headers setting.
83+
gateway (Optional[DbGateway]): Target gateway instance. If provided, uses
84+
gateway.passthrough_headers to override global settings. Also checks
85+
gateway.auth_type to prevent Authorization header conflicts.
2586
2687
Returns:
27-
Dict of headers that should be passed through
88+
Dict[str, str]: Combined dictionary of base headers plus allowed passthrough
89+
headers from the request. Base headers are preserved, and passthrough
90+
headers are added only if they don't conflict with security policies.
91+
92+
Raises:
93+
No exceptions are raised. Errors are logged as warnings and processing continues.
94+
Database connection issues may propagate from the db.query() call.
95+
96+
Examples:
97+
Basic usage with global configuration:
98+
>>> # Mock database and settings for doctest
99+
>>> from unittest.mock import Mock, MagicMock
100+
>>> mock_db = Mock()
101+
>>> mock_global_config = Mock()
102+
>>> mock_global_config.passthrough_headers = ["X-Tenant-Id", "X-Trace-Id"]
103+
>>> mock_db.query.return_value.first.return_value = mock_global_config
104+
>>>
105+
>>> request_headers = {
106+
... "authorization": "Bearer token123",
107+
... "x-tenant-id": "acme-corp",
108+
... "x-trace-id": "trace-456",
109+
... "user-agent": "TestClient/1.0"
110+
... }
111+
>>> base_headers = {"Content-Type": "application/json"}
112+
>>>
113+
>>> result = get_passthrough_headers(request_headers, base_headers, mock_db)
114+
>>> sorted(result.items())
115+
[('Content-Type', 'application/json'), ('X-Tenant-Id', 'acme-corp'), ('X-Trace-Id', 'trace-456')]
116+
117+
Gateway-specific configuration override:
118+
>>> mock_gateway = Mock()
119+
>>> mock_gateway.passthrough_headers = ["X-Custom-Header"]
120+
>>> mock_gateway.auth_type = None
121+
>>> request_headers = {
122+
... "x-custom-header": "custom-value",
123+
... "x-tenant-id": "should-be-ignored"
124+
... }
125+
>>>
126+
>>> result = get_passthrough_headers(request_headers, base_headers, mock_db, mock_gateway)
127+
>>> sorted(result.items())
128+
[('Content-Type', 'application/json'), ('X-Custom-Header', 'custom-value')]
129+
130+
Authorization header conflict with basic auth:
131+
>>> mock_gateway.auth_type = "basic"
132+
>>> mock_gateway.passthrough_headers = ["Authorization", "X-Tenant-Id"]
133+
>>> request_headers = {
134+
... "authorization": "Bearer should-be-blocked",
135+
... "x-tenant-id": "acme-corp"
136+
... }
137+
>>>
138+
>>> result = get_passthrough_headers(request_headers, base_headers, mock_db, mock_gateway)
139+
>>> sorted(result.items()) # Authorization blocked due to basic auth conflict
140+
[('Content-Type', 'application/json'), ('X-Tenant-Id', 'acme-corp')]
141+
142+
Base header conflict prevention:
143+
>>> base_headers_with_conflict = {"Content-Type": "application/json", "x-tenant-id": "from-base"}
144+
>>> request_headers = {"x-tenant-id": "from-request"}
145+
>>> mock_gateway.auth_type = None
146+
>>> mock_gateway.passthrough_headers = ["X-Tenant-Id"]
147+
>>>
148+
>>> result = get_passthrough_headers(request_headers, base_headers_with_conflict, mock_db, mock_gateway)
149+
>>> result["x-tenant-id"] # Base header preserved, request header blocked
150+
'from-base'
151+
152+
Empty allowed headers (no passthrough):
153+
>>> empty_global_config = Mock()
154+
>>> empty_global_config.passthrough_headers = []
155+
>>> mock_db.query.return_value.first.return_value = empty_global_config
156+
>>>
157+
>>> request_headers = {"x-tenant-id": "should-be-ignored"}
158+
>>> result = get_passthrough_headers(request_headers, {"Content-Type": "application/json"}, mock_db)
159+
>>> result
160+
{'Content-Type': 'application/json'}
161+
162+
Note:
163+
Header names are matched case-insensitively but preserved in their original
164+
case from the allowed_headers configuration. Request header values are
165+
matched case-insensitively against the request_headers dictionary.
28166
"""
29167
passthrough_headers = base_headers.copy()
30168

0 commit comments

Comments
 (0)