Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Deploy pwc-cli main (c8a789ed6c1151a2bb9fdd65eb49197eb81daf7a)
Browse files- README.md +0 -5
- mcp_server/README.md +10 -0
- mcp_server/SPEC.md +3 -1
- mcp_server/space/README.md +0 -19
- mcp_server/src/pwc_mcp/app.py +54 -2
- mcp_server/tests/test_app.py +105 -0
README.md
CHANGED
|
@@ -17,8 +17,3 @@ leaderboards through Model Context Protocol.
|
|
| 17 |
- MCP endpoint: `/mcp`
|
| 18 |
- Health endpoint: `/health`
|
| 19 |
- Source: <https://github.com/huggingface/pwc-cli>
|
| 20 |
-
- Built from `huggingface/pwc-cli@6a547659644bce05a246a0e20029699dbe2850e1`
|
| 21 |
-
(`pwc-mcp` 0.2.10, the release live at `https://paperswithcode.co/mcp`)
|
| 22 |
-
|
| 23 |
-
Prototype: catalog reads go to the public `https://paperswithcode.co/api/v1`
|
| 24 |
-
and share its anonymous rate limits.
|
|
|
|
| 17 |
- MCP endpoint: `/mcp`
|
| 18 |
- Health endpoint: `/health`
|
| 19 |
- Source: <https://github.com/huggingface/pwc-cli>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mcp_server/README.md
CHANGED
|
@@ -106,6 +106,7 @@ unfiltered leaderboard and a `metric_note` naming the available metrics.
|
|
| 106 |
| `PWC_MCP_SEMANTIC_LIMIT` | Semantic or hybrid searches per client per minute; defaults to `10` |
|
| 107 |
| `PWC_MCP_CONCURRENCY_LIMIT` | Concurrent requests per client; defaults to `4` |
|
| 108 |
| `PWC_MCP_GLOBAL_CONCURRENCY_LIMIT` | Concurrent requests across all clients; defaults to `128` and sizes the tool thread pool |
|
|
|
|
| 109 |
| `LOG_LEVEL` | Content-free operational log level |
|
| 110 |
|
| 111 |
Native clients may omit `Origin`. Browser requests must match the configured
|
|
@@ -115,6 +116,15 @@ hashed chat session); the header counts only on a direct loopback connection
|
|
| 115 |
without `X-Forwarded-For`, so proxied public traffic cannot use it. The server does not log queries, paper references, request bodies,
|
| 116 |
raw IP addresses, or authorization headers.
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
The hosted defaults allow 60 total requests and 10 semantic or hybrid searches per minute
|
| 119 |
per client IP, with at most four concurrent requests per IP and 128 globally.
|
| 120 |
Tool inputs cap list results at 25, catalog calls time out after 25 seconds, and
|
|
|
|
| 106 |
| `PWC_MCP_SEMANTIC_LIMIT` | Semantic or hybrid searches per client per minute; defaults to `10` |
|
| 107 |
| `PWC_MCP_CONCURRENCY_LIMIT` | Concurrent requests per client; defaults to `4` |
|
| 108 |
| `PWC_MCP_GLOBAL_CONCURRENCY_LIMIT` | Concurrent requests across all clients; defaults to `128` and sizes the tool thread pool |
|
| 109 |
+
| `PWC_MCP_TRUSTED_PROXY_CIDRS` | Comma-separated networks of a non-loopback reverse proxy whose `X-Forwarded-For` is trusted; empty by default |
|
| 110 |
| `LOG_LEVEL` | Content-free operational log level |
|
| 111 |
|
| 112 |
Native clients may omit `Origin`. Browser requests must match the configured
|
|
|
|
| 116 |
without `X-Forwarded-For`, so proxied public traffic cannot use it. The server does not log queries, paper references, request bodies,
|
| 117 |
raw IP addresses, or authorization headers.
|
| 118 |
|
| 119 |
+
Behind a reverse proxy that does not run on the same host, such as the Hugging
|
| 120 |
+
Face Spaces ingress, set `PWC_MCP_TRUSTED_PROXY_CIDRS` to the proxy's networks.
|
| 121 |
+
A request whose direct peer falls in one of them is limited by the right-most
|
| 122 |
+
`X-Forwarded-For` hop outside those networks; hops further left are
|
| 123 |
+
client-supplied and ignored. Such requests can never name an identity with
|
| 124 |
+
`X-PwC-MCP-Client`. Loopback peers keep the behaviour above. List only
|
| 125 |
+
networks that hold nothing but the proxy: any peer inside them can choose its
|
| 126 |
+
own rate-limit identity.
|
| 127 |
+
|
| 128 |
The hosted defaults allow 60 total requests and 10 semantic or hybrid searches per minute
|
| 129 |
per client IP, with at most four concurrent requests per IP and 128 globally.
|
| 130 |
Tool inputs cap list results at 25, catalog calls time out after 25 seconds, and
|
mcp_server/SPEC.md
CHANGED
|
@@ -108,7 +108,9 @@ openness, and update time. Metric direction is explicit when known.
|
|
| 108 |
256-entry and 16 MiB ceiling.
|
| 109 |
- Bind to loopback on the VPS, trust forwarded identity only from an exact
|
| 110 |
loopback peer, let a direct loopback client without `X-Forwarded-For` name
|
| 111 |
-
its own rate-limit identity with `X-PwC-MCP-Client`,
|
|
|
|
|
|
|
| 112 |
wait on an upstream call.
|
| 113 |
|
| 114 |
## Release
|
|
|
|
| 108 |
256-entry and 16 MiB ceiling.
|
| 109 |
- Bind to loopback on the VPS, trust forwarded identity only from an exact
|
| 110 |
loopback peer, let a direct loopback client without `X-Forwarded-For` name
|
| 111 |
+
its own rate-limit identity with `X-PwC-MCP-Client`, optionally trust the
|
| 112 |
+
right-most untrusted `X-Forwarded-For` hop from peers in
|
| 113 |
+
`PWC_MCP_TRUSTED_PROXY_CIDRS` (never their `X-PwC-MCP-Client`), and expose cached catalog readiness without making `/health`
|
| 114 |
wait on an upstream call.
|
| 115 |
|
| 116 |
## Release
|
mcp_server/space/README.md
DELETED
|
@@ -1,19 +0,0 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Papers With Code MCP
|
| 3 |
-
emoji: 📚
|
| 4 |
-
colorFrom: yellow
|
| 5 |
-
colorTo: blue
|
| 6 |
-
sdk: docker
|
| 7 |
-
app_port: 7860
|
| 8 |
-
suggested_hardware: cpu-upgrade
|
| 9 |
-
short_description: Read-only MCP access to the Papers With Code research catalog
|
| 10 |
-
---
|
| 11 |
-
|
| 12 |
-
# Papers With Code MCP
|
| 13 |
-
|
| 14 |
-
Anonymous, read-only access to papers, tasks, methods, benchmarks, and
|
| 15 |
-
leaderboards through Model Context Protocol.
|
| 16 |
-
|
| 17 |
-
- MCP endpoint: `/mcp`
|
| 18 |
-
- Health endpoint: `/health`
|
| 19 |
-
- Source: <https://github.com/huggingface/pwc-cli>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mcp_server/src/pwc_mcp/app.py
CHANGED
|
@@ -185,7 +185,42 @@ CLIENT_IDENTITY_HEADER = "x-pwc-mcp-client"
|
|
| 185 |
CLIENT_IDENTITY = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
|
| 186 |
|
| 187 |
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
client = scope.get("client")
|
| 190 |
direct_address = str(client[0]) if client else "unknown"
|
| 191 |
try:
|
|
@@ -199,6 +234,14 @@ def _client_address(scope: Scope, headers: Headers, trust_proxy_headers: bool) -
|
|
| 199 |
identity = headers.get(CLIENT_IDENTITY_HEADER, "")
|
| 200 |
if CLIENT_IDENTITY.fullmatch(identity):
|
| 201 |
return f"client:{identity}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
return direct_address
|
| 203 |
|
| 204 |
|
|
@@ -310,6 +353,7 @@ class RateLimitMiddleware:
|
|
| 310 |
concurrency_limit: int,
|
| 311 |
global_concurrency_limit: int,
|
| 312 |
trust_proxy_headers: bool,
|
|
|
|
| 313 |
identity_limit: int = 10_000,
|
| 314 |
):
|
| 315 |
self.app = app
|
|
@@ -318,6 +362,7 @@ class RateLimitMiddleware:
|
|
| 318 |
self.concurrency_limit = concurrency_limit
|
| 319 |
self.global_concurrency_limit = global_concurrency_limit
|
| 320 |
self.trust_proxy_headers = trust_proxy_headers
|
|
|
|
| 321 |
self.identity_limit = identity_limit
|
| 322 |
self.key = secrets.token_bytes(32)
|
| 323 |
self.requests: dict[str, deque[float]] = defaultdict(deque)
|
|
@@ -373,7 +418,9 @@ class RateLimitMiddleware:
|
|
| 373 |
tool, semantic = _tool_and_semantic(headers, body)
|
| 374 |
scope.setdefault("state", {})["pwc_tool_name"] = tool
|
| 375 |
identity = self._identity(
|
| 376 |
-
_client_address(
|
|
|
|
|
|
|
| 377 |
)
|
| 378 |
now = time.monotonic()
|
| 379 |
limited = False
|
|
@@ -482,6 +529,7 @@ def create_app(
|
|
| 482 |
concurrency_limit: int | None = None,
|
| 483 |
global_concurrency_limit: int | None = None,
|
| 484 |
trust_proxy_headers: bool = True,
|
|
|
|
| 485 |
) -> ASGIApp:
|
| 486 |
if request_limit is None:
|
| 487 |
request_limit = _int_env("PWC_MCP_REQUEST_LIMIT", DEFAULT_REQUEST_LIMIT)
|
|
@@ -495,6 +543,9 @@ def create_app(
|
|
| 495 |
global_concurrency_limit = _int_env(
|
| 496 |
"PWC_MCP_GLOBAL_CONCURRENCY_LIMIT", DEFAULT_GLOBAL_CONCURRENCY_LIMIT
|
| 497 |
)
|
|
|
|
|
|
|
|
|
|
| 498 |
hosts = allowed_hosts or _csv_env(
|
| 499 |
"PWC_MCP_ALLOWED_HOSTS",
|
| 500 |
[
|
|
@@ -544,6 +595,7 @@ def create_app(
|
|
| 544 |
concurrency_limit=concurrency_limit,
|
| 545 |
global_concurrency_limit=global_concurrency_limit,
|
| 546 |
trust_proxy_headers=trust_proxy_headers,
|
|
|
|
| 547 |
)
|
| 548 |
wrapped = MCPMethodMiddleware(wrapped)
|
| 549 |
wrapped = ResponseSizeLimitMiddleware(wrapped)
|
|
|
|
| 185 |
CLIENT_IDENTITY = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
|
| 186 |
|
| 187 |
|
| 188 |
+
# Hosts that put the server behind a non-loopback reverse proxy, such as the
|
| 189 |
+
# Hugging Face Spaces ingress, list the proxy's networks in
|
| 190 |
+
# PWC_MCP_TRUSTED_PROXY_CIDRS. A request from such a peer is attributed to the
|
| 191 |
+
# right-most X-Forwarded-For hop outside those networks: hops to its left are
|
| 192 |
+
# client-supplied and can be forged. A proxied request never names its own
|
| 193 |
+
# identity through CLIENT_IDENTITY_HEADER.
|
| 194 |
+
IPNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _trusted_proxy_networks(value: str) -> tuple[IPNetwork, ...]:
|
| 198 |
+
try:
|
| 199 |
+
return tuple(
|
| 200 |
+
ipaddress.ip_network(item.strip())
|
| 201 |
+
for item in value.split(",")
|
| 202 |
+
if item.strip()
|
| 203 |
+
)
|
| 204 |
+
except ValueError as error:
|
| 205 |
+
raise ValueError(
|
| 206 |
+
f"PWC_MCP_TRUSTED_PROXY_CIDRS must list CIDR networks: {error}"
|
| 207 |
+
) from None
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _in_networks(address: str, networks: tuple[IPNetwork, ...]) -> bool:
|
| 211 |
+
try:
|
| 212 |
+
ip = ipaddress.ip_address(address)
|
| 213 |
+
except ValueError:
|
| 214 |
+
return False
|
| 215 |
+
return any(ip in network for network in networks)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _client_address(
|
| 219 |
+
scope: Scope,
|
| 220 |
+
headers: Headers,
|
| 221 |
+
trust_proxy_headers: bool,
|
| 222 |
+
trusted_proxies: tuple[IPNetwork, ...] = (),
|
| 223 |
+
) -> str:
|
| 224 |
client = scope.get("client")
|
| 225 |
direct_address = str(client[0]) if client else "unknown"
|
| 226 |
try:
|
|
|
|
| 234 |
identity = headers.get(CLIENT_IDENTITY_HEADER, "")
|
| 235 |
if CLIENT_IDENTITY.fullmatch(identity):
|
| 236 |
return f"client:{identity}"
|
| 237 |
+
elif trust_proxy_headers and _in_networks(direct_address, trusted_proxies):
|
| 238 |
+
hops = ",".join(headers.getlist("x-forwarded-for")).split(",")
|
| 239 |
+
for hop in reversed([hop.strip() for hop in hops if hop.strip()]):
|
| 240 |
+
if not _in_networks(hop, trusted_proxies):
|
| 241 |
+
try:
|
| 242 |
+
return str(ipaddress.ip_address(hop))
|
| 243 |
+
except ValueError:
|
| 244 |
+
break
|
| 245 |
return direct_address
|
| 246 |
|
| 247 |
|
|
|
|
| 353 |
concurrency_limit: int,
|
| 354 |
global_concurrency_limit: int,
|
| 355 |
trust_proxy_headers: bool,
|
| 356 |
+
trusted_proxies: tuple[IPNetwork, ...] = (),
|
| 357 |
identity_limit: int = 10_000,
|
| 358 |
):
|
| 359 |
self.app = app
|
|
|
|
| 362 |
self.concurrency_limit = concurrency_limit
|
| 363 |
self.global_concurrency_limit = global_concurrency_limit
|
| 364 |
self.trust_proxy_headers = trust_proxy_headers
|
| 365 |
+
self.trusted_proxies = trusted_proxies
|
| 366 |
self.identity_limit = identity_limit
|
| 367 |
self.key = secrets.token_bytes(32)
|
| 368 |
self.requests: dict[str, deque[float]] = defaultdict(deque)
|
|
|
|
| 418 |
tool, semantic = _tool_and_semantic(headers, body)
|
| 419 |
scope.setdefault("state", {})["pwc_tool_name"] = tool
|
| 420 |
identity = self._identity(
|
| 421 |
+
_client_address(
|
| 422 |
+
scope, headers, self.trust_proxy_headers, self.trusted_proxies
|
| 423 |
+
)
|
| 424 |
)
|
| 425 |
now = time.monotonic()
|
| 426 |
limited = False
|
|
|
|
| 529 |
concurrency_limit: int | None = None,
|
| 530 |
global_concurrency_limit: int | None = None,
|
| 531 |
trust_proxy_headers: bool = True,
|
| 532 |
+
trusted_proxy_cidrs: str | None = None,
|
| 533 |
) -> ASGIApp:
|
| 534 |
if request_limit is None:
|
| 535 |
request_limit = _int_env("PWC_MCP_REQUEST_LIMIT", DEFAULT_REQUEST_LIMIT)
|
|
|
|
| 543 |
global_concurrency_limit = _int_env(
|
| 544 |
"PWC_MCP_GLOBAL_CONCURRENCY_LIMIT", DEFAULT_GLOBAL_CONCURRENCY_LIMIT
|
| 545 |
)
|
| 546 |
+
if trusted_proxy_cidrs is None:
|
| 547 |
+
trusted_proxy_cidrs = os.environ.get("PWC_MCP_TRUSTED_PROXY_CIDRS", "")
|
| 548 |
+
trusted_proxies = _trusted_proxy_networks(trusted_proxy_cidrs)
|
| 549 |
hosts = allowed_hosts or _csv_env(
|
| 550 |
"PWC_MCP_ALLOWED_HOSTS",
|
| 551 |
[
|
|
|
|
| 595 |
concurrency_limit=concurrency_limit,
|
| 596 |
global_concurrency_limit=global_concurrency_limit,
|
| 597 |
trust_proxy_headers=trust_proxy_headers,
|
| 598 |
+
trusted_proxies=trusted_proxies,
|
| 599 |
)
|
| 600 |
wrapped = MCPMethodMiddleware(wrapped)
|
| 601 |
wrapped = ResponseSizeLimitMiddleware(wrapped)
|
mcp_server/tests/test_app.py
CHANGED
|
@@ -5,7 +5,9 @@ import logging
|
|
| 5 |
from pwc_mcp.app import (
|
| 6 |
MAX_RESPONSE_BODY_SIZE,
|
| 7 |
ResponseSizeLimitMiddleware,
|
|
|
|
| 8 |
_client_address,
|
|
|
|
| 9 |
create_app,
|
| 10 |
)
|
| 11 |
from starlette.applications import Starlette
|
|
@@ -256,6 +258,109 @@ def test_loopback_first_party_clients_may_name_their_rate_limit_identity():
|
|
| 256 |
assert _client_address({"client": ("127.0.0.1", 1)}, long, True) == "127.0.0.1"
|
| 257 |
|
| 258 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
def test_serialized_mcp_response_limit_fails_closed():
|
| 260 |
async def oversized(_request):
|
| 261 |
return Response(b"x" * (MAX_RESPONSE_BODY_SIZE + 1))
|
|
|
|
| 5 |
from pwc_mcp.app import (
|
| 6 |
MAX_RESPONSE_BODY_SIZE,
|
| 7 |
ResponseSizeLimitMiddleware,
|
| 8 |
+
RateLimitMiddleware,
|
| 9 |
_client_address,
|
| 10 |
+
_trusted_proxy_networks,
|
| 11 |
create_app,
|
| 12 |
)
|
| 13 |
from starlette.applications import Starlette
|
|
|
|
| 258 |
assert _client_address({"client": ("127.0.0.1", 1)}, long, True) == "127.0.0.1"
|
| 259 |
|
| 260 |
|
| 261 |
+
HF_PROXIES = _trusted_proxy_networks("10.0.0.0/8, fd00::/8")
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def test_trusted_proxy_peer_uses_the_rightmost_untrusted_forwarded_hop():
|
| 265 |
+
def address(peer: str, forwarded: str | None, **extra: str) -> str:
|
| 266 |
+
values = dict(extra)
|
| 267 |
+
if forwarded is not None:
|
| 268 |
+
values["x-forwarded-for"] = forwarded
|
| 269 |
+
return _client_address(
|
| 270 |
+
{"client": (peer, 1)}, Headers(values), True, HF_PROXIES
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
assert address("10.1.2.3", "203.0.113.9") == "203.0.113.9"
|
| 274 |
+
# Hops left of the proxy's own entry are client-supplied and ignored.
|
| 275 |
+
assert address("10.1.2.3", "198.51.100.1, 203.0.113.9") == "203.0.113.9"
|
| 276 |
+
# Trusted intermediate proxies are skipped from the right.
|
| 277 |
+
assert address("10.1.2.3", "203.0.113.9, 10.9.9.9") == "203.0.113.9"
|
| 278 |
+
assert address("fd00::5", "2001:db8::7, fd00::6") == "2001:db8::7"
|
| 279 |
+
# Repeated headers are read as one list in arrival order.
|
| 280 |
+
repeated = Headers(
|
| 281 |
+
raw=[(b"x-forwarded-for", b"198.51.100.1"), (b"x-forwarded-for", b"203.0.113.9")]
|
| 282 |
+
)
|
| 283 |
+
assert (
|
| 284 |
+
_client_address({"client": ("10.1.2.3", 1)}, repeated, True, HF_PROXIES)
|
| 285 |
+
== "203.0.113.9"
|
| 286 |
+
)
|
| 287 |
+
# Missing, fully trusted, or malformed chains fall back to the proxy peer.
|
| 288 |
+
assert address("10.1.2.3", None) == "10.1.2.3"
|
| 289 |
+
assert address("10.1.2.3", "10.4.4.4, 10.5.5.5") == "10.1.2.3"
|
| 290 |
+
assert address("10.1.2.3", "203.0.113.9, not-an-ip") == "10.1.2.3"
|
| 291 |
+
# Untrusted peers and disabled trust ignore the header entirely.
|
| 292 |
+
assert address("192.0.2.4", "203.0.113.9") == "192.0.2.4"
|
| 293 |
+
assert (
|
| 294 |
+
_client_address(
|
| 295 |
+
{"client": ("10.1.2.3", 1)},
|
| 296 |
+
Headers({"x-forwarded-for": "203.0.113.9"}),
|
| 297 |
+
False,
|
| 298 |
+
HF_PROXIES,
|
| 299 |
+
)
|
| 300 |
+
== "10.1.2.3"
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def test_proxied_hops_never_name_their_own_identity():
|
| 305 |
+
tagged = {"x-pwc-mcp-client": "chat-0123abcd"}
|
| 306 |
+
assert (
|
| 307 |
+
_client_address({"client": ("10.1.2.3", 1)}, Headers(tagged), True, HF_PROXIES)
|
| 308 |
+
== "10.1.2.3"
|
| 309 |
+
)
|
| 310 |
+
forwarded = Headers({**tagged, "x-forwarded-for": "203.0.113.9"})
|
| 311 |
+
assert (
|
| 312 |
+
_client_address({"client": ("10.1.2.3", 1)}, forwarded, True, HF_PROXIES)
|
| 313 |
+
== "203.0.113.9"
|
| 314 |
+
)
|
| 315 |
+
# Loopback behaviour is unchanged when proxies are configured.
|
| 316 |
+
assert (
|
| 317 |
+
_client_address({"client": ("127.0.0.1", 1)}, Headers(tagged), True, HF_PROXIES)
|
| 318 |
+
== "client:chat-0123abcd"
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def test_trusted_proxy_cidrs_are_opt_in_and_validated(monkeypatch):
|
| 323 |
+
def middleware(app):
|
| 324 |
+
while not isinstance(app, RateLimitMiddleware):
|
| 325 |
+
app = app.app
|
| 326 |
+
return app
|
| 327 |
+
|
| 328 |
+
monkeypatch.delenv("PWC_MCP_TRUSTED_PROXY_CIDRS", raising=False)
|
| 329 |
+
assert middleware(create_app(StubCatalog())).trusted_proxies == ()
|
| 330 |
+
monkeypatch.setenv("PWC_MCP_TRUSTED_PROXY_CIDRS", " 10.0.0.0/8 ,fd00::/8,")
|
| 331 |
+
assert [str(n) for n in middleware(create_app(StubCatalog())).trusted_proxies] == [
|
| 332 |
+
"10.0.0.0/8",
|
| 333 |
+
"fd00::/8",
|
| 334 |
+
]
|
| 335 |
+
for invalid in ("10.0.0.1/8", "not-a-cidr", "10.0.0.0/33"):
|
| 336 |
+
monkeypatch.setenv("PWC_MCP_TRUSTED_PROXY_CIDRS", invalid)
|
| 337 |
+
try:
|
| 338 |
+
create_app(StubCatalog())
|
| 339 |
+
except ValueError as error:
|
| 340 |
+
assert "PWC_MCP_TRUSTED_PROXY_CIDRS" in str(error)
|
| 341 |
+
continue
|
| 342 |
+
raise AssertionError(invalid)
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def test_clients_behind_a_trusted_proxy_get_separate_rate_limits():
|
| 346 |
+
app = create_app(
|
| 347 |
+
StubCatalog(),
|
| 348 |
+
allowed_hosts=["testserver"],
|
| 349 |
+
request_limit=1,
|
| 350 |
+
trusted_proxy_cidrs="10.0.0.0/8",
|
| 351 |
+
)
|
| 352 |
+
with TestClient(app, client=("10.1.2.3", 50000)) as client:
|
| 353 |
+
first = client.post("/mcp", json={}, headers={"x-forwarded-for": "203.0.113.9"})
|
| 354 |
+
other = client.post("/mcp", json={}, headers={"x-forwarded-for": "203.0.113.10"})
|
| 355 |
+
repeat = client.post(
|
| 356 |
+
"/mcp", json={}, headers={"x-forwarded-for": "1.2.3.4, 203.0.113.9"}
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
assert first.status_code != 429
|
| 360 |
+
assert other.status_code != 429
|
| 361 |
+
assert repeat.status_code == 429
|
| 362 |
+
|
| 363 |
+
|
| 364 |
def test_serialized_mcp_response_limit_fails_closed():
|
| 365 |
async def oversized(_request):
|
| 366 |
return Response(b"x" * (MAX_RESPONSE_BODY_SIZE + 1))
|