Skip to content

Commit 5090875

Browse files
committed
Added testing and doctest
Signed-off-by: Mihai Criveti <[email protected]>
1 parent 9fbe510 commit 5090875

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
@@ -2472,10 +2472,16 @@ async function editGateway(gatewayId) {
24722472
}
24732473

24742474
// Handle passthrough headers
2475-
const passthroughHeadersField = safeGetElement("edit-gateway-passthrough-headers");
2475+
const passthroughHeadersField = safeGetElement(
2476+
"edit-gateway-passthrough-headers",
2477+
);
24762478
if (passthroughHeadersField) {
2477-
if (gateway.passthroughHeaders && Array.isArray(gateway.passthroughHeaders)) {
2478-
passthroughHeadersField.value = gateway.passthroughHeaders.join(", ");
2479+
if (
2480+
gateway.passthroughHeaders &&
2481+
Array.isArray(gateway.passthroughHeaders)
2482+
) {
2483+
passthroughHeadersField.value =
2484+
gateway.passthroughHeaders.join(", ");
24792485
} else {
24802486
passthroughHeadersField.value = "";
24812487
}
@@ -3778,16 +3784,24 @@ async function runToolTest() {
37783784
"Content-Type": "application/json",
37793785
};
37803786

3781-
const passthroughHeadersField = document.getElementById("test-passthrough-headers");
3787+
const passthroughHeadersField = document.getElementById(
3788+
"test-passthrough-headers",
3789+
);
37823790
if (passthroughHeadersField && passthroughHeadersField.value.trim()) {
3783-
const headerLines = passthroughHeadersField.value.trim().split('\n');
3791+
const headerLines = passthroughHeadersField.value
3792+
.trim()
3793+
.split("\n");
37843794
for (const line of headerLines) {
37853795
const trimmedLine = line.trim();
37863796
if (trimmedLine) {
3787-
const colonIndex = trimmedLine.indexOf(':');
3797+
const colonIndex = trimmedLine.indexOf(":");
37883798
if (colonIndex > 0) {
3789-
const headerName = trimmedLine.substring(0, colonIndex).trim();
3790-
const headerValue = trimmedLine.substring(colonIndex + 1).trim();
3799+
const headerName = trimmedLine
3800+
.substring(0, colonIndex)
3801+
.trim();
3802+
const headerValue = trimmedLine
3803+
.substring(colonIndex + 1)
3804+
.trim();
37913805
if (headerName && headerValue) {
37923806
requestHeaders[headerName] = headerValue;
37933807
}
@@ -4546,13 +4560,16 @@ async function handleGatewayFormSubmit(e) {
45464560
if (passthroughHeadersString && passthroughHeadersString.trim()) {
45474561
// Split by comma and clean up each header name
45484562
const passthroughHeaders = passthroughHeadersString
4549-
.split(',')
4550-
.map(header => header.trim())
4551-
.filter(header => header.length > 0);
4563+
.split(",")
4564+
.map((header) => header.trim())
4565+
.filter((header) => header.length > 0);
45524566

45534567
// Remove the original string and add as JSON array
45544568
formData.delete("passthrough_headers");
4555-
formData.append("passthrough_headers", JSON.stringify(passthroughHeaders));
4569+
formData.append(
4570+
"passthrough_headers",
4571+
JSON.stringify(passthroughHeaders),
4572+
);
45564573
}
45574574

45584575
const response = await fetchWithTimeout(
@@ -4967,12 +4984,16 @@ async function handleEditGatewayFormSubmit(e) {
49674984
}
49684985

49694986
// Handle passthrough headers
4970-
const passthroughHeadersString = formData.get("passthrough_headers") || "";
4987+
const passthroughHeadersString =
4988+
formData.get("passthrough_headers") || "";
49714989
const passthroughHeaders = passthroughHeadersString
4972-
.split(',')
4973-
.map(header => header.trim())
4974-
.filter(header => header.length > 0);
4975-
formData.append("passthrough_headers", JSON.stringify(passthroughHeaders));
4990+
.split(",")
4991+
.map((header) => header.trim())
4992+
.filter((header) => header.length > 0);
4993+
formData.append(
4994+
"passthrough_headers",
4995+
JSON.stringify(passthroughHeaders),
4996+
);
49764997

49774998
const isInactiveCheckedBool = isInactiveChecked("gateways");
49784999
formData.append("is_inactive_checked", isInactiveCheckedBool);

mcpgateway/templates/admin.html

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2221,7 +2221,9 @@ <h3 class="text-lg font-bold mb-4 dark:text-gray-200">
22212221
>Passthrough Headers</label
22222222
>
22232223
<small class="text-gray-500 dark:text-gray-400 block mb-2">
2224-
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.
2224+
List of headers to pass through from client requests
2225+
(comma-separated, e.g., "Authorization, X-Tenant-Id,
2226+
X-Trace-Id"). Leave empty to use global defaults.
22252227
</small>
22262228
<input
22272229
type="text"
@@ -2550,11 +2552,15 @@ <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
25502552
</div>
25512553
<div class="mt-4 border-t pt-4">
25522554
<div>
2553-
<label for="test-passthrough-headers" class="block text-sm font-medium text-gray-700 dark:text-gray-400">
2555+
<label
2556+
for="test-passthrough-headers"
2557+
class="block text-sm font-medium text-gray-700 dark:text-gray-400"
2558+
>
25542559
Passthrough Headers (Optional)
25552560
</label>
25562561
<small class="text-gray-500 dark:text-gray-400 block mb-2">
2557-
Additional headers to send with the request (format: "Header-Name: Value", one per line)
2562+
Additional headers to send with the request (format:
2563+
"Header-Name: Value", one per line)
25582564
</small>
25592565
<textarea
25602566
id="test-passthrough-headers"
@@ -3489,11 +3495,16 @@ <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
34893495
</div>
34903496
</div>
34913497
<div>
3492-
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400"
3498+
<label
3499+
class="block text-sm font-medium text-gray-700 dark:text-gray-400"
34933500
>Passthrough Headers</label
34943501
>
3495-
<small class="text-gray-500 dark:text-gray-400 block mb-2">
3496-
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.
3502+
<small
3503+
class="text-gray-500 dark:text-gray-400 block mb-2"
3504+
>
3505+
List of headers to pass through from client requests
3506+
(comma-separated, e.g., "Authorization, X-Tenant-Id,
3507+
X-Trace-Id"). Leave empty to use global defaults.
34973508
</small>
34983509
<input
34993510
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)